mirror of
https://github.com/ubicloud/ubicloud.git
synced 2025-10-05 22:31:57 +08:00
Table-based model browsing can be friendlier than the unordered list display currently used on the admin site. However, you need to be careful to not introduce N+1 queries when using table-based browsing. AutoForme is a library that builds on top of Forme (already used on the admin site) and provides the ability to browse and search the models in a way that avoids N+1 queries. It's quite flexible, requiring only a few lines of configuration code per model to have it display and allow searching of the columns desired. AutoForme also supports CRUD actions for models, but those are currently disabled, and it is only used for the tabular display and searching. It also supports downloading of data in CSV format (both in browse and search mode), which can be useful with external analysis tools. One potential regression with the AutoForme based browsing and searching is the use of offsets for pagination, instead of using a filter. If this becomes problematic, it's possible to add filter-based pagination to AutoForme. While it is possible to implement table-based browsing without using AutoForme, it would require reimplementing parts of AutoForme, and I think using AutoForme will result in smaller and simpler code in the long run. Currently, this only implements the table-based browsing for Firewall as a proof of concept. We can expand it to other models in the future.
295 lines
8.1 KiB
Ruby
295 lines
8.1 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require_relative "model"
|
|
|
|
require "roda"
|
|
require "tilt"
|
|
require "tilt/erubi"
|
|
require "openssl"
|
|
|
|
class CloverAdmin < Roda
|
|
# :nocov:
|
|
plugin :exception_page if Config.development?
|
|
default_fixed_locals = if Config.production? || ENV["CLOVER_FREEZE"] == "1"
|
|
"()"
|
|
# :nocov:
|
|
else
|
|
"(_no_kw: nil)"
|
|
end
|
|
|
|
plugin :render, views: "views/admin", escape: true, assume_fixed_locals: true, template_opts: {
|
|
chain_appends: !defined?(SimpleCov),
|
|
freeze: true,
|
|
skip_compiled_encoding_detection: true,
|
|
scope_class: self,
|
|
default_fixed_locals:,
|
|
extract_fixed_locals: true
|
|
}
|
|
|
|
plugin :public
|
|
plugin :flash
|
|
plugin :h
|
|
|
|
plugin :content_security_policy do |csp|
|
|
csp.default_src :none
|
|
csp.style_src :self
|
|
csp.img_src :self # /favicon.ico
|
|
csp.script_src :self # webauthn
|
|
csp.form_action :self
|
|
csp.base_uri :none
|
|
csp.frame_ancestors :none
|
|
end
|
|
|
|
plugin :sessions,
|
|
key: "_CloverAdmin.session",
|
|
cookie_options: {secure: !(Config.development? || Config.test?)},
|
|
secret: OpenSSL::HMAC.digest("SHA512", Config.clover_session_secret, "admin-site")
|
|
|
|
plugin :typecast_params_sized_integers, sizes: [64], default_size: 64
|
|
plugin :typecast_params do
|
|
ubid_regexp = /\A[a-tv-z0-9]{26}\z/
|
|
|
|
handle_type(:ubid) do
|
|
it if ubid_regexp.match?(it)
|
|
end
|
|
|
|
handle_type(:ubid_uuid) do
|
|
UBID.to_uuid(it) if ubid_regexp.match?(it)
|
|
end
|
|
end
|
|
|
|
plugin :symbol_matchers
|
|
symbol_matcher(:ubid, /([a-tv-z0-9]{26})/)
|
|
|
|
plugin :not_found do
|
|
raise "admin route not handled: #{request.path}" if Config.test? && !ENV["DONT_RAISE_ADMIN_ERRORS"]
|
|
@page_title = "File Not Found"
|
|
view(content: "")
|
|
end
|
|
|
|
plugin :route_csrf do |token|
|
|
flash.now["error"] = "An invalid security token submitted with this request, please try again"
|
|
@page_title = "Invalid Security Token"
|
|
view(content: "")
|
|
end
|
|
|
|
plugin :error_handler do |e|
|
|
raise e if Config.test? && !ENV["DONT_RAISE_ADMIN_ERRORS"]
|
|
Clog.emit("admin route exception") { Util.exception_to_hash(e) }
|
|
@page_title = "Internal Server Error"
|
|
view(content: "")
|
|
end
|
|
|
|
plugin :forme_route_csrf
|
|
Forme.register_config(:clover_admin, base: :default, labeler: :explicit)
|
|
Forme.default_config = :clover_admin
|
|
|
|
def self.create_admin_account(login)
|
|
if Config.production? && defined?(Pry)
|
|
raise "cannot create admin account in production via pry as it would log the password"
|
|
end
|
|
|
|
password = SecureRandom.urlsafe_base64(16)
|
|
password_hash = rodauth.new(nil).password_hash(password)
|
|
DB.transaction do
|
|
id = DB[:admin_account].insert(login:)
|
|
DB[:admin_password_hash].insert(id:, password_hash:)
|
|
end
|
|
Clog.emit("Created admin account") { {admin_account_created: login} }
|
|
password
|
|
end
|
|
|
|
plugin :rodauth, route_csrf: true do
|
|
enable :argon2, :login, :logout, :webauthn, :change_password
|
|
accounts_table :admin_account
|
|
password_hash_table :admin_password_hash
|
|
webauthn_keys_table :admin_webauthn_key
|
|
webauthn_user_ids_table :admin_webauthn_user_id
|
|
login_column :login
|
|
login_redirect do
|
|
uses_two_factor_authentication? ? "/webauthn-auth" : "/webauthn-setup"
|
|
end
|
|
check_csrf? false
|
|
require_bcrypt? false
|
|
title_instance_variable :@page_title
|
|
argon2_secret OpenSSL::HMAC.digest("SHA256", Config.clover_session_secret, "admin-argon2-secret")
|
|
hmac_secret OpenSSL::HMAC.digest("SHA512", Config.clover_session_secret, "admin-rodauth-hmac-secret")
|
|
function_name(&{
|
|
rodauth_get_salt: :rodauth_admin_get_salt,
|
|
rodauth_valid_password_hash: :rodauth_admin_valid_password_hash
|
|
}.to_proc)
|
|
|
|
password_minimum_length 16
|
|
password_maximum_bytes 72
|
|
password_meets_requirements? do |password|
|
|
super(password) && password.match?(/[a-z]/) && password.match?(/[A-Z]/) && password.match?(/[0-9]/)
|
|
end
|
|
end
|
|
|
|
ObjectAction = Data.define(:label, :flash, :action) do
|
|
def self.define(*, &action)
|
|
new(*, action)
|
|
end
|
|
|
|
def call(obj)
|
|
action.call(obj)
|
|
end
|
|
end
|
|
|
|
def self.object_action(...)
|
|
ObjectAction.define(...)
|
|
end
|
|
|
|
OBJECT_ACTIONS = {
|
|
"GithubRunner" => {
|
|
"provision" => object_action("Provision Spare Runner", "Spare runner provisioned", &:provision_spare_runner)
|
|
},
|
|
"PostgresResource" => {
|
|
"restart" => object_action("Restart", "Restart scheduled for PostgresResource", &:incr_restart)
|
|
},
|
|
"Strand" => {
|
|
"schedule" => object_action("Schedule Strand to Run Immediately", "Scheduled strand to run immediately") do |obj|
|
|
obj.this.update(schedule: Sequel::CURRENT_TIMESTAMP)
|
|
end
|
|
},
|
|
"Vm" => {
|
|
"restart" => object_action("Restart", "Restart scheduled for Vm", &:incr_restart)
|
|
},
|
|
"VmHost" => {
|
|
"accept" => object_action("Move to Accepting", "Host allocation state changed to accepting") do |obj|
|
|
obj.update(allocation_state: "accepting")
|
|
end,
|
|
"drain" => object_action("Move to Draining", "Host allocation state changed to draining") do |obj|
|
|
obj.update(allocation_state: "draining")
|
|
end,
|
|
"reset" => object_action("Hardware Reset", "Hardware reset scheduled for VmHost", &:incr_hardware_reset),
|
|
"reboot" => object_action("Reboot", "Reboot scheduled for VmHost", &:incr_reboot)
|
|
}
|
|
}.freeze
|
|
OBJECT_ACTIONS.each_value(&:freeze)
|
|
|
|
plugin :autoforme
|
|
autoforme_framework = ::AutoForme.for(:roda, self) do
|
|
# :nocov:
|
|
register_by_name if Config.development?
|
|
# :nocov:
|
|
|
|
order [:id]
|
|
supported_actions [:browse, :search]
|
|
form_options(wrapper: :div)
|
|
|
|
link = lambda do |obj|
|
|
"<a href=\"/model/#{obj.class}/#{obj.ubid}\">#{Erubi.h(obj.name)}</a>"
|
|
end
|
|
|
|
show_html do |obj, column|
|
|
case column
|
|
when :name
|
|
link.call(obj)
|
|
when :project, :location
|
|
link.call(obj.send(column))
|
|
end
|
|
end
|
|
|
|
model Firewall do
|
|
eager [:project, :location]
|
|
columns [:name, :project, :location, :description]
|
|
end
|
|
end
|
|
@autoforme_routes[nil] = autoforme_framework.route_proc
|
|
@autoforme_models = autoforme_framework.models
|
|
singleton_class.attr_reader :autoforme_models
|
|
|
|
route do |r|
|
|
r.public
|
|
check_csrf!
|
|
r.rodauth
|
|
rodauth.require_authentication
|
|
rodauth.require_two_factor_setup
|
|
|
|
# :nocov:
|
|
r.exception_page_assets if Config.development?
|
|
# :nocov:
|
|
|
|
r.on "model", /([A-Z][a-zA-Z]+)/ do |model_name|
|
|
begin
|
|
@klass = Object.const_get(model_name)
|
|
rescue NameError
|
|
next
|
|
end
|
|
|
|
next unless @klass.is_a?(Class) && @klass < ResourceMethods::InstanceMethods
|
|
|
|
r.get true do
|
|
limit = 101
|
|
ds = @klass.limit(limit).order(:id)
|
|
|
|
if (after = typecast_params.ubid_uuid("after"))
|
|
ds = ds.where { id > after }
|
|
end
|
|
|
|
@objects = ds.all
|
|
|
|
if @objects.length == limit
|
|
@objects.pop
|
|
@after = @objects.last.ubid
|
|
end
|
|
|
|
view("objects")
|
|
end
|
|
|
|
r.on :ubid do |ubid|
|
|
next unless (@obj = @klass[ubid])
|
|
|
|
r.get true do
|
|
view("object")
|
|
end
|
|
|
|
if (actions = OBJECT_ACTIONS[@obj.class.name])
|
|
r.is actions.keys do |key|
|
|
action = actions[key]
|
|
|
|
r.get do
|
|
@label = action.label
|
|
view("object_action")
|
|
end
|
|
|
|
r.post do
|
|
action.call(@obj)
|
|
flash["notice"] = action.flash
|
|
r.redirect("/model/#{@obj.class}/#{ubid}")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
r.on "autoforme" do
|
|
autoforme
|
|
end
|
|
|
|
r.root do
|
|
if (ubid = typecast_params.ubid("ubid")) && (klass = UBID.class_for_ubid(ubid))
|
|
r.redirect("/model/#{klass.name}/#{ubid}")
|
|
elsif typecast_params.nonempty_str("ubid")
|
|
flash.now["error"] = "Invalid ubid provided"
|
|
end
|
|
|
|
@grouped_pages = Page.active.reverse(:created_at, :summary).group_by_vm_host
|
|
@classes = Sequel::Model
|
|
.subclasses
|
|
.map { [it, it.subclasses] }
|
|
.flatten
|
|
.select { it < ResourceMethods::InstanceMethods }
|
|
.sort_by(&:name)
|
|
|
|
view("index")
|
|
end
|
|
|
|
# :nocov:
|
|
if Config.test?
|
|
# :nocov:
|
|
r.get("error") { raise }
|
|
end
|
|
end
|
|
end
|