Filter posts by case-insensitive match in title and body. Extracted filter_by_lang and search_posts helpers to keep complexity in check.
456 lines
12 KiB
Ruby
456 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"
|
|
|
|
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
|
|
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
|
|
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")
|
|
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
|
|
{
|
|
"page" => page,
|
|
"page_size" => limit,
|
|
"total" => posts.length,
|
|
"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">
|
|
<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
|
|
<<~XML.chomp
|
|
<item>
|
|
<title>#{escape(post.title)}</title>
|
|
<link>#{escape(link)}</link>
|
|
<guid>#{escape(link)}</guid>
|
|
<description>#{escape(post.excerpt)}</description>#{pub}
|
|
</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
|
|
}
|
|
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?
|
|
|
|
store.save(post_from_params(params, existing: existing))
|
|
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)
|
|
|
|
nil
|
|
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"]),
|
|
"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 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
|