Files
volumen/lib/volumen/config.rb
T
petrbalvin d7f9813484 fix: prevent Config DEFAULTS mutation through deep_merge
Freeze inner hashes in DEFAULTS and rewrite deep_merge to build fresh
hashes with {}.merge for uncopied inner maps. Previously, inner hashes
shared objects with the frozen-looking DEFAULTS, so mutating a loaded
config silently poisoned all later loads.
2026-06-25 22:06:49 +02:00

152 lines
3.8 KiB
Ruby

# frozen_string_literal: true
require "toml-rb"
require "fileutils"
module Volumen
# Loads and merges volumen configuration from a TOML file, then applies
# command-line overrides on top of the file values and built-in defaults.
class Config
DEFAULT_PATH = "/etc/volumen/config.toml"
DEFAULTS = {
"server" => { "host" => "0.0.0.0", "port" => 9090 }.freeze,
"content_dir" => "/var/lib/volumen/posts",
"users_file" => "/var/lib/volumen/users.toml",
"site" => {
"title" => "My Blog",
"description" => "A blog powered by volumen.",
"base_url" => "https://example.com",
"language" => "en",
"author" => "Anonymous"
}.freeze,
"admin" => {
"password_hash" => "",
"session_key" => "",
"session_ttl" => 86_400
}.freeze
}.freeze
TEMPLATE = <<~TOML
# volumen configuration.
[server]
host = "0.0.0.0"
port = 9090
# Directory of your Markdown posts (.md with TOML frontmatter).
content_dir = "/var/lib/volumen/posts"
# File storing admin users (managed from the admin Settings page).
users_file = "/var/lib/volumen/users.toml"
[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"
[admin]
# Bootstrap admin login: paste a hash from `volumen hash-password` to sign
# in as user "admin". Once you manage users in Settings, users_file wins.
password_hash = ""
# Secret signing session cookies (>= 64 bytes). Empty = random per start.
session_key = ""
# Session lifetime in seconds (24h default).
session_ttl = 86400
TOML
def self.load(path: DEFAULT_PATH, overrides: {})
data = File.exist?(path) ? TomlRB.load_file(path) : {}
merged = deep_merge(DEFAULTS, data)
new(apply_overrides(merged, overrides))
end
# Writes the commented template to `path` if it does not exist yet.
# Returns the path on success, or nil if it already existed or could not
# be created.
def self.generate_default(path)
return nil if File.exist?(path)
FileUtils.mkdir_p(File.dirname(path))
File.write(path, TEMPLATE)
path
rescue SystemCallError
nil
end
def self.deep_merge(base, override)
result = {}
base.each do |key, base_val|
result[key] = if override.key?(key)
merge_leaf(base_val, override[key])
elsif base_val.is_a?(Hash)
{}.merge(base_val)
else
base_val
end
end
override.each do |key, val|
result[key] = val unless result.key?(key)
end
result
end
def self.merge_leaf(base_val, override_val)
if base_val.is_a?(Hash) && override_val.is_a?(Hash)
deep_merge(base_val, override_val)
else
override_val
end
end
def self.apply_overrides(data, overrides)
server = data["server"].dup
server["host"] = overrides[:host] if overrides[:host]
server["port"] = overrides[:port] if overrides[:port]
result = data.dup
result["server"] = server
result["content_dir"] = overrides[:content] if overrides[:content]
result
end
private_class_method :deep_merge, :apply_overrides
attr_reader :data
def initialize(data)
@data = data
end
def host
@data["server"]["host"]
end
def port
@data["server"]["port"]
end
def content_dir
@data["content_dir"]
end
def users_file
@data["users_file"]
end
def site
@data["site"]
end
def language
@data["site"]["language"]
end
def admin
@data["admin"]
end
end
end