Files
ubicloud/clover.rb
2024-11-05 10:47:00 -08:00

505 lines
17 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# frozen_string_literal: true
require_relative "model"
require "roda"
require "tilt"
require "tilt/erubi"
class Clover < Roda
opts[:check_dynamic_arity] = false
opts[:check_arity] = :warn
Unreloader.require("routes/helpers") {}
plugin :all_verbs
plugin :assets, js: "app.js", css: "app.css", css_opts: {style: :compressed, cache: false}, timestamp_paths: true
plugin :disallow_file_uploads
plugin :flash
plugin :h
plugin :hash_branches
plugin :hash_branch_view_subdir
plugin :Integer_matcher_max
plugin :json
plugin :json_parser
plugin :public
plugin :render, escape: true, layout: "./layouts/app", template_opts: {chain_appends: true, freeze: true, skip_compiled_encoding_detection: true}
plugin :request_headers
plugin :typecast_params_sized_integers, sizes: [64], default_size: 64
plugin :sessions,
key: "_Clover.session",
cookie_options: {secure: !(Config.development? || Config.test?)},
secret: Config.clover_session_secret
plugin :route_csrf do |token|
flash["error"] = "An invalid security token submitted with this request, please try again"
return redirect_back_with_inputs
end
plugin :content_security_policy do |csp|
csp.default_src :none
csp.style_src :self, "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css"
csp.img_src :self, "data: image/svg+xml"
csp.form_action :self, "https://checkout.stripe.com"
csp.script_src :self, "https://cdn.jsdelivr.net/npm/jquery@3.7.0/dist/jquery.min.js", "https://cdn.jsdelivr.net/npm/dompurify@3.0.5/dist/purify.min.js", "https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js", "https://challenges.cloudflare.com/turnstile/v0/api.js"
csp.frame_src :self, "https://challenges.cloudflare.com"
csp.connect_src :self
csp.base_uri :none
csp.frame_ancestors :none
end
logger = if ENV["RACK_ENV"] == "test"
Class.new {
def write(_)
end
}.new
else
# :nocov:
$stderr
# :nocov:
end
plugin :common_logger, logger
plugin :not_found do
next if runtime?
@error = {
code: 404,
type: "ResourceNotFound",
message: "Sorry, we couldnt find the resource youre looking for."
}
if api?
{error: @error}.to_json
else
view "/error"
end
end
if Config.development?
# :nocov:
plugin :exception_page
class RodaRequest
def assets
exception_page_assets
super
end
end
# :nocov:
end
plugin :error_handler do |e|
# raise e
error = parse_error(e)
if error[:code] == 204
# nothing
elsif api? || runtime?
{error: error}
else
@error = error
case e
when Sequel::ValidationFailed
flash["error"] = @error[:message]
return redirect_back_with_inputs
when Validation::ValidationFailed
flash["error"] = @error[:message]
flash["errors"] = (flash["errors"] || {}).merge(@error[:details])
return redirect_back_with_inputs
end
# :nocov:
next exception_page(e, assets: true) if Config.development? && @error[:code] == 500
# :nocov:
view "/error"
end
end
plugin :rodauth, name: :api do
enable :argon2, :json, :jwt, :active_sessions, :login
only_json? true
use_jwt? true
# Converting rodauth error response to the common error format of the API
json_response_body do |hash|
# In case of an error, rodauth returns the error in the following format
# {
# (required) "error": "There was an error logging in"
# (optional) "field-error": [
# "password",
# "invalid password"
# ]
# }
if json_response_error?
error_message = hash["error"]
type, code = case error_message
when "There was an error logging in"
["InvalidCredentials", 401]
when "invalid JWT format or claim in Authorization header"
["InvalidRequest", 400]
when "Please login to continue"
["LoginRequired", 401]
else
# :nocov:
["AuthenticationError", 401]
# :nocov:
end
hash.clear
hash["error"] = {
"code" => code,
"type" => type,
"message" => error_message
}
end
hash.to_json
end
clear_session do
session.clear
set_jwt
end
hmac_secret Config.clover_session_secret
jwt_secret Config.clover_session_secret
argon2_secret { Config.clover_session_secret }
require_bcrypt? false
end
plugin :rodauth do
enable :argon2, :change_login, :change_password, :close_account, :create_account,
:lockout, :login, :logout, :remember, :reset_password,
:disallow_password_reuse, :password_grace_period, :active_sessions,
:verify_login_change, :change_password_notify, :confirm_password,
:otp, :webauthn, :recovery_codes
title_instance_variable :@page_title
# :nocov:
unless Config.development?
enable :disallow_common_passwords, :verify_account
email_from Config.mail_from
verify_account_view { view "auth/verify_account", "Verify Account" }
resend_verify_account_view { view "auth/verify_account_resend", "Resend Verification" }
verify_account_email_sent_redirect { login_route }
verify_account_email_recently_sent_redirect { login_route }
verify_account_set_password? false
send_verify_account_email do
Util.send_email(email_to, "Welcome to Ubicloud: Please Verify Your Account",
greeting: "Welcome to Ubicloud,",
body: ["To complete your registration and activate your account, click the button below.",
"If you did not initiate this registration process, you may disregard this message.",
"We're excited to serve you. Should you require any assistance, our customer support team stands ready to help at support@ubicloud.com."],
button_title: "Verify Account",
button_link: verify_account_email_link)
end
# Password Requirements
password_minimum_length 8
password_maximum_bytes 72
password_meets_requirements? do |password|
password.match?(/[a-z]/) && password.match?(/[A-Z]/) && password.match?(/[0-9]/)
end
invalid_password_message = "Password must have 8 characters minimum and contain at least one lowercase letter, one uppercase letter, and one digit."
password_does_not_meet_requirements_message invalid_password_message
password_too_short_message invalid_password_message
end
# :nocov:
hmac_secret Config.clover_session_secret
login_view { view "auth/login", "Login" }
login_redirect { "/after-login" }
login_return_to_requested_location? true
login_label "Email Address"
two_factor_auth_return_to_requested_location? true
already_logged_in { redirect login_redirect }
after_login { remember_login if request.params["remember-me"] == "on" }
update_session do
if Account[account_session_value].suspended_at
flash["error"] = "Your account has been suspended. " \
"If you believe there's a mistake, or if you need further assistance, " \
"please reach out to our support team at support@ubicloud.com."
forget_login
redirect login_route
end
super()
end
create_account_view { view "auth/create_account", "Create Account" }
create_account_redirect { login_route }
create_account_set_password? true
password_confirm_label "Password Confirmation"
before_create_account do
Validation.validate_cloudflare_turnstile(param("cf-turnstile-response"))
account[:id] = Account.generate_uuid
account[:name] = param("name")
Validation.validate_account_name(account[:name])
end
after_create_account do
account = Account[account_id]
account.create_project_with_default_policy("Default")
ProjectInvitation.where(email: account.email).each do |inv|
account.associate_with_project(inv.project)
if (managed_policy = Authorization::ManagedPolicy.from_name(inv.policy))
managed_policy.apply(inv.project, [account], append: true)
end
inv.destroy
end
end
reset_password_view { view "auth/reset_password", "Request Password" }
reset_password_request_view { view "auth/reset_password_request", "Request Password Reset" }
reset_password_redirect { login_route }
reset_password_email_sent_redirect { login_route }
reset_password_email_recently_sent_redirect { reset_password_request_route }
send_reset_password_email do
user = Account[account_id]
Util.send_email(user.email, "Reset Ubicloud Account Password",
greeting: "Hello #{user.name},",
body: ["We received a request to reset your account password. To reset your password, click the button below.",
"If you did not initiate this request, no action is needed. Your account remains secure.",
"For any questions or assistance, reach out to our team at support@ubicloud.com."],
button_title: "Reset Password",
button_link: reset_password_email_link)
end
after_reset_password do
remove_all_active_sessions_except_current
end
change_password_redirect "/account/change-password"
change_password_route "account/change-password"
change_password_view { view "account/change_password", "My Account" }
after_change_password do
remove_all_active_sessions_except_current
end
send_password_changed_email do
user = Account[account_id]
Util.send_email(email_to, "Ubicloud Account Password Changed",
greeting: "Hello #{user.name},",
body: ["Someone has changed the password for the account associated to this email address.",
"If you did not initiate this request or for any questions, reach out to our team at support@ubicloud.com."])
end
change_login_redirect "/account/change-login"
change_login_route "account/change-login"
change_login_view { view "account/change_login", "My Account" }
verify_login_change_view { view "auth/verify_login_change", "Verify Email Change" }
send_verify_login_change_email do |new_login|
user = Account[account_id]
Util.send_email(email_to, "Please Verify New Email Address for Ubicloud",
greeting: "Hello #{user.name},",
body: ["We received a request to change your account email to '#{new_login}'. To verify new email, click the button below.",
"If you did not initiate this request, no action is needed. Current email address can be used to login your account.",
"For any questions or assistance, reach out to our team at support@ubicloud.com."],
button_title: "Verify Email",
button_link: verify_login_change_email_link)
end
after_verify_login_change do
remove_all_active_sessions_except_current
end
close_account_redirect "/login"
close_account_route "account/close-account"
close_account_view { view "account/close_account", "My Account" }
before_close_account do
account = Account[account_id]
# Do not allow to close account if the project has resources and
# the account is the only user
if (project = account.projects.find { _1.accounts.count == 1 && _1.has_resources })
flash["error"] = "'#{project.name}' project has some resources. Delete all related resources first."
redirect "/project"
end
end
delete_account_on_close? true
delete_account do
account = Account[account_id]
account.projects.each { account.dissociate_with_project(_1) }
account.destroy
end
argon2_secret { Config.clover_session_secret }
require_bcrypt? false
# Multifactor Manage
two_factor_manage_route "account/multifactor-manage"
two_factor_manage_view { view "account/two_factor_manage", "My Account" }
# Multifactor Auth
two_factor_auth_view { view "auth/two_factor_auth", "Two-factor Authentication" }
two_factor_auth_notice_flash { login_notice_flash }
# don't show error message when redirected after login
# :nocov:
two_factor_need_authentication_error_flash { (flash["notice"] == login_notice_flash) ? nil : super() }
# :nocov:
# If the single multifactor auth method is setup, redirect to it
before_two_factor_auth_route do
redirect otp_auth_path if otp_exists? && !webauthn_setup?
redirect webauthn_auth_path if webauthn_setup? && !otp_exists?
end
# OTP Setup
otp_setup_route "account/multifactor/otp-setup"
otp_setup_view { view "account/multifactor/otp_setup", "My Account" }
otp_setup_link_text "Enable"
otp_setup_button "Enable One-Time Password Authentication"
otp_setup_notice_flash "One-time password authentication is now setup, please make note of your recovery codes"
otp_setup_error_flash "Error setting up one-time password authentication"
# :nocov:
after_otp_setup do
flash["notice"] = otp_setup_notice_flash
redirect "/" + recovery_codes_route
end
# :nocov:
# OTP Disable
otp_disable_route "account/multifactor/otp-disable"
otp_disable_view { view "account/multifactor/otp_disable", "My Account" }
otp_disable_link_text "Disable"
otp_disable_button "Disable One-Time Password Authentication"
otp_disable_notice_flash "One-time password authentication has been disabled"
otp_disable_error_flash "Error disabling one-time password authentication"
otp_disable_redirect { "/" + two_factor_manage_route }
# OTP Auth
otp_auth_view { view "auth/otp_auth", "One-Time" }
otp_auth_button "Authenticate Using One-Time Password"
otp_auth_link_text "One-Time Password Generator"
# Webauthn Setup
webauthn_setup_route "account/multifactor/webauthn-setup"
webauthn_setup_view { view "account/multifactor/webauthn_setup", "My Account" }
webauthn_setup_link_text "Add"
webauthn_setup_button "Setup Security Key"
webauthn_setup_notice_flash "Security key is now setup, please make note of your recovery codes"
webauthn_setup_error_flash "Error setting up security key"
webauthn_key_insert_hash { |credential| super(credential).merge(name: request.params["name"]) }
# :nocov:
after_webauthn_setup do
flash["notice"] = webauthn_setup_notice_flash
redirect "/" + recovery_codes_route
end
# :nocov:
# Webauthn Remove
webauthn_remove_route "account/multifactor/webauthn-remove"
webauthn_remove_view { view "account/multifactor/webauthn_remove", "My Account" }
webauthn_remove_link_text "Remove"
webauthn_remove_button "Remove Security Key"
webauthn_remove_notice_flash "Security key has been removed"
webauthn_remove_error_flash "Error removing security key"
webauthn_invalid_remove_param_message "Invalid security key to remove"
webauthn_remove_redirect { "/" + two_factor_manage_route }
# Webauthn Auth
webauthn_auth_view { view "auth/webauthn_auth", "Security Keys" }
webauthn_auth_button "Authenticate Using Security Keys"
webauthn_auth_link_text "Security Keys"
# Recovery Codes
recovery_codes_route "account/multifactor/recovery-codes"
recovery_codes_view { view "account/multifactor/recovery_codes", "My Account" }
recovery_codes_link_text "View"
add_recovery_codes_view { view "account/multifactor/recovery_codes", "My Account" }
auto_add_recovery_codes? true
auto_remove_recovery_codes? true
recovery_auth_view { view "auth/recovery_auth", "Recovery Codes" }
end
hash_branch("dashboard") do |r|
view "/dashboard"
end
hash_branch("after-login") do |r|
if (project = current_account.projects_dataset.order(:created_at, :name).first)
r.redirect "#{project.path}/dashboard"
else
r.redirect "/project"
end
end
# :nocov:
if Config.test?
hash_branch(:webhook_prefix, "test-error") do |r|
raise
end
end
# :nocov:
autoload_routes("web")
autoload_routes("api", "api")
autoload_routes("runtime", "runtime")
route do |r|
if api?
response.json = true
# To make test and development easier
# :nocov:
unless Config.production?
r.on("api") do
r.rodauth
rodauth.check_active_session
rodauth.require_authentication
r.hash_branches("api")
end
end
# :nocov:
r.rodauth
rodauth.check_active_session
rodauth.require_authentication
r.hash_branches("api")
else
r.on "runtime" do
@is_runtime = true
response.json = true
if (jwt_payload = get_runtime_jwt_payload).nil? || (@vm = Vm.from_ubid(jwt_payload["sub"])).nil?
fail CloverError.new(400, "InvalidRequest", "invalid JWT format or claim in Authorization header")
end
r.hash_branches("runtime")
end
r.public
r.assets
r.on "webhook" do
r.hash_branches(:webhook_prefix)
end
r.rodauth
check_csrf!
rodauth.load_memory
rodauth.check_active_session
r.root do
r.redirect rodauth.login_route
end
rodauth.require_authentication
r.hash_branches("")
end
end
end