Dependencies and security
Ruby stdlib only -- no gems, no Bundler; external binaries are fine. git clone + system Ruby is the entire installation, and nothing can bit-rot in a dependency tree. Where stdlib can't reach (rsync, git, rclone, sftp, $EDITOR), the engine shells out to system binaries the user already understands. Cost: some things are hand-rolled that a gem would provide -- multipart uploads, JPEG/MP4 header parsing, YAML-adjacent frontmatter, and a static file server for ./blog.sh preview (lib/preview_server.rb, plain TCPServer) instead of the webrick-dependent ruby -run -e httpd one-liner.
HEIC is refused with instructions by default; converting it is an opt-in (media.convert_heic), never automatic. The iPhone's default photo format displays only in Safari, so silently attaching one puts a broken image in front of most readers -- but converting silently would mean the site serves a different file than the author handed over, and would make an image tool a de-facto dependency of the engine. So the default is the same honesty the engine uses elsewhere: stop before anything is copied or deleted, name the file, print the exact command. The opt-in conversion shells out to whatever the machine already has (sips is part of macOS; heif-convert, ImageMagick and vips are probed for an actual HEIC delegate first), and a missing or failing tool degrades back to the refusal. Detection is by content -- the ftyp box -- not extension, so a HEIC named .jpg is caught, and AVIF, which shares the container but which browsers render natively, is recognized and deliberately left alone. A converted staging file in incoming/ counts as consumed by a successful save, exactly like a directly copied photo. Pure-Ruby conversion was not an option to reject politely: HEIC decoding is HEVC decompression, and a native gem for it would break the no-gems promise for real. Cost: one more config key, and the conversion quality (JPEG, fixed 90) is not configurable -- a knob nobody asked for would outlive the question.
Platform players are built from the address, not from the platform's embed code -- and only for platforms whose address says enough. YouTube worked this way from the start; Vimeo, PeerTube, archive.org, Spotify, SoundCloud and Mixcloud now follow it (lib/embed.rb). The engine stores a provider and an id, never foreign HTML, so a post carries no third party's markup or tracking, and a platform changing its embed path is one edit here rather than a re-import of every post. It also costs no network call at any point: writing a post stays an offline operation. Cost: six patterns that a platform can invalidate, each of which fails visibly (an empty player) rather than silently. The rules came from the live services, not their docs, which is where the corrections came from -- an unlisted Vimeo link needs its hash or the player answers 403, a Spotify URL copied from a browser carries an intl-xx segment the embed path rejects, SoundCloud has no id to extract so the whole watch URL is handed to the widget (which is also how a private track's secret_token gets through), and Mixcloud's widget redirects to a second hostname that the page's CSP has to allow as well.
Two platforms are asked once, when the post is saved -- the only network call in the authoring path. Funkwhale and Bandcamp cannot be handled by the string transform the other platforms use, and both were established against the live services rather than their docs: Funkwhale's obvious embed path builds a player that looks right and stays a permanently black rectangle on a current instance, while its oEmbed endpoint answers correctly; Bandcamp's page address contains no id at all, only a slug, and it has no oEmbed endpoint to ask, so the id comes from the page's own twitter:player metadata. The answer is stored in the post as an address, so an edit never asks again and the build stays offline -- and it is an address, not the HTML those services returned, because "a post carries no third party's markup" does not stop applying because a lookup was involved. A failed lookup (offline, a timeout, a service with no player for that address) is a sentence and a saved post with a link in it, never a refused save: writing on a train has to end with a written post. Cost: saving a post can now block on a slow service for as long as the shared HTTP deadline allows, and the CLI says which address it is waiting on.
A page's frame-src is computed from the players it actually carries. PeerTube is federated, so the host is a property of the post rather than of the engine: a fixed policy could not name it in advance, and widening frame-src for every site to cover every supported platform would hand every page permissions it does not use. Post pages and listings therefore compute their own frame origins from their blocks. The PeerTube host is taken with URI.parse and an explicit hostname shape, never pattern-matched out of the raw string -- https://good.example@evil.test/w/x reads as the good host to a careless regex and as the evil one to the browser. Cost: a page's CSP is no longer a constant, and a new provider has to say which origins it needs.
Every player carries its own referrer policy, because the site's belongs to whoever serves it. A host that sends Referrer-Policy: same-origin -- Cloudron's Surfer does, and it is a sensible default -- makes the browser send no Referer at all on a cross-origin subresource. The iframe reaches YouTube anonymous, and YouTube will not play for an embedder it cannot identify: a black rectangle reading "Error 153", which the service's own answer explains as EMBEDDER_IDENTITY_MISSING_REFERRER. An element's referrerpolicy overrides the document's, so the players the engine builds carry strict-origin-when-cross-origin and an imported embed_html has it added to the iframe it brought (Embed::REFERRER_POLICY). The alternative was to need a looser header, which is a broken player on every host that tightens one and a thing no README can make people read. Cost: the provider learns the site's origin, which is the least that identifies it -- never the path, never the query, and nothing at all for a reader who arrived over plain http. An embed that chose a policy of its own keeps it: the provider knows what its player needs.
A phone video is mentioned, not refused -- the opposite of HEIC, and for a measurable reason. A HEIC photo displays in Safari and nowhere else; HEVC video plays in the large majority of browsers, so refusing it would take away a video most readers could watch. The genuinely undeployable files are already stopped by the per-file size limit, and on real footage the two almost coincide -- the clip that is HEVC tends to be the one over the limit anyway. What was missing was a sentence at the moment the author can still act, so lib/video_probe.rb reads the video track's codec out of the file's own boxes (no ffprobe, the same box walk MediaDimensions already does) and the save says one line about it. The .mov container gets the same treatment for a different reason: the video inside is usually ordinary H.264, but not every browser accepts the container, and repacking it to .mp4 copies the video across untouched. Neither message blocks the save, and the suggested command names the real file -- ffmpeg is not installed on a Mac by default, so the message says where to get it. Cost: the engine now knows about codecs, which it did not before; a new codec that browsers disagree about would need a line here.
rexml is required lazily, inside each file that needs it, not at load time. rexml ships as a Ruby default gem -- present in a normal install, but some distros split their Ruby package and leave default gems out of the minimal one (Debian/Ubuntu's bare ruby vs. ruby-full). A build with no widgets.pixelfed/widgets.rss configured never touches require 'rexml/document' at all, so it can't fail over a dependency the site doesn't use; a LoadError when the widget is configured says exactly what to install rather than crashing the whole build. Cost: the require call moved from the top of two files to inside their fetch_items, a small deviation from every other file's load-time-requires convention.
No third-party requests from the visitor's browser. Widgets are fetched server-side on cron into same-origin JSON: visitors' IPs leak nowhere, GitHub's rate limit can't kill the sidebar, and a slow third party can't slow the page. Two exceptions are deliberate, and naming both matters -- an unlisted one reads later like an oversight somebody should "fix". The first is the comments thread, fetched client-side because it is the actual live discussion -- and that one lapses the moment comments.approval is on, since a moderated thread is no longer live and cron has read it already (see Publishing and comments). Avatars in comments remain hotlinked either way. The second is an analytics script, for a site that asks for one: analytics: is absent by default, nothing is added to any page without it, and the policy names that origin only while the section exists. Fonts are self-hosted for the same reason as the rest.
CSP without unsafe-inline, delivered as a meta tag. The single inline script (client i18n strings) is allowlisted by its SHA-256 content hash, so injected markup still can't execute. A meta tag instead of an HTTP header because several supported hosts (Surfer, Pages) can't set custom headers -- this way the policy travels with the pages to any host.
Everything from the Fediverse is escaped. Display names, avatars and URLs are attacker-controlled by definition (anyone can reply to a toot); only Mastodon's own sanitized status HTML is inserted as HTML, and that decision is documented where it happens.
