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.
492 lines
14 KiB
Ruby
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
|