- Add OPTIONS /api/volumen/* for CORS preflight (S-22). - Invalidate session in require_login! when user no longer exists (S-8). - Add check_csrf! to POST /admin/preview (S-1). - Use File.realpath for symlink-safe containment in media_file (S-2). - Memoize Users#all with mtime-based invalidation (S-12). - Document that empty permitted_hosts means allow-all (S-5). - Warn and fall back to random when session_key is too short (S-20).
140 lines
3.6 KiB
Ruby
140 lines
3.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "fileutils"
|
|
require "toml-rb"
|
|
require_relative "password"
|
|
|
|
module Volumen
|
|
# File-backed set of admin users, stored as a TOML `[[users]]` array.
|
|
#
|
|
# Each user has a role: "admin" (full access, including user management) or
|
|
# "author" (posts and own account only). If the file does not exist yet, a
|
|
# single bootstrap "admin" user is synthesised from the legacy
|
|
# `admin.password_hash` config value.
|
|
class Users
|
|
ROLES = %w[admin author].freeze
|
|
DEFAULT_ROLE = "author"
|
|
|
|
User = Struct.new(:username, :password_hash, :role)
|
|
|
|
def initialize(path:, bootstrap_hash: nil)
|
|
@path = path
|
|
@bootstrap_hash = bootstrap_hash.to_s
|
|
@all = nil
|
|
end
|
|
|
|
def all
|
|
mtime = File.exist?(@path) ? File.mtime(@path) : nil
|
|
@all = nil if mtime != @all_mtime
|
|
@all ||= begin
|
|
@all_mtime = mtime
|
|
if File.exist?(@path)
|
|
read_file
|
|
elsif @bootstrap_hash.empty?
|
|
[]
|
|
else
|
|
[User.new("admin", @bootstrap_hash, "admin")]
|
|
end
|
|
end
|
|
end
|
|
|
|
def any?
|
|
all.any?
|
|
end
|
|
|
|
def find(username)
|
|
all.find { |user| user.username == username }
|
|
end
|
|
|
|
def authenticate(username, password)
|
|
user = find(username)
|
|
user if user && Password.verify(password, user.password_hash)
|
|
end
|
|
|
|
def add(username, password, role = DEFAULT_ROLE)
|
|
return nil if username.to_s.empty? || find(username)
|
|
|
|
user = User.new(username, Password.hash(password), normalize_role(role))
|
|
persist(all + [user])
|
|
user
|
|
end
|
|
|
|
def update_password(username, password)
|
|
mutate(username) { |user| user.password_hash = Password.hash(password) }
|
|
end
|
|
|
|
def rename(current_name, new_name)
|
|
return nil if new_name.to_s.empty? || find(new_name)
|
|
|
|
mutate(current_name) { |user| user.username = new_name }
|
|
end
|
|
|
|
def set_role(username, role)
|
|
return nil unless ROLES.include?(role)
|
|
|
|
users = all
|
|
user = users.find { |entry| entry.username == username }
|
|
return nil if user.nil? || demoting_last_admin?(users, user, role)
|
|
|
|
user.role = role
|
|
persist(users)
|
|
user
|
|
end
|
|
|
|
def delete(username)
|
|
users = all
|
|
target = users.find { |entry| entry.username == username }
|
|
return nil if target.nil? || users.length <= 1 || last_admin?(users, target)
|
|
|
|
persist(users.reject { |entry| entry.username == username })
|
|
username
|
|
end
|
|
|
|
private
|
|
|
|
def mutate(username)
|
|
users = all
|
|
user = users.find { |entry| entry.username == username }
|
|
return nil if user.nil?
|
|
|
|
yield user
|
|
persist(users)
|
|
user
|
|
end
|
|
|
|
def normalize_role(role)
|
|
ROLES.include?(role) ? role : DEFAULT_ROLE
|
|
end
|
|
|
|
def admins(users)
|
|
users.select { |user| user.role == "admin" }
|
|
end
|
|
|
|
def last_admin?(users, user)
|
|
user.role == "admin" && admins(users).length <= 1
|
|
end
|
|
|
|
def demoting_last_admin?(users, user, role)
|
|
user.role == "admin" && role != "admin" && admins(users).length <= 1
|
|
end
|
|
|
|
def read_file
|
|
data = TomlRB.load_file(@path)
|
|
Array(data["users"]).map do |entry|
|
|
User.new(entry["username"], entry["password_hash"], entry["role"] || "admin")
|
|
end
|
|
end
|
|
|
|
def persist(users)
|
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
tmp = "#{@path}.tmp"
|
|
File.write(tmp, TomlRB.dump("users" => users.map { |user| user_hash(user) }))
|
|
File.rename(tmp, @path)
|
|
end
|
|
|
|
def user_hash(user)
|
|
{ "username" => user.username, "password_hash" => user.password_hash, "role" => user.role }
|
|
end
|
|
end
|
|
end
|