435 lines
12 KiB
Ruby
435 lines
12 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/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
|
|
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 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_media_type", "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 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"]),
|
|
"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
|