Files
volumen/lib/volumen/server.rb
T

489 lines
14 KiB
Ruby

# frozen_string_literal: true
require "sinatra/base"
require "json"
require "date"
require "securerandom"
require_relative "config"
require_relative "store"
require_relative "markdown"
require_relative "password"
require_relative "api_routes"
require_relative "admin_routes"
require_relative "feed_helpers"
module Volumen
# Sinatra application: the public JSON API at /api/volumen and the
# server-rendered admin at /admin.
class Server < Sinatra::Base
MAX_LOGIN_ATTEMPTS = 10
LOGIN_WINDOW = 60 # seconds
MAX_UPLOAD_BYTES = 10 * 1024 * 1024 # 10 MB
ALLOWED_UPLOAD_TYPES = %w[image/webp image/avif].freeze
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
register ApiRoutes
register AdminRoutes
helpers FeedHelpers
configure do
set :show_exceptions, false
set :raise_errors, false
set :host_authorization, { permitted_hosts: [] } # empty = allow all hosts
set :views, File.expand_path("web/views", __dir__)
end
# Returns a configured subclass bound to a specific config + store.
def self.configured(config:, store:)
secret = session_secret(config)
ttl = config.admin["session_ttl"].to_i
ttl = nil if ttl <= 0
Class.new(self) do
set :volumen_config, config
set :volumen_store, store
set :sessions, httponly: true, same_site: :strict, expire_after: ttl
set :session_secret, secret
end
end
def self.session_secret(config)
key = config.admin["session_key"].to_s
return SecureRandom.hex(64) if key.empty?
if key.bytesize < 64
warn "volumen: session_key is shorter than 64 bytes, falling back to random secret"
return SecureRandom.hex(64)
end
key
end
# Mark the session cookie Secure only when the request is HTTPS (directly or
# via a trusted X-Forwarded-Proto from nginx). Over plain HTTP (local dev)
# the cookie stays usable so the admin login round-trips.
before do
options = request.env["rack.session.options"]
options[:secure] = request.ssl? if options
if request.path_info.start_with?("/admin")
headers "Content-Security-Policy" => [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"font-src 'self'",
"connect-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'"
].join("; ")
end
end
private
def config
settings.volumen_config
end
def store
settings.volumen_store
end
# --- API helpers -----------------------------------------------------
def json(data)
JSON.generate(data)
end
def published_posts
@published_posts ||= store.all
.reject { |post| post.draft? || post.scheduled? }
.sort_by { |post| post.date_string || "" }.reverse
end
def clear_posts_cache!
@published_posts = nil
end
def site_payload
config.site.slice("title", "description", "base_url", "language", "author",
"fediverse_creator")
end
def site_fediverse_creator
config.site["fediverse_creator"]
end
def posts_payload
posts = filtered_posts
page = positive_int(params["page"], 1)
limit = positive_int(params["limit"], 20).clamp(1, 100)
offset = (page - 1) * limit
total = posts.length
{
"page" => page,
"page_size" => limit,
"total" => total,
"has_next" => offset + limit < total,
"has_prev" => page > 1,
"posts" => posts[offset, limit].to_a.map(&:summary)
}
end
def filtered_posts
posts = published_posts
posts = filter_by_lang(posts)
posts = posts.select { |post| post.tags.include?(params["tag"]) } if params["tag"]
posts = search_posts(posts) if params["q"]
posts
end
def filter_by_lang(posts)
return posts unless params["lang"]
lang = params["lang"]
posts.select { |post| post.lang == lang || post.all_langs? }
end
def search_posts(posts)
query = params["q"].to_s.strip.downcase
return posts if query.empty?
posts.select do |post|
post.title.to_s.downcase.include?(query) ||
post.body.downcase.include?(query)
end
end
def tag_counts
counts = Hash.new(0)
published_posts.each { |post| post.tags.each { |tag| counts[tag] += 1 } }
counts.sort_by { |name, count| [-count, name] }
.map { |name, count| { "name" => name, "count" => count } }
end
def positive_int(value, default)
number = value.to_i
number.positive? ? number : default
end
def base_url
config.site["base_url"].to_s.chomp("/")
end
def escape(text)
Rack::Utils.escape_html(text.to_s)
end
alias h escape
# --- Admin helpers ---------------------------------------------------
def users
Users.new(path: config.users_file, bootstrap_hash: config.admin["password_hash"])
end
def authenticated?
!session[:user].to_s.empty?
end
def current_user
session[:user]
end
def current_user_record
users.find(current_user)
end
def current_role
current_user_record&.role
end
def current_user_name
current_user_record&.name
end
def current_user_fediverse
current_user_record&.fediverse_creator
end
def current_user_photo
current_user_record&.photo
end
def admin?
current_role == "admin"
end
def require_login!
unless authenticated?
redirect to("/admin/login")
return
end
return unless current_user_record.nil?
session.clear
redirect to("/admin/login")
end
def require_admin!
halt(403, "Forbidden") unless admin?
end
def csrf_token
session[:csrf] ||= SecureRandom.hex(32)
end
def check_csrf!
token = params["_csrf"]
return if token && session["csrf"] && Rack::Utils.secure_compare(session["csrf"], token)
halt(403, "Invalid CSRF token")
end
def validate_upload!(upload)
if upload[:tempfile].size > MAX_UPLOAD_BYTES
halt(413, json("error" => "file_too_large", "max_bytes" => MAX_UPLOAD_BYTES))
end
content_type = upload[:type].to_s.split(";").first.to_s.strip.downcase
return if ALLOWED_UPLOAD_TYPES.include?(content_type)
halt(415, json("error" => "unsupported_format",
"message" => "Only WebP (.webp) and AVIF (.avif) images are supported. " \
"Please convert your image before uploading — for example with " \
"`cwebp` (JPEG/PNG → WebP) or `avifenc` (PNG/JPEG → AVIF).",
"allowed" => ALLOWED_UPLOAD_TYPES))
end
def check_login_rate_limit!
Volumen.sweep_login_attempts!
ip = request.ip.to_s
now = Time.now.to_f
cutoff = now - LOGIN_WINDOW
attempts = (Volumen.login_attempts[ip] ||= []).select { |t| t > cutoff }
if attempts.length >= MAX_LOGIN_ATTEMPTS
halt(429, "Too many login attempts. Please wait and try again.")
end
attempts << now
Volumen.login_attempts[ip] = attempts
end
def render_settings(error: nil, notice: nil)
@error = error
@notice = notice
@users = users.all
erb :settings
end
def change_password
unless users.authenticate(current_user, params["current_password"])
return render_settings(error: "Current password is incorrect.")
end
fresh = presence(params["new_password"])
return render_settings(error: "New password cannot be empty.") if fresh.nil?
users.update_password(current_user, fresh)
render_settings(notice: "Password updated.")
end
def change_username
new_name = presence(params["username"])
return render_settings(error: "Username cannot be empty.") if new_name.nil?
unless new_name.match?(/\A[a-zA-Z0-9._-]+\z/)
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
end
return render_settings(notice: "Username unchanged.") if new_name == current_user
if users.rename(current_user, new_name)
session[:user] = new_name
render_settings(notice: "Username updated.")
else
render_settings(error: "That username is already taken.")
end
end
def change_name
name = params["name"].to_s.strip
name = nil if name.empty?
users.update_name(current_user, name)
render_settings(notice: name ? "Display name updated." : "Display name cleared.")
end
def change_fediverse_creator
value = params["fediverse_creator"].to_s.strip
if value.empty?
users.update_fediverse_creator(current_user, nil)
return render_settings(notice: "Fediverse handle cleared.")
end
unless value.match?(Post::FEDIVERSE_CREATOR_REGEX)
return render_settings(error: "Fediverse handle must look like @user@host.")
end
users.update_fediverse_creator(current_user, value)
render_settings(notice: "Fediverse handle updated.")
end
def change_photo
upload = params["photo"]
unless upload.is_a?(Hash) && upload[:tempfile]
return render_settings(error: "No file selected.")
end
validate_upload!(upload)
url = store.store_user_photo(current_user, upload[:filename], upload[:tempfile])
users.update_photo(current_user, url)
render_settings(notice: "Profile photo updated.")
end
def remove_photo
users.update_photo(current_user, nil)
render_settings(notice: "Profile photo removed.")
end
def create_user
name = presence(params["username"])
password = presence(params["password"])
if name.nil? || password.nil?
return render_settings(error: "Username and password are required.")
end
unless name.match?(/\A[a-zA-Z0-9._-]+\z/)
return render_settings(error: "Username may use letters, numbers, dot, dash, underscore.")
end
if users.add(name, password, params["role"].to_s)
render_settings(notice: "User added.")
else
render_settings(error: "That username already exists.")
end
end
def remove_user(username)
if username == current_user
return render_settings(error: "You cannot delete your own account.")
end
if users.delete(username)
render_settings(notice: "User removed.")
else
render_settings(error: "Cannot remove the last user or the last admin.")
end
end
def change_role(username)
return render_settings(error: "You cannot change your own role.") if username == current_user
if users.set_role(username, params["role"].to_s)
render_settings(notice: "Role updated.")
else
render_settings(error: "Could not change role (last admin?).")
end
end
def sorted_posts
store.all.sort_by { |post| post.date_string || "" }.reverse
end
def create_post
post = post_from_params(params)
error = creation_error(post)
if error
@mode = :new
@post = post
@error = error
return erb(:form)
end
store.save(post)
clear_posts_cache!
redirect to("/admin/")
end
def update_post
existing = store.find(params["slug"])
halt(404, "Not found") if existing.nil?
post = post_from_params(params, existing: existing)
error = fediverse_creator_error(post)
if error
@mode = :edit
@post = post
@error = error
return erb(:form)
end
store.save(post)
clear_posts_cache!
redirect to("/admin/")
end
def creation_error(post)
return "Slug is required." if presence(post.slug).nil?
return "Invalid slug." unless post.slug.match?(SLUG_REGEX)
return "A post with that slug already exists." if store.find(post.slug)
fediverse_creator_error(post)
end
def post_from_params(http_params, existing: nil)
metadata = base_metadata(http_params, existing: existing)
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
end
def base_metadata(http_params, existing:)
{
"title" => presence(http_params["title"]),
"slug" => presence(http_params["slug"]) || existing&.slug,
"lang" => presence(http_params["lang"]),
"author" => presence(http_params["author"]),
"fediverse_creator" => presence(http_params["fediverse_creator"]),
"date" => parse_date(http_params["date"]),
"publish_at" => parse_date(http_params["publish_at"]),
"tags" => parse_tags(http_params["tags"]),
"excerpt" => presence(http_params["excerpt"]),
"cover" => presence(http_params["cover"]),
"cover_alt" => presence(http_params["cover_alt"]),
"cover_caption" => presence(http_params["cover_caption"]),
"draft" => http_params["draft"] == "on",
"all_langs" => http_params["all_langs"] == "on"
}
end
def fediverse_creator_error(post)
value = post.metadata["fediverse_creator"]
return nil if value.nil?
if Post::FEDIVERSE_CREATOR_REGEX.match?(value.to_s)
nil
else
"Fediverse creator must look like @user@host."
end
end
def clean_metadata(metadata)
metadata.reject { |_key, value| value.nil? || value == "" || value == [] || value == false }
end
def presence(value)
text = value.to_s.strip
text.empty? ? nil : text
end
def parse_date(value)
text = presence(value)
text && Date.iso8601(text)
rescue ArgumentError
nil
end
def parse_tags(value)
presence(value).to_s.split(",").map(&:strip).reject(&:empty?)
end
end
end