diff --git a/README.md b/README.md index aa55e98..197e5d3 100644 --- a/README.md +++ b/README.md @@ -2,38 +2,53 @@ A self-hosted bridge that mirrors **one or many** Ghost blogs into [Standard.site](https://standard.site) records on AT Protocol PDSes. Each blog becomes its own `site.standard.publication` record (with its own handle, app password, theme, icon) and every published post becomes a `site.standard.document`. New posts sync automatically via Ghost webhooks; the `` tag gets dropped into each post's `` without you touching the Ghost editor. -Self-contained: one Docker container, one named volume, Caddy in front for TLS. Configuration is a CLI you run inside the container — no admin web UI to log into. +Self-contained: one Docker container, one named volume, Caddy (bundled or pre-existing) in front for TLS. Configuration is a CLI you run inside the container — no admin web UI to log into. -> **Status: v0.1 in development.** Multi-tenant refactor just landed; full end-to-end test against a real Ghost still pending. +> **Status: v0.1, end-to-end validated.** Production-tested against one Ghost on 2026-05-29. Not yet released or version-tagged. + +## What ghoststandard does + +For each Ghost blog you configure: + +- Creates a `site.standard.publication` record on your PDS (you pick the handle / app password / publication metadata) +- Listens for Ghost webhooks (auto-installed during setup) +- On `post.published` / `post.published.edited`: fetches the post, uploads its cover image as a blob, writes a `site.standard.document` record, and updates the post's per-post `codeinjection_head` so its `` carries the link tag — all without touching Ghost's editor UI +- On `post.unpublished` / `post.deleted`: deletes the document record from the PDS +- Tracks every post's `ghost_post_id` ↔ AT-URI mapping in a local SQLite (so deletes always know which record to remove) + +What it doesn't do automatically (you wire these up once per Ghost — see [Ghost-side configuration](#ghost-side-configuration)): + +- The `/.well-known/site.standard.publication` file on the blog's domain (reverse-proxy config, not Ghost config) +- The blog-homepage `` (theme or Ghost Code Injection — you pick the placement) ## What you need -- A Docker host with ports 80 and 443 reachable (or an existing reverse proxy — see [Deploying alongside another service](#deploying-alongside-another-service)) +- A Docker host with ports 80 and 443 reachable (or an existing reverse proxy on the host — see [Deploying alongside another service](#deploying-alongside-another-service)) - A hostname for the bridge (`bridge.example.com`) -- One or more Ghost instances with the Admin API enabled -- One AT Protocol PDS account per Ghost instance you want to bridge — your own (`pds.example.com`) or Bluesky (`bsky.social`). One bridge can mirror many blogs to many publications. -- An app password generated on each PDS account +- One or more self-hosted Ghost instances (the Admin API is the dependency — Ghost Pro is not currently supported) +- One AT Protocol PDS account per Ghost blog you want to bridge — your own (`pds.example.com`) or Bluesky (`bsky.social`). One bridge can mirror many blogs to many publications. +- An app password generated on each PDS account (NOT your personal Bluesky password) ## Quick start ```bash git clone cd ghoststandard -mkdir -p assets && cp ~/path/to/icon.png assets/icon.png # optional, used by 'Provide local file path' icon step GS_HOSTNAME=bridge.example.com docker compose up -d docker compose exec app gs setup ``` -`gs setup` collects the bridge's public URL (one-time) and then runs the **site-add wizard** for your first site. The wizard walks you through: +`gs setup` collects the bridge's public URL (one-time) and runs the **per-site wizard** for your first site. The wizard: 1. **PDS connection** — service URL, handle, app password (validated by login) 2. **Ghost connection** — URL + Admin API key (validated by a `posts.browse` call). Include any subpath: `https://blog.example.com` or `https://example.com/blog` both work. -3. **Site slug** — a short identifier for this site (used in the webhook URL and as an optional arg to per-site commands) -4. **Publication metadata** — defaults pulled from Ghost's `settings.browse()` (title, description, accent color, icon). Hit Enter through anything you don't want to override. -5. **Bootstrap** — creates the `site.standard.publication` record, auto-generates a webhook secret, and prints the three pieces of glue: - - The `/.well-known/site.standard.publication` content for that Ghost's reverse-proxy - - The four Ghost webhooks to add (URL + secret prefilled — URL includes the site slug) - - The `` to drop into the theme's homepage `` +3. **Site slug** — a short identifier for this site (used in the webhook URL and as the optional first argument to per-site commands) +4. **Publication metadata** — defaults pulled from Ghost's `/admin/site/` (title, description, accent color, icon). Hit Enter through anything you don't want to override. +5. **Bootstrap + auto-installs**: + - Creates the `site.standard.publication` record on the PDS + - Generates the webhook secret + - **Installs the four required Ghost webhooks via the Admin API** (no manual UI clicks) + - Prints the two pieces you still have to do by hand: the `.well-known` file and the blog-homepage ``. Both with ready-to-paste snippets. To add another site later: @@ -41,32 +56,166 @@ To add another site later: docker compose exec app gs site add ``` -## CLI +After all sites are set up, fire a test publish and tail the log: + +```bash +docker compose exec app gs logs +``` + +You should see `[] sync at://…` within a couple seconds of clicking Publish in Ghost. + +## Ghost-side configuration + +The bridge auto-handles webhooks and per-post `codeinjection_head` via the Admin API. The two things you wire up manually per site are below — both involve the reverse proxy or theme in front of Ghost, neither of which Ghost itself exposes a way to manage from the API. + +### 1. The `/.well-known/site.standard.publication` file + +This is how Standard.site verifies that you control the domain. The file must: + +- Be served at the exact path `/.well-known/site.standard.publication` +- Return `200` +- Have `Content-Type: text/plain` +- Contain your publication's AT-URI as the entire body + +The wizard prints the exact AT-URI to use. Below are three common Docker-Ghost patterns. + +#### Pattern A: official Ghost docker-compose with bundled Caddy + +This is the [TryGhost/docker](https://github.com/TryGhost/docker) compose template (also used by some community variants). Caddy sits in the same compose stack as Ghost and uses a `{$DOMAIN}` substitution. Find your Caddyfile (typically `/opt/ghost/caddy/Caddyfile` or `$GHOST_HOME/caddy/Caddyfile`) and add a `handle` block to your existing `{$DOMAIN}` site — **before** the catch-all `handle { reverse_proxy ghost:2368 }`: + +```caddy +{$DOMAIN} { + import snippets/Logging + import snippets/TrafficAnalytics + import snippets/ActivityPub + + # Standard.site publication verification + handle /.well-known/site.standard.publication { + header Content-Type text/plain + respond "at://did:plc:.../site.standard.publication/REPLACE_RKEY" + } + + # Default proxy everything else to Ghost + handle { + reverse_proxy ghost:2368 + } + + encode gzip + import snippets/SecurityHeaders +} +``` + +Two important details: + +- **Tabs, not spaces.** Caddy's `caddy fmt` convention uses tabs. Mixed indentation produces a non-fatal "Caddyfile input is not formatted" warning; pasting from markdown often converts tabs to spaces, so check with `cat -A` or run `caddy fmt --overwrite` after editing. +- **The path-matched `handle` must come BEFORE the catch-all `handle`.** Caddy stops at the first matching directive within a site block. + +Reload Caddy from the Ghost stack: + +```bash +cd /opt/ghost # or wherever your Ghost compose is +docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile +``` + +#### Pattern B: Ghost behind a host-installed Caddy + +If Caddy runs as a systemd service on the host (not in Docker), the Caddyfile is typically `/etc/caddy/Caddyfile`. Same `handle` block, same ordering rule. Reload: + +```bash +sudo systemctl reload caddy +``` + +#### Pattern C: Ghost behind nginx (Ghost-CLI default) + +Ghost-CLI sets up nginx on the host with vhost files in `/etc/nginx/sites-available/`. Add a `location =` block to your site (exact-match takes precedence over regex/prefix locations): + +```nginx +server { + server_name blog.example.com; + # … your usual ssl_certificate / ssl_certificate_key lines … + + # Standard.site publication verification + location = /.well-known/site.standard.publication { + default_type text/plain; + add_header Content-Type "text/plain"; + return 200 "at://did:plc:.../site.standard.publication/REPLACE_RKEY"; + } + + location / { + proxy_pass http://127.0.0.1:2368; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +Reload: + +```bash +sudo nginx -t && sudo systemctl reload nginx +``` + +#### Verification (any pattern) + +From a machine other than the Ghost host: + +```bash +curl -i https://your-blog.example.com/.well-known/site.standard.publication +``` + +You want `HTTP/2 200`, `content-type: text/plain`, body = your full AT-URI. If you get `404`, the reverse-proxy block isn't matching the path. If you get the Ghost 404 page, the catch-all proxy ran first — re-order so the well-known directive comes before it. + +### 2. The blog-homepage `` + +This advertises the publication's AT-URI in the HTML head. The wizard prints the exact tag with your AT-URI substituted in. Two reasonable placements: + +#### Option A — site-wide via Ghost Code Injection (easy) + +In Ghost Admin → Settings → Code injection → **Site Header**, paste the tag. Ghost renders it in `` on every page (home, posts, tags, etc). The per-post `` still wins as the canonical pointer for individual post pages, so there's no real conflict — just a slightly more-tags-than-strictly-needed `` on content pages. + +#### Option B — homepage-only via theme edit (precise) + +Edit your Ghost theme's `default.hbs` (or wherever the shared `` lives) and wrap the tag in Ghost's `{{#is "home"}}…{{/is}}` block: + +```handlebars +{{#is "home"}} + +{{/is}} +``` + +Renders only on the index page. Requires theme access (and theme upload + activation if the theme is bundled in your Ghost). Survives Ghost UI changes. + +## CLI reference ```bash # Setup / sites -docker compose exec app gs setup # first-run wizard -docker compose exec app gs site add # add another site -docker compose exec app gs site list # show all sites -docker compose exec app gs site show # per-site details -docker compose exec app gs site rm # remove a site - -# Operate on a site (slug optional if there's only one site) -docker compose exec app gs sync [] -docker compose exec app gs unsync [] -docker compose exec app gs backfill [] -docker compose exec app gs set-icon [] -docker compose exec app gs set-theme [] [skip|light|dark] +gs setup # first-run wizard +gs site add # add another site +gs site list # show all configured sites +gs site show # per-site details +gs site rm # remove a site (with optional PDS document cleanup) + +# Operate on a site — slug optional if there's only one site +gs sync [] # manually sync one post +gs unsync [] # delete one PDS record + mapping +gs backfill [] # re-sync every currently-published post +gs set-icon [] # replace the publication icon +gs set-theme [] [preset] # change the theme palette (skip|light|dark) +gs webhooks [] # (re-)install the four Ghost webhooks # Inspect -docker compose exec app gs status [] # health / counts -docker compose exec app gs config [] # stored config (secrets masked) -docker compose exec app gs logs # tail webhook delivery log +gs status [] # health / counts (per-site or summary) +gs config [] # stored config (secrets masked) +gs logs # tail webhook delivery log ``` +All commands run inside the container — prefix with `docker compose exec app` (or `docker compose -f compose.shared-host.yaml exec app` for the shared-host deployment shape). + `gs` is the short alias; the longer `ghoststandard` works identically if `gs` ever collides with something in your environment. -When the first positional argument matches a configured site slug, it's treated as the site selector. Otherwise commands default to the single configured site (and error if there are multiple). +**Site-arg behavior.** When the first positional argument matches a configured site slug, it's treated as the site selector. Otherwise commands default to the single configured site (and error out if multiple sites exist). ## Webhook URLs @@ -76,7 +225,12 @@ Each site gets a unique webhook endpoint shaped as: https://bridge.example.com/webhooks/ghost/ ``` -You'd configure four Ghost webhooks per site (Post published / Published post updated / Post unpublished / Post deleted), all pointing at the same `/webhooks/ghost/` URL with that site's secret. +The wizard auto-installs four webhooks pointing at this URL with the site's secret. If the auto-install fails (network issue during setup, or you're updating an old install), `gs webhooks ` re-runs the installer. If you ever need to do it by hand, the events to bind are: + +- Post published +- Published post updated +- Post unpublished +- Post deleted ## Deploying alongside another service @@ -93,7 +247,7 @@ Then add a site block to whatever's already running on 80/443: ```caddy bridge.example.com { - reverse_proxy localhost:8080 + reverse_proxy localhost:8080 } ``` @@ -101,20 +255,32 @@ bridge.example.com { ```nginx server { - listen 443 ssl http2; - server_name bridge.example.com; - # … your usual ssl_certificate / ssl_certificate_key lines … - location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } + listen 443 ssl http2; + server_name bridge.example.com; + # … your usual ssl_certificate / ssl_certificate_key lines … + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } } ``` -The setup wizard's "public URL of this bridge" prompt is where you tell ghoststandard which hostname Ghost will hit (`https://bridge.example.com`). Per-site webhook URLs become `https://bridge.example.com/webhooks/ghost/` from there. +The wizard's "public URL of this bridge" prompt is where you tell ghoststandard which hostname Ghost will hit (`https://bridge.example.com`). Per-site webhook URLs become `https://bridge.example.com/webhooks/ghost/` from there. + +## Backfill + +After a Ghost blog has been configured, the bridge syncs new posts automatically via webhooks — but existing published posts at setup time are NOT retroactively synced. Run: + +```bash +gs backfill +``` + +The bridge browses every currently-published post via the Admin API and syncs each one. Idempotent (rkey = Ghost post id), so safe to re-run — already-synced posts just get their PDS records overwritten with the same content. + +Per-post errors don't abort the run; they end up in the returned `failed: [...]` summary you can re-try by hand. ## Architecture @@ -140,10 +306,22 @@ Caddy (bundled or pre-existing) sits in front for TLS termination + automatic Le ## Data + backups -Everything lives in one named Docker volume (`gs_data`) at `/data/ghoststandard.sqlite`. Back it up. Schema is `sites`, `post_mapping` (site-scoped), and a small `config` for global settings. +Everything lives in one named Docker volume (`gs_data`) at `/data/ghoststandard.sqlite`. Back it up. Schema is `sites`, `post_mapping` (site-scoped), and a small `config` table for global settings. If you lose the SQLite file, re-running `gs setup` + `gs site add` (for any extras) + `gs backfill ` rebuilds everything. The bridge writes records by Ghost post ID (`rkey = post.id`), so a backfill from a fresh DB produces the same AT-URIs as before. No duplicates, no orphans. +The webhook delivery log lives at `/data/webhook.log` (append-only JSONL). `gs logs` tails it. + +## Troubleshooting + +**Webhooks aren't firing.** First check Ghost's webhook delivery history (Settings → Integrations → your integration → click into a webhook → "Recent deliveries"). If those show non-2xx, the URL or secret might be wrong; re-run `gs webhooks ` to re-create or use `gs config ` to confirm the secret length matches what Ghost has. If Ghost shows successful deliveries but you see no `sync` lines in `gs logs`, look at the bridge's container logs (`docker compose logs app`) — webhook handler errors that don't make it into the delivery log will show up there. + +**`gs site add` says "Ghost Admin API call failed".** The Admin API key is `key_id:hex_secret`, all one string with a colon — don't paste the Content API key by mistake. Also confirm your `ghost_url` includes any subpath (`https://example.com/blog`, not just `https://example.com`, if Ghost is mounted at `/blog`). + +**The `.well-known` file returns 404.** The reverse-proxy directive must come before the catch-all proxy directive in the same site block. Caddy uses `handle /...` (first-match wins); nginx uses `location = /...` (exact match takes precedence over location `/`). + +**TLS errors when calling out to Ghost from inside Docker.** This bit Tim's original integrated bridge — usually the cause is a stale `/etc/hosts` entry on the host after an IP change, not a real network problem. Check that the box's `/etc/hosts` matches the current public IP. + ## Development ```bash @@ -163,7 +341,7 @@ npm run build # → dist/ - [@tryghost/admin-api](https://github.com/TryGhost/SDK/tree/main/packages/admin-api) — Ghost Admin client - [@inquirer/prompts](https://github.com/SBoudrias/Inquirer.js) + [picocolors](https://github.com/alexeyraspopov/picocolors) + [ora](https://github.com/sindresorhus/ora) — CLI polish - [sharp](https://sharp.pixelplumbing.com/) — cover-image resize -- [Caddy](https://caddyserver.com/) — reverse proxy + automatic TLS +- [Caddy](https://caddyserver.com/) — reverse proxy + automatic TLS (when using bundled compose) ## License