Files
volumen/lib/volumen/server.rb
T
petrbalvin bc2e056b68 feat: add has_next and has_prev to paginated posts response
Clients no longer need to compute page boundaries themselves; the API
now returns boolean flags indicating whether more pages exist in each
direction. Includes tests for first, middle, and last page.
2026-06-26 20:36:16 +02:00

492 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"
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/jpeg image/png image/gif image/webp image/avif image/svg+xml
].freeze
SLUG_REGEX = /\A[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\z/
register ApiRoutes
register AdminRoutes
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
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 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 feed_xml
site = config.site
items = published_posts.first(20).map { |post| feed_item(post) }.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
<title>#{escape(site["title"])}</title>
<link>#{escape(base_url)}</link>
<description>#{escape(site["description"])}</description>
<language>#{escape(site["language"])}</language>
#{items}
</channel>
</rss>
XML
end
def feed_item(post)
link = "#{base_url}/#{post.slug}"
pub = if post.metadata["date"]
"\n <pubDate>#{escape(rfc822_date(post))}</pubDate>"
else
""
end
creator = post.fediverse_creator
creator_tag = creator ? "\n <dc:creator>#{escape(creator)}</dc:creator>" : ""
<<~XML.chomp
<item>
<title>#{escape(post.title)}</title>
<link>#{escape(link)}</link>
<guid>#{escape(link)}</guid>
<description>#{escape(post.excerpt)}</description>#{pub}#{creator_tag}
</item>
XML
end
def rfc822_date(post)
value = post.metadata["date"]
case value
when Date then value.to_time.rfc822
when Time then value.rfc822
end
end
def feed_json
site = config.site
feed_url = "#{base_url}/api/volumen/feed.json"
{
"version" => "https://jsonfeed.org/version/1.1",
"title" => site["title"],
"home_page_url" => base_url,
"feed_url" => feed_url,
"description" => site["description"],
"language" => site["language"],
"items" => published_posts.first(20).map { |post| json_feed_item(post) }
}
end
def json_feed_item(post)
link = "#{base_url}/#{post.slug}"
item = {
"id" => link,
"url" => link,
"title" => post.title,
"content_html" => post.html,
"summary" => post.excerpt,
"date_published" => iso_date(post.metadata["date"]),
"tags" => post.tags
}
author = post.fediverse_creator || site_fediverse_creator
item["authors"] = [{ "name" => author }] if author
item.reject { |_k, v| v.nil? || v == "" || v == [] }
end
def iso_date(value)
case value
when Date then value.iso8601
when Time then value.to_date.iso8601
end
end
def sitemap_xml
urls = published_posts.map do |post|
url = " <url><loc>#{escape("#{base_url}/#{post.slug}")}</loc>"
url << "<lastmod>#{escape(post.date_string)}</lastmod>" if post.date_string
url << "</url>"
end.join("\n")
<<~XML
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}
</urlset>
XML
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 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 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 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)
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)
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 = {
"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"]),
"draft" => http_params["draft"] == "on",
"all_langs" => http_params["all_langs"] == "on"
}
post = Post.new(metadata: clean_metadata(metadata), body: http_params["body"].to_s)
post.path = existing.path if existing
post
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