cdn.si

rendered markdown titpetric

blog

A multi-user writing platform: a Go host serving a phpscript frontend over an SQLite storage layer. Users register with email and password, create projects alone or share them with co-writers, follow authors, subscribe to projects, and publish markdown articles with code highlighting, tables and pasted images or SVG diagrams. Projects live under their owner at /u/{username}/{project-slug}, articles below them; an author may also file an article under no project, and it then lives on the author’s page at /u/{username}/{slug}. Any resource is reachable by id alone through the /_/{ulid} shortlink.

Uses:

Dependency Source
phpscript https://github.com/titpetric/phpscript
mdedit https://git.cdn.si/titpetric/mdedit

How it fits together

Data model

Users and projects are many-to-many through project_member with roles: owners have full control; members are authors - they write, edit drafts and reviews, and publish their own work; reviewers read articles in the review and scheduled states before publication. Articles move through draft - review - scheduled - published; entering review notifies the project’s reviewers and owners, again on each re-entry; a scheduled article’s link shares as a countdown teaser that flips to the content at publish time, and post-publish edits stamp content_updated_at (“updated on”). An article homes in at most one project and can cross-list into others through article_project; an empty project_id files it under none as the author’s own post, public once published and theirs alone before that; its queues are the writing desk on /u/{username} and /trash, with /drafts left as a redirect to the first, and publishing it notifies the author’s followers. An article’s address is its home project’s owner and slug when it has a project, its author and slug otherwise; the path the page states is the one address a reader holds. Project visibility is public, private (members only) or invite (mailed invitations, redeemable only by an account holding the invited address; owners configure joining rules). Every article save appends a full snapshot to article_revision - the audit trail the owner reads at /p/{project}/{article}/audit, or at /articles/{id}/audit for a project-less article, whose author is its only controller. Follows (user to user), subscriptions (user to project) and tag follows (user to subject) are the three edges into /feed; the first two fan out durable notification rows inside the publish transaction, deduplicated per recipient across every listed project. A reader reviews and revokes all three at /settings/following, which the follower counts on your own profile link. Tag hubs at /t/{tag} list public articles site-wide, tumblr-style, and a followed tag lists PUBLIC articles only: following a subject grants no project access. /search finds published articles by a word in their title, description or slug - site-wide over public articles, or one project’s from the search box on its page.

A REPOST is an ordinary article citing another through a <!-- article#{id} --> marker in its markdown, rendered as a preview card and re-decided for whoever is reading - an article the viewer may not read renders a placeholder naming nothing. A body may hold any number of markers, and a reading list uses the same mechanism as a single repost; every save rebuilds article_embed from them the way article_image reconciles uploads. A marker inside a fenced code block is code to show: it renders literally and records no embed.

Readers like and bookmark articles (article_like, article_bookmark, with denormalised counters on article; the feed’s likes filter compares one column). Bookmarks have an Atom twin at /u/{username}/bookmarks.xml behind user.bookmark_visibility (private, followers, public). That setting decides who may open the LIST. Per-article project visibility applies on top, and a public reading list keeps a private project’s titles hidden.

Readers COMMENT on published articles (comment, with the text split into comment_body the way article_content splits from article, and a denormalised comment_count). Threads are one level deep: a reply to a reply re-points at the same top-level parent. Three settings govern a thread - who may comment (off, followers, public), whether a comment appears at once or waits (approve, instant), and who may reply (disabled, author_only, enabled) - each set on the project and again on the article, where inherit takes the project’s. An article can close what its project opens; it cannot open what the project closes. allow_reposts follows the same rule. An article filed under no project resolves against its author’s own floor, two toggles in /settings (user.comment_policy_default, user.comment_moderation_default). comment.visible defaults to 0: a new row lands in the moderation queue at /p/{slug}/comments, and instant posting writes 1 at insert; /comments is the same queue across every project the actor owns, linked from the account menu for whoever moderates one. An article in review takes feedback ahead of all this: its thread is live for exactly the project’s reviewers, owners and the author, whatever the settings say, and their comments post visible at once flagged review - a tag the thread keeps after publish. A comment the readers can see notifies the article’s author and the parent comment’s author in the insert transaction; a held one notifies its moderators (the project’s owners, or the author of a project-less article), and the thread’s notifications go out at approval; a rejected one tells its author of the refusal. A comment stays its author’s to edit, held or published alike, and the thread marks an edited one “(edited)” beside its timestamp.

Uploaded images live in the image table with an article_image link per referencing article; files land at data/upload/<yyyy>-<mm>/ named {user_id}-{image_id}.{ext}: the server builds every filename, and the extension comes from decoding the bytes (jpg/png/gif, plus script-free SVG served under a no-script CSP). Saving reconciles links against the markdown; an image with no links left loses its row and file; an hourly sweep collects orphans. Both projects and articles soft-delete (deleted_at).

Deleting an article trashes it: it leaves the queue for /p/{project}/trash (/trash when it has no project) with its tags, cross-listings, revisions and image links intact. A restore brings it back whole: into drafts, unpublished, with a suffixed slug when a live article holds its slug. Deleting a project is the same reversible verb: the project row and its home articles soft-delete, the invitations die with it (each one is a mailed secret), and memberships, subscriptions, cross-listings, tags and image links stay in place behind the liveness filter every reader applies; blog project restore <slug|id> is the operator’s way back, after which the articles wait in the project trash for an owner to restore. Purging is the destructive half: it removes the rows and unlinks the image files. Each project sets its own window in project_setting.trash_retention_days (0 keeps the trash forever, 30 days for a new project); the project-less trash takes that 30-day default; the hourly sweep and the daily prune job purge past it, and “Empty trash” does the same immediately.

Accounts and membership

Registration takes email, username, password and a birthdate, and refuses anyone under 18 before it writes a row: the site keeps no data about minors. Registering is agreeing to /terms-of-use, and the form says so. The profile in /settings holds the display name and the bio; the chrome’s disc, the profile head and every byline draw the display name’s initials. /login signs in existing accounts, with username or email and password, or passwordless: /login/link mails a sign-in link (ULID token, 15 minutes, single use, stored hashed) whose answer keeps it private whether the address has an account. A new account receives a verification link by mail (/verify/{token}, ULID, 48 hours, single use, stored hashed); until the reader follows it, every page shows a nag with a resend button. Verification gates nothing, and following either mailed link verifies the address it reached.

The password changes in /settings behind the current one: the other sessions sign out and the audit row records the change with empty values and holds no password; a forgotten password signs in through the emailed link, which the login page names as the way back. Changing the email needs the password: the change lands in the append-only user_audit table, the other sessions die, the old address gets a notice and the new one a fresh verification link. The username changes the same way, behind the password: links to the old /u/{username} stop resolving by design, the /_/{id} shortlink keeps working, and the other sessions sign out. The operator restores an account taken over through a stolen password from that trail, and only to an address it verifiably held. The terms of use also let the operator suspend an account: every session signs out, and the login gets the same generic refusal a wrong password does. The refusal keeps the suspension private by design. The same terms cover content removal: blog article purge destroys one article outright, rows and image files, live or trashed, with no role check and no trash step:

blog user audit <username|email>                  # the full audit trail
blog user emails <username|email>                 # the email audit trail
blog user restore-email <username|email> <email>  # back to a previous address, all sessions out
blog user suspend <username|email>                # all sessions out, logins refused generically
blog user unsuspend <username|email>              # the account signs in again
blog article purge <article-id>                   # destroy one article and its image files, no undo
blog project restore <slug|id>                    # bring a deleted project back; its articles wait in its trash

Deleting the account is self-serve behind the password, with trash semantics: the user row and sole-owned projects soft-delete, authored articles go to their trash, and the delete removes edges and notifications; projects with another owner stay theirs. /export.zip downloads the account’s projects and articles any time: projects/<slug>.md and articles/<yyyy-mm-dd>-<slug>.md, front matter a 1:1 dump of each row plus the projects: and tags: slugs, body the markdown. The image files the articles link ride along under uploads/<path>, and the export rewrites every /upload/<path> reference in a body or front-matter value to that archive path. The markdown stays whole outside the site.

Reading is free: following, subscribing, liking and bookmarking need only an account. Writing needs the membership: storage refuses creating a project, creating or publishing an article (a repost is an article), commenting and uploading for any account without user.member_since, and the frontend answers with /pricing - a one-time 1 € / 1 $ membership, with a recurring supporter plan priced from the same configuration file. Fees are not refunded; the supporter fee goes toward development and hosting, and stepping back from supporter to member loses nothing the account uses. Payments open when the deployment configures Stripe; until then the pricing page shows one list per plan: joining either grants the membership at once and records which plan the account pays for when payments open, and the same button switches between the lists. With Stripe configured the lists close: the page refuses a join and sends the reader to buy the membership. The operator comps by hand:

blog member waitlist                  # who is on which list, and whether they verified
blog member grant <username|email>    # member from now, off the members list
blog member revoke <username|email>   # a sanction: a waitlist click cannot undo it, a fresh grant does

Payments

Checkout runs through Stripe when the environment names it; with no key set the null provider answers: every page renders its amounts and says payments open later. The amounts live in config/plans.yml, embedded into the binary and overridable with BLOG_PLANS: the one-time membership at 1 € / 1 $, the supporter plan at 10 € / 10 $ a month (billed monthly; a year costs twelve months at the monthly price), the tip tiers (5, 10, 50, 100 plus pay-anything from 1 €) and the badge thresholds - bronze at 1, silver at 100, gold at 1000 of cumulative paid money, eur and usd counted alike, worn quietly beside the handle on the profile. Every price is before tax, and the file never holds a secret.

Stripe holds the real prices. Each plan names a stripe_lookup_key, and checkout charges the active Stripe price under it: set the sandbox and the live price to the same lookup keys (incubator_member, incubator_supporter_monthly) and the file works against both. Each price is in eur with a usd amount added through currency options, tax-exclusive, on a product with an eligible tax code; Checkout charges usd to a buyer in the US and eur to everyone else, which Adaptive Pricing converts to their local currency where it can. Checkout prices tips, and a plan with no lookup key, inline in eur under the file’s tax_code - a tip on top of the membership keeps that checkout in eur, because Checkout switches currency only when every line item has an amount in it. The account runs Managed Payments: Stripe is the merchant of record, adds and files the VAT or sales tax, and refuses any product without an eligible tax code.

STRIPE_SECRET_KEY turns checkouts on: /settings/payments - the account’s money page, linked from /settings - sends the buyer to a hosted Checkout Session and writes a pending payment row keyed by the session id. A restricted key needs Checkout Sessions: Write and Prices: Read. POST /stripe/webhook, verified with STRIPE_WEBHOOK_SECRET, acts on five event types and answers 200 to the rest, which stops Stripe retrying them:

Renewals and cancellations find the account through the user_id and plan the checkout writes into the subscription’s metadata; a subscription without them belongs to something else on the Stripe account, and the handler leaves it alone. Every settle is idempotent. Create the webhook endpoint on the Stripe API version the Go SDK pins (2025-08-27.basil for stripe-go v82.5.1); the handler tolerates a mismatch; keep the versions matched. A PAID payment is the identity of the membership: it grants writing even after blog member revoke, superseding the revoke in the audit trail. The operator records money taken outside Stripe:

blog payment record <username|email> <amount-eur> [note]  # a paid one-time payment; grants like a real one
blog payment list <username|email>                        # the account's history, total and badge

Configuration

Variable Default What it sets
PLATFORM_DB_BLOG sqlite://blog.db The database connection.
BLOG_FRONTEND, BLOG_DATA frontend, data The phpscript tree and the writable state (uploads).
BLOG_SITE_NAME Blog The name in the chrome and the mail.
BLOG_SITE_URL request origin The origin mailed links point at, e.g. https://incubator.to. Set it in production.
BLOG_SLUG_MIN 3 The shortest project slug.
BLOG_BACKUP_DIR unset Setting it makes the server snapshot into this directory once a day.
BLOG_BACKUP_KEEP_DAYS 14 Every backup prunes archives older than this, aged by the date in the name.
BLOG_PLANS embedded A plans.yml overriding the amounts, lookup keys, tip tiers and badge thresholds.
STRIPE_SECRET_KEY unset The Stripe API key. Unset leaves payments closed: prices render, checkouts refuse.
STRIPE_WEBHOOK_SECRET unset Verifies the signature on POST /stripe/webhook.

Mail is phpscript’s own: the verification email is the auth-verify-mail.tpl template sent with mail(), which delivers through the mail block of frontend/phpscript.yml - named servers since phpscript v0.6.0, with mail() resolving default. The committed file declares no mail block: development runs with mail unconfigured, and mail() throws catchably. A deployment that wants mail mounts its own file over frontend/phpscript.yml:

mail:
  default:
    host: "smtp.example.com"
    port: 587
    from: "team@example.com"
    username: "..."
    password: "..."

Every delivery is a telemetry span, a failed one naming its error. An unconfigured deployment is honest about it: the host warns once at start, /login/link answers that it cannot send a sign-in link, and the verification nag reports each resend’s outcome in a one-shot flash.

Development

atkins setup      # composer install for frontend/ (MiniTPL), route table check
atkins            # go fmt/test/build, php fmt/lint/test, semgrep, docker
atkins venom      # boot a throwaway server, run the black-box smoke suite
atkins playwright # boot a throwaway server, run the browser suite
atkins serve      # run against ./blog.db
atkins migrate    # mig migrate/lint/docs/gen after schema/ changes
atkins shots      # recapture docs/screenshots against a seeded world
atkins deploy     # rsync README.md and docs/ to cdn.si/titpetric/incubator

Compiled MiniTPL templates parse once per server process: MiniTPL recompiles a changed .tpl into frontend/templates/cache when the source is newer, but the runner caches the parsed program for the life of the process. A running deployment picks a template change up only with a restart - the blog host embeds the runner and takes no reload signal (kill -HUP and -s reload address phpscript’s standalone server). atkins clean drops the on-disk compile cache, for a stale compile after a template rename; a live process keeps its parsed programs.

The editor is one editable surface holding the markdown itself: a heading typed as ### reads at a heading’s size from the third character on, a table draws as a table with editable cells, an image draws its picture, and the pilcrow at the right of the toolbar shows or hides the marker characters. Preview renders through the same Go binding the published page uses, and shows what the article prints. The textarea the surface mounts over stays in the page hidden, holding name and the value the form posts; the no-JS fallback and every server-side check keep working unchanged. The editor folds line endings to LF on the way in: a browser submits a textarea as CRLF, and blackfriday stops recognising tables and fences when every line ends with a CR.

A picture lays out through an anchor on its address (![caption](photo.png#left), #right or #center), and an author floats one with plain markdown. site.css repeats the same three rules the editor draws with, keyed on an article body, a description or a bio, and a comment, with headings, fenced blocks and tables clearing the float. The fragment stays on the src the renderer writes and a browser strips it before the request. The upload route sees the plain path, and the markdown reads the same way outside the site.

Route files must survive phpscript lint -f frontend/phpscript.yml (frontend/tests/lint.php declares the Go bindings for the linter) and show up in phpscript list ./frontend/.... Semgrep rules in rules/ flag @ suppression, new Database and SQL inside frontend/. Create and edit forms submit over XHR to /api/project and /api/article (plain POST stays as the no-JS fallback).

Two test layers sit above the Go and PHP unit tests. atkins venom keeps everything a curl can see: 64 black-box cases over the running server. atkins playwright (tests/playwright/) is the browser layer - what needs JavaScript or two live sessions: the editor’s surface, its pilcrow, its table widget, its uploads and its preview, the slug probe, the account popover, and the persona journeys (specs/journey-*.spec.ts) that walk a visitor, reader, writer and owner end to end. It boots its own server on :18093 against a scratch database under tests/playwright/.run/ and drives the sandbox chromium, downloading no browser. Both run outside the default atkins chain.

The vocabulary - every term the product prints, the table behind it and the module that serves it - is in GLOSSARY.md. The roles and what each can do are in docs/user-stories.md, with the test that proves each story; the rendered views are in docs/screenshots.md. The captures themselves are not in git: atkins shots writes docs/screenshots/ and atkins deploy rsyncs this README and docs/ to https://cdn.si/titpetric/incubator/, where that page’s links point.

Backup

atkins backup (or blog backup <dir>) writes a consistent snapshot - VACUUM INTO for the database, no sqlite3 binary needed, plus the data/upload tree - as <dir>/snapshot-<yyyy-mm-dd>.tgz, /backup/blog by default (BACKUP_DIR overrides). The archive lands under its final name once complete, and the offsite machine pulling the directory collects whole files. Every write prunes archives older than BLOG_BACKUP_KEEP_DAYS (14 by default), aged by the date in the name, and setting BLOG_BACKUP_DIR makes the server take the same snapshot once a day. blog restore <archive.tgz> unpacks one into a fresh provision and refuses a live database.

blog import <archive.zip> --user <username|email> loads an /export.zip back in: projects first, then articles as drafts with their tags, listings and image files, then each article’s dumped state - published keeps its published date, a scheduled time already past publishes at that moment, a future one stays scheduled, and review waits in drafts. The import takes no identity from the archive: it mints every row fresh with the named account as owner and author (the account needs the membership writing requires), image files land under fresh names with the bodies rewritten to match, and a slug a live row already claims comes in suffixed -2, -3 … like a trash restore. An import is a copy: re-running one duplicates its projects and articles under suffixed slugs, and the import skips and reports a listing into a project the account cannot write in.

Schema

schema/ holds one *.up.sql file per table, applied by the Go host on start. Generated artefacts (schema/docs/, schema/schema.yml, model/types.mig.go) come from atkins migrate; do not edit them by hand.

mig records how far into each file a database has read and reads past that mark alone. A column added to a CREATE TABLE IF NOT EXISTS reaches a fresh database and skips every existing one. Every column added after a table’s first release is an appended ALTER TABLE. schema/statements.lock holds the hash of every statement shipped and schema/statements_test.go fails when one of them changes; after appending, run go test ./schema -run TestSchemaStatements -update.