From d958a298f71c06f28ee178224235425210042be8 Mon Sep 17 00:00:00 2001 From: andwati Date: Tue, 1 Sep 2026 16:50:26 +0300 Subject: [PATCH] feat(cms): implement Git publishing for content changes and add entrypoint script - Added a Docker entrypoint script to manage content repository cloning and updates. - Updated server configuration to include public URL and proxy settings. - Enhanced content file utilities to return file paths for written entries. - Introduced GitPublisher class for handling Git operations and deployments. - Added tests for Git publishing functionality. - Updated Dockerfile and docker-compose for production setup. - Added new public PGP key and security.txt for contact information. - Created new About and Archive pages in the site. - Updated site configuration to include new navigation links. --- .dockerignore | 11 +- DEPLOYMENT.md | 95 +++++++++++ Dockerfile | 29 ++-- TODO.md | 29 ++-- apps/cms/.env.example | 13 ++ apps/cms/Dockerfile | 6 +- apps/cms/config/server.ts | 2 + apps/cms/docker-entrypoint.sh | 40 +++++ apps/cms/package.json | 2 + apps/cms/src/utils/bookshelf-covers.ts | 3 +- apps/cms/src/utils/content-file.ts | 6 +- apps/cms/src/utils/content-paths.ts | 22 +++ apps/cms/src/utils/content-sync.ts | 66 ++++++-- apps/cms/src/utils/git-publisher.ts | 186 ++++++++++++++++++++++ apps/cms/tests/git-publisher.test.ts | 125 +++++++++++++++ apps/site/public/.well-known/pgp-key.txt | 13 ++ apps/site/public/.well-known/security.txt | 17 ++ apps/site/src/pages/about.astro | 146 +++++++++++++++++ apps/site/src/pages/archive/index.astro | 121 ++++++++++++++ apps/site/src/site.config.ts | 2 + docker-compose.prod.yml | 50 ++++++ package.json | 1 + pnpm-lock.yaml | 29 +++- sws.toml | 5 +- 24 files changed, 960 insertions(+), 59 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 apps/cms/docker-entrypoint.sh create mode 100644 apps/cms/src/utils/content-paths.ts create mode 100644 apps/cms/src/utils/git-publisher.ts create mode 100644 apps/cms/tests/git-publisher.test.ts create mode 100644 apps/site/public/.well-known/pgp-key.txt create mode 100644 apps/site/public/.well-known/security.txt create mode 100644 apps/site/src/pages/about.astro create mode 100644 apps/site/src/pages/archive/index.astro create mode 100644 docker-compose.prod.yml diff --git a/.dockerignore b/.dockerignore index fce65b8..487baed 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,12 @@ /public -node_modules +**/node_modules .git manimations/*.* /zola /zola-v*.tar.gz /artifacts -dist -.strapi -.tmp -build +**/dist +**/.astro +**/.strapi +**/.tmp +**/build diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..e25a835 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,95 @@ +# Dokploy deployment + +The production deployment is one Dokploy Compose application with two +services: + +- `site` builds the Astro app and serves its static output with + `static-web-server`. +- `cms` runs Strapi with persistent SQLite/admin data and a persistent Git + checkout. + +Markdown remains the source of truth. A Strapi edit writes into the checkout, +commits and pushes the changed Markdown (plus a generated bookshelf cover when +applicable), then calls Dokploy's deploy webhook. The redeploy builds Astro +from that new commit. + +## GitHub token + +Create a fine-grained personal access token restricted to the +`andwati/andwati.com` repository with: + +- Repository contents: read and write +- Metadata: read (automatically included) + +The configured publishing branch must accept direct pushes from the token. +Store the token only in Dokploy as `GITHUB_TOKEN`. + +## Dokploy application + +1. Create a Compose application from this repository and select + `docker-compose.prod.yml`. +2. Configure its source branch to match `CONTENT_GIT_BRANCH` (normally + `main`). +3. Route the public site domain to the `site` service on port `80`. +4. Route the CMS domain (for example `cms.andwati.com`) to the `cms` service + on port `1337`. +5. Copy the application's deploy webhook URL into + `DOKPLOY_DEPLOY_WEBHOOK_URL`. +6. Add the environment variables below and deploy. + +Required secrets: + +```dotenv +CMS_PUBLIC_URL=https://cms.andwati.com +APP_KEYS= +API_TOKEN_SALT= +ADMIN_JWT_SECRET= +TRANSFER_TOKEN_SALT= +JWT_SECRET= +ENCRYPTION_KEY= +GITHUB_TOKEN= +DOKPLOY_DEPLOY_WEBHOOK_URL= +``` + +Optional publishing settings: + +```dotenv +CONTENT_GIT_REPOSITORY=andwati/andwati.com +CONTENT_GIT_BRANCH=main +CONTENT_GIT_COMMIT_NAME="andwati.com CMS" +CONTENT_GIT_COMMIT_EMAIL=andwati-cms@users.noreply.github.com +``` + +Generate each random secret independently; do not reuse the local development +values from `docker-compose.yml`. + +## Persistent data + +The production Compose file declares three named volumes: + +- `cms_database` keeps Strapi's admin users, API tokens, and SQLite cache. +- `cms_uploads` keeps files uploaded through Strapi. +- `content_checkout` keeps the Git working tree used for publishing. + +Back up `cms_database` and `cms_uploads`. The content checkout is recoverable +from Git, but retaining it protects an edit if the process stops between the +filesystem write and Git push. + +On startup, the CMS fast-forwards a clean checkout to the configured branch. +If it finds uncommitted files, it preserves them and starts without pulling so +an interrupted edit is not overwritten. Resolve and push those files from the +volume before forcing a reset. + +## Local validation + +Provide temporary values for required interpolation variables, then validate +the production manifest: + +```sh +docker compose --env-file .env.production \ + -f docker-compose.prod.yml config +docker compose --env-file .env.production \ + -f docker-compose.prod.yml build +``` + +The existing `docker-compose.yml` remains the local Strapi development setup. diff --git a/Dockerfile b/Dockerfile index 6cc4474..32a0199 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,20 @@ -# Multi-stage build: generate llms.txt with Node, build the site with Zola, -# then serve the static output with static-web-server. -# -# This is the Dokploy/self-hosted deployment path (the only one - the prior -# Cloudflare Pages/Workers path has been retired). +# Production image for the Astro site. Build from the workspace root: +# docker build -t andwati-site . -FROM node:22-slim AS llms -WORKDIR /project -COPY . . -RUN node scripts/generate-llms.mjs +FROM node:24-bookworm-slim AS build +RUN corepack enable +WORKDIR /app -FROM ghcr.io/getzola/zola:v0.23.1 AS zola -WORKDIR /project -COPY . . -COPY --from=llms /project/static/llms.txt static/llms.txt -RUN ["/zola", "build"] +COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./ +COPY apps/site/package.json apps/site/package.json +COPY apps/cms/package.json apps/cms/package.json +COPY apps/site apps/site +COPY content content +COPY scripts scripts +RUN pnpm install --frozen-lockfile --filter andwati-com --filter site +RUN pnpm --filter site build FROM ghcr.io/static-web-server/static-web-server:2 -COPY --from=zola /project/public /public +COPY --from=build /app/apps/site/dist /public COPY sws.toml /sws.toml EXPOSE 80 diff --git a/TODO.md b/TODO.md index 971c645..52ae7f6 100644 --- a/TODO.md +++ b/TODO.md @@ -164,10 +164,12 @@ approved plan for full rationale on each decision referenced below. `from-c-to-machine-code` - [x] Preserve slugs/URLs via redirects — the script generates 301s from `/posts//` to `/writings//` into `sws.toml`, idempotently -- [ ] Migrate `content/archive/`, `content/about.md` equivalents — holding - off: the Astro site doesn't have archive/about pages built yet, and - "about" copy/framing is exactly the kind of thing you'd want to - review/rewrite for the new "digital legacy" identity, not port as-is +- [x] Migrate `content/archive/`, `content/about.md` equivalents — + `/archive/` groups every published writing by year; `/about/` reframes + the legacy copy around the site's "digital legacy" identity while + preserving its PGP and security contact details. Both are in the main + navigation, and the linked `.well-known` files are copied into Astro's + public output - [ ] Verify RSS subscribers and inbound links survive the cutover — can only really be checked once this is deployed live (**needs your input**: this is a production-cutover verification, not a build-time @@ -175,14 +177,17 @@ approved plan for full rationale on each decision referenced below. ## Infra / deployment -- [ ] Wire Strapi into the Dokploy deployment alongside the Astro build — - **needs your input**: requires your Dokploy access -- [ ] Decide build trigger: webhook on Strapi publish → CI rebuild, vs. - manual/scheduled rebuild — **needs your input**: your call, not an - engineering constraint either way -- [ ] Update `Dockerfile`/`sws.toml` for the new build output (currently - Zola-specific) — holding off until the migration/cutover is actually - ready to ship, so this doesn't drift out of sync in the meantime +- [x] Wire Strapi into the Dokploy deployment alongside the Astro build — + `docker-compose.prod.yml` defines both production services, persistent + CMS/Git volumes, reverse-proxy settings, and secret-only configuration +- [x] Build trigger decided and implemented: Strapi commits Markdown and + generated covers to Git, pushes the production branch, then calls the + Dokploy Compose deploy webhook so Astro rebuilds from the durable commit +- [x] Update `Dockerfile`/`sws.toml` for Astro's `apps/site/dist` output, + current security headers, legacy redirects, and the custom 404 page +- [ ] Launch the production Compose application and configure its domains, + secrets, GitHub token, and deploy webhook in Dokploy — repository-side + setup is complete in `DEPLOYMENT.md`; host access is still required ## Cleanup diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 78c956e..2485bc5 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -6,3 +6,16 @@ ADMIN_JWT_SECRET=tobemodified TRANSFER_TOKEN_SALT=tobemodified JWT_SECRET=tobemodified ENCRYPTION_KEY=tobemodified + +# Production reverse-proxy and Git publishing settings. Git publishing stays +# disabled during local development unless explicitly enabled. +PUBLIC_URL=http://localhost:1337 +IS_PROXIED=false +GIT_PUBLISH_ENABLED=false +CONTENT_REPOSITORY_ROOT= +CONTENT_GIT_REPOSITORY=andwati/andwati.com +CONTENT_GIT_BRANCH=main +CONTENT_GIT_COMMIT_NAME="andwati.com CMS" +CONTENT_GIT_COMMIT_EMAIL=andwati-cms@users.noreply.github.com +GITHUB_TOKEN= +DOKPLOY_DEPLOY_WEBHOOK_URL= diff --git a/apps/cms/Dockerfile b/apps/cms/Dockerfile index cb65b78..1afa4d2 100644 --- a/apps/cms/Dockerfile +++ b/apps/cms/Dockerfile @@ -26,8 +26,12 @@ RUN pnpm --filter cms build FROM base AS runtime ENV NODE_ENV=production +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates git \ + && rm -rf /var/lib/apt/lists/* COPY --from=build /app/node_modules /app/node_modules COPY --from=build /app/apps/cms /app/apps/cms +COPY --chmod=755 apps/cms/docker-entrypoint.sh /usr/local/bin/andwati-cms-entrypoint WORKDIR /app/apps/cms EXPOSE 1337 -CMD ["pnpm", "start"] +ENTRYPOINT ["andwati-cms-entrypoint"] diff --git a/apps/cms/config/server.ts b/apps/cms/config/server.ts index 2edea97..080f03b 100644 --- a/apps/cms/config/server.ts +++ b/apps/cms/config/server.ts @@ -5,6 +5,8 @@ const config = ({ }: Core.Config.Shared.ConfigParams): Core.Config.Server => ({ host: env("HOST", "0.0.0.0"), port: env.int("PORT", 1337), + url: env("PUBLIC_URL", "http://localhost:1337"), + proxy: env.bool("IS_PROXIED", false), app: { keys: env.array("APP_KEYS")!, }, diff --git a/apps/cms/docker-entrypoint.sh b/apps/cms/docker-entrypoint.sh new file mode 100644 index 0000000..3551300 --- /dev/null +++ b/apps/cms/docker-entrypoint.sh @@ -0,0 +1,40 @@ +#!/bin/sh +set -eu + +repository_root="${CONTENT_REPOSITORY_ROOT:-/content-repository}" +repository="${CONTENT_GIT_REPOSITORY:-andwati/andwati.com}" +branch="${CONTENT_GIT_BRANCH:-main}" + +case "$repository" in + *[!A-Za-z0-9._/-]* | /* | */ | *..*) + echo "Invalid CONTENT_GIT_REPOSITORY: $repository" >&2 + exit 1 + ;; +esac + +mkdir -p "$repository_root" + +if [ ! -d "$repository_root/.git" ]; then + if [ -n "$(find "$repository_root" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + echo "$repository_root is not empty and is not a Git checkout" >&2 + exit 1 + fi + + git clone \ + --branch "$branch" \ + --single-branch \ + "https://github.com/$repository.git" \ + "$repository_root" +else + git -C "$repository_root" fetch origin "$branch" + git -C "$repository_root" checkout "$branch" + + if [ -z "$(git -C "$repository_root" status --porcelain)" ]; then + git -C "$repository_root" merge --ff-only "origin/$branch" + else + echo "Content checkout has unpublished changes; preserving them without pulling." >&2 + fi +fi + +export CONTENT_REPOSITORY_ROOT="$repository_root" +exec node node_modules/@strapi/strapi/bin/strapi.js start diff --git a/apps/cms/package.json b/apps/cms/package.json index 368b1cd..221b92a 100644 --- a/apps/cms/package.json +++ b/apps/cms/package.json @@ -13,6 +13,7 @@ "develop": "strapi develop", "start": "strapi start", "strapi": "strapi", + "test:publisher": "tsx --test tests/git-publisher.test.ts", "upgrade": "npx @strapi/upgrade latest", "upgrade:dry": "npx @strapi/upgrade latest --dry" }, @@ -32,6 +33,7 @@ "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", + "tsx": "^4.23.13", "typescript": "^5" }, "engines": { diff --git a/apps/cms/src/utils/bookshelf-covers.ts b/apps/cms/src/utils/bookshelf-covers.ts index ce68f6c..848f6fa 100644 --- a/apps/cms/src/utils/bookshelf-covers.ts +++ b/apps/cms/src/utils/bookshelf-covers.ts @@ -1,12 +1,13 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { coversRoot } from "./content-paths"; /** * Covers are cached into the Astro app's public/ dir directly, so the built * site serves them from /covers/.jpg with no runtime dependency on the * source (Open Library, Semantic Scholar, ...). */ -const COVERS_DIR = join(process.cwd(), "..", "site", "public", "covers"); +const COVERS_DIR = coversRoot(); async function download(url: string): Promise { const res = await fetch(url); diff --git a/apps/cms/src/utils/content-file.ts b/apps/cms/src/utils/content-file.ts index 3c889f8..f2565c6 100644 --- a/apps/cms/src/utils/content-file.ts +++ b/apps/cms/src/utils/content-file.ts @@ -107,11 +107,12 @@ export function writeEntry( layout: "nested" | "flat", frontmatter: Record, body: string, -): void { +): string { const file = entryPath(dir, slug, layout); mkdirSync(dirname(file), { recursive: true }); const toml = stringifyToml(compact(frontmatter)).trimEnd(); writeFileSync(file, `+++\n${toml}\n+++\n\n${(body ?? "").trim()}\n`); + return file; } /** Deletes the file (and, for nested layout, its now-empty directory) for `slug`. */ @@ -119,9 +120,10 @@ export function deleteEntry( dir: string, slug: string, layout: "nested" | "flat", -): void { +): string { const file = entryPath(dir, slug, layout); rmSync(file, { force: true }); if (layout === "nested") rmSync(join(dir, slug), { recursive: true, force: true }); + return file; } diff --git a/apps/cms/src/utils/content-paths.ts b/apps/cms/src/utils/content-paths.ts new file mode 100644 index 0000000..0bc0649 --- /dev/null +++ b/apps/cms/src/utils/content-paths.ts @@ -0,0 +1,22 @@ +import { isAbsolute, join, resolve } from "node:path"; + +/** + * Root of the Git checkout that owns content/ and apps/site/public/covers/. + * Production points this at the persistent checkout prepared by the + * container entrypoint; local development falls back to the workspace root. + */ +export function repositoryRoot(): string { + const configured = process.env.CONTENT_REPOSITORY_ROOT; + if (!configured) return resolve(process.cwd(), "..", ".."); + return isAbsolute(configured) + ? configured + : resolve(process.cwd(), configured); +} + +export function contentRoot(): string { + return join(repositoryRoot(), "content"); +} + +export function coversRoot(): string { + return join(repositoryRoot(), "apps", "site", "public", "covers"); +} diff --git a/apps/cms/src/utils/content-sync.ts b/apps/cms/src/utils/content-sync.ts index f29afa6..d75ebe3 100644 --- a/apps/cms/src/utils/content-sync.ts +++ b/apps/cms/src/utils/content-sync.ts @@ -1,4 +1,5 @@ -import { join } from "node:path"; +import { rmSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; import { cacheRemoteImage, fetchBookCover, @@ -10,6 +11,8 @@ import { toTomlDate, writeEntry, } from "./content-file"; +import { contentRoot, coversRoot } from "./content-paths"; +import { publishContentChanges } from "./git-publisher"; const BOOKSHELF_UID = "api::bookshelf-entry.bookshelf-entry"; @@ -39,11 +42,6 @@ const isoDate = (value: unknown): string | undefined => ? value : undefined; -function contentRoot(): string { - // apps/cms -> repo root/content (also the mount point in the dev container) - return join(process.cwd(), "..", "..", "content"); -} - function buildConfigs(): Record { const root = contentRoot(); @@ -209,11 +207,28 @@ let importing = false; function writeEntryForConfig( config: SyncConfig, entry: Record, -): void { +): string | undefined { const slug = entry.slug as string | undefined; - if (!slug) return; + if (!slug) return undefined; const { frontmatter, body } = config.toFile(entry); - writeEntry(config.dir, slug, config.layout, frontmatter, body); + return writeEntry(config.dir, slug, config.layout, frontmatter, body); +} + +function coverFileForEntry( + entry: Record | null, +): string | undefined { + const coverImage = entry?.cover_image; + if (typeof coverImage !== "string" || !coverImage.startsWith("/covers/")) { + return undefined; + } + + const root = resolve(coversRoot()); + const file = resolve(root, coverImage.slice("/covers/".length)); + const fromRoot = relative(root, file); + if (fromRoot === ".." || fromRoot.startsWith("../")) { + throw new Error(`Refusing unsafe local cover path: ${coverImage}`); + } + return file; } /** @@ -325,6 +340,9 @@ async function enrichBookshelfEntry( } if (Object.keys(patch).length === 0) return entry; + // Keep the nested update outside the network-failure guard. Its middleware + // persists and publishes the enriched entry; publication errors must reach + // the caller instead of being mistaken for a cover-provider outage. return documents(BOOKSHELF_UID).update({ documentId: entry.documentId as string, data: patch, @@ -348,14 +366,13 @@ export function registerContentSync(strapi: { const config = SYNC_CONFIGS[ctx.uid]; if (!config || importing) return next(); - let preDeleteSlug: string | undefined; + let preDeleteEntry: Record | null = null; if (ctx.action === "delete") { const documentId = (ctx.params as { documentId?: string }).documentId; if (documentId) { - const existing = await strapi + preDeleteEntry = await strapi .documents(ctx.uid) .findOne({ documentId }); - preDeleteSlug = existing?.slug as string | undefined; } } @@ -374,14 +391,33 @@ export function registerContentSync(strapi: { result as Record, ) : (result as Record); - writeEntryForConfig(config, entry); - } else if (ctx.action === "delete" && preDeleteSlug) { - deleteEntry(config.dir, preDeleteSlug, config.layout); + const entryFile = writeEntryForConfig(config, entry); + if (entryFile) { + const paths = [entryFile]; + const coverFile = coverFileForEntry(entry); + if (coverFile) paths.push(coverFile); + await publishContentChanges( + paths, + `content: ${ctx.action} ${entry.slug as string}`, + ); + } + } else if (ctx.action === "delete" && preDeleteEntry) { + const slug = preDeleteEntry.slug as string | undefined; + if (!slug) return result; + + const paths = [deleteEntry(config.dir, slug, config.layout)]; + const coverFile = coverFileForEntry(preDeleteEntry); + if (coverFile) { + rmSync(coverFile, { force: true }); + paths.push(coverFile); + } + await publishContentChanges(paths, `content: delete ${slug}`); } } catch (err) { strapi.log.error( `content-sync: failed to write ${ctx.uid} to disk: ${(err as Error).message}`, ); + throw err; } return result; diff --git a/apps/cms/src/utils/git-publisher.ts b/apps/cms/src/utils/git-publisher.ts new file mode 100644 index 0000000..bfa199d --- /dev/null +++ b/apps/cms/src/utils/git-publisher.ts @@ -0,0 +1,186 @@ +import { execFile } from "node:child_process"; +import { relative, resolve } from "node:path"; +import { promisify } from "node:util"; +import { repositoryRoot } from "./content-paths"; + +const execFileAsync = promisify(execFile); + +interface GitPublisherConfig { + worktree: string; + remote?: string; + branch?: string; + token?: string; + commitName?: string; + commitEmail?: string; + deployWebhookUrl?: string; + fetchImpl?: typeof fetch; +} + +function gitAuthenticationEnv(token?: string): NodeJS.ProcessEnv { + if (!token) return process.env; + + const credentials = Buffer.from(`x-access-token:${token}`).toString("base64"); + return { + ...process.env, + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraheader", + GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${credentials}`, + }; +} + +export class GitPublisher { + private readonly worktree: string; + private readonly remote: string; + private readonly branch: string; + private readonly token?: string; + private readonly commitName: string; + private readonly commitEmail: string; + private readonly deployWebhookUrl?: string; + private readonly fetchImpl: typeof fetch; + private queue: Promise = Promise.resolve(); + private deployPending = false; + + constructor(config: GitPublisherConfig) { + this.worktree = resolve(config.worktree); + this.remote = config.remote ?? "origin"; + this.branch = config.branch ?? "main"; + this.token = config.token; + this.commitName = config.commitName ?? "andwati.com CMS"; + this.commitEmail = + config.commitEmail ?? "andwati-cms@users.noreply.github.com"; + this.deployWebhookUrl = config.deployWebhookUrl; + this.fetchImpl = config.fetchImpl ?? fetch; + } + + publish(paths: string[], message: string): Promise { + const operation = this.queue.then(() => this.publishNow(paths, message)); + this.queue = operation.catch(() => undefined); + return operation; + } + + private async git(args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { + cwd: this.worktree, + env: gitAuthenticationEnv(this.token), + timeout: 30_000, + }); + return stdout.trim(); + } + + private repositoryPaths(paths: string[]): string[] { + return [...new Set(paths)].map((path) => { + const absolute = resolve(path); + const repositoryPath = relative(this.worktree, absolute); + if ( + repositoryPath === "" || + repositoryPath === ".." || + repositoryPath.startsWith("../") + ) { + throw new Error( + `Refusing to publish path outside Git checkout: ${path}`, + ); + } + return repositoryPath; + }); + } + + private async hasStagedChanges(): Promise { + try { + await this.git(["diff", "--cached", "--quiet"]); + return false; + } catch (error) { + if ((error as { code?: number }).code === 1) return true; + throw error; + } + } + + private async publishNow(paths: string[], message: string): Promise { + const repositoryPaths = this.repositoryPaths(paths); + await this.git(["add", "--all", "--", ...repositoryPaths]); + + if (await this.hasStagedChanges()) { + await this.git([ + "-c", + `user.name=${this.commitName}`, + "-c", + `user.email=${this.commitEmail}`, + "-c", + "commit.gpgsign=false", + "commit", + "-m", + message, + ]); + } + + await this.git(["fetch", this.remote, this.branch]); + try { + await this.git([ + "rebase", + "--autostash", + `${this.remote}/${this.branch}`, + ]); + } catch (error) { + await this.git(["rebase", "--abort"]).catch(() => undefined); + throw error; + } + + const ahead = Number( + await this.git([ + "rev-list", + "--count", + `${this.remote}/${this.branch}..HEAD`, + ]), + ); + if (ahead > 0) { + await this.git(["push", this.remote, `HEAD:${this.branch}`]); + this.deployPending = true; + } + + if (this.deployPending && this.deployWebhookUrl) { + const response = await this.fetchImpl(this.deployWebhookUrl, { + method: "POST", + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) { + throw new Error( + `Dokploy deploy webhook returned ${response.status} ${response.statusText}`, + ); + } + this.deployPending = false; + } + } +} + +let publisher: GitPublisher | undefined; + +function publisherFromEnvironment(): GitPublisher | undefined { + if (process.env.GIT_PUBLISH_ENABLED !== "true") return undefined; + + const token = process.env.GITHUB_TOKEN; + const deployWebhookUrl = process.env.DOKPLOY_DEPLOY_WEBHOOK_URL; + if (!token) throw new Error("GITHUB_TOKEN is required for Git publishing"); + if (!deployWebhookUrl) { + throw new Error( + "DOKPLOY_DEPLOY_WEBHOOK_URL is required for Git publishing", + ); + } + + return new GitPublisher({ + worktree: repositoryRoot(), + remote: process.env.CONTENT_GIT_REMOTE ?? "origin", + branch: process.env.CONTENT_GIT_BRANCH ?? "main", + token, + commitName: process.env.CONTENT_GIT_COMMIT_NAME, + commitEmail: process.env.CONTENT_GIT_COMMIT_EMAIL, + deployWebhookUrl, + }); +} + +/** Publishes changed source files when production Git publishing is enabled. */ +export async function publishContentChanges( + paths: string[], + message: string, +): Promise { + publisher ??= publisherFromEnvironment(); + await publisher?.publish(paths, message); +} diff --git a/apps/cms/tests/git-publisher.test.ts b/apps/cms/tests/git-publisher.test.ts new file mode 100644 index 0000000..c4514bb --- /dev/null +++ b/apps/cms/tests/git-publisher.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { GitPublisher } from "../src/utils/git-publisher.ts"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +function repositoryFixture(): { + root: string; + worktree: string; + cleanup: () => void; +} { + const root = mkdtempSync(join(tmpdir(), "andwati-git-publisher-")); + const remote = join(root, "remote.git"); + const worktree = join(root, "worktree"); + + git(root, "init", "--bare", remote); + git(root, "clone", remote, worktree); + writeFileSync(join(worktree, "README.md"), "seed\n"); + git(worktree, "add", "README.md"); + git( + worktree, + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "seed", + ); + git(worktree, "branch", "-M", "main"); + git(worktree, "push", "-u", "origin", "main"); + + return { + root, + worktree, + cleanup: () => rmSync(root, { recursive: true, force: true }), + }; +} + +test("commits, pushes, and deploys a changed source file", async () => { + const fixture = repositoryFixture(); + let deploys = 0; + const publisher = new GitPublisher({ + worktree: fixture.worktree, + branch: "main", + deployWebhookUrl: "https://deploy.invalid/hook", + fetchImpl: async () => { + deploys += 1; + return new Response(null, { status: 200 }); + }, + }); + + try { + const contentFile = join(fixture.worktree, "content", "post.md"); + mkdirSync(join(fixture.worktree, "content")); + writeFileSync(contentFile, "published\n", { flag: "w" }); + const concurrentFile = join(fixture.worktree, "content", "next.md"); + writeFileSync(concurrentFile, "still being edited\n"); + await publisher.publish([contentFile], "content: update post"); + + assert.equal( + git(fixture.worktree, "show", "origin/main:content/post.md"), + "published", + ); + assert.equal(deploys, 1); + assert.equal(readFileSync(concurrentFile, "utf8"), "still being edited\n"); + assert.match(git(fixture.worktree, "status", "--short"), /next\.md/); + + await publisher.publish([contentFile], "content: update post"); + assert.equal(deploys, 1, "unchanged content must not trigger a deploy"); + } finally { + fixture.cleanup(); + } +}); + +test("retries a pending deploy without creating another commit", async () => { + const fixture = repositoryFixture(); + let deploys = 0; + const publisher = new GitPublisher({ + worktree: fixture.worktree, + branch: "main", + deployWebhookUrl: "https://deploy.invalid/hook", + fetchImpl: async () => { + deploys += 1; + return new Response(null, { + status: deploys === 1 ? 503 : 200, + statusText: deploys === 1 ? "Unavailable" : "OK", + }); + }, + }); + + try { + const contentFile = join(fixture.worktree, "entry.md"); + writeFileSync(contentFile, "durable\n"); + + await assert.rejects( + publisher.publish([contentFile], "content: create entry"), + /Dokploy deploy webhook returned 503/, + ); + await publisher.publish([contentFile], "content: create entry"); + + assert.equal(deploys, 2); + assert.equal( + git(fixture.worktree, "rev-list", "--count", "origin/main"), + "2", + "the webhook retry must not create a duplicate commit", + ); + } finally { + fixture.cleanup(); + } +}); diff --git a/apps/site/public/.well-known/pgp-key.txt b/apps/site/public/.well-known/pgp-key.txt new file mode 100644 index 0000000..ea35ba1 --- /dev/null +++ b/apps/site/public/.well-known/pgp-key.txt @@ -0,0 +1,13 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEalcjtBYJKwYBBAHaRw8BAQdA55X1mRCKw9DA35kCAy+7Z30ye0rUHWBZsl7O +Wmar+RS0IklhbiBBbmR3YXRpIDxhbmR3YXRpaWFuQGdtYWlsLmNvbT6IkwQTFgoA +OxYhBK1YqreiDYUjy/WaeR8g/tCqBbDaBQJqVyO0AhsDBQsJCAcCAiICBhUKCQgL +AgQWAgMBAh4HAheAAAoJEB8g/tCqBbDace8BAN1SpkEMSYll+XupKHiaIUwnhm4N +eS79G1i+345Gl6CyAQDjgn+3rzG6sexjKh+10EKiy5omsQEGzUW03s7gS0T1Drg4 +BGpXI7QSCisGAQQBl1UBBQEBB0CXofBIYYXG2+dZqgllhsbkNPF4qlyfPgbRW+zb +7Kw3IAMBCAeIeAQYFgoAIBYhBK1YqreiDYUjy/WaeR8g/tCqBbDaBQJqVyO0AhsM +AAoJEB8g/tCqBbDauMkA/RG4iqtPZOI3DQzFJYu7SnxaD81vdP0LyTLKDQkynySu +AQDrlWyF8LRlad4iX1yQPCuOV0aUpTNq5kPlWurtX99/BQ== +=uKsS +-----END PGP PUBLIC KEY BLOCK----- diff --git a/apps/site/public/.well-known/security.txt b/apps/site/public/.well-known/security.txt new file mode 100644 index 0000000..5232162 --- /dev/null +++ b/apps/site/public/.well-known/security.txt @@ -0,0 +1,17 @@ +-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA512 + +Contact: mailto:andwatiian@gmail.com +Expires: 2027-07-16T00:00:00.000Z +Encryption: https://andwati.com/.well-known/pgp-key.txt +Encryption: https://keys.openpgp.org/vks/v1/by-fingerprint/AD58AAB7A20D8523CBF59A791F20FED0AA05B0DA +Encryption: https://keyserver.ubuntu.com/pks/lookup?op=get&search=0xAD58AAB7A20D8523CBF59A791F20FED0AA05B0DA +Preferred-Languages: en +Canonical: https://andwati.com/.well-known/security.txt +-----BEGIN PGP SIGNATURE----- + +iHUEARYKAB0WIQStWKq3og2FI8v1mnkfIP7QqgWw2gUCaljXlQAKCRAfIP7QqgWw +2qAoAP0Q7Cc0YsP2dvV7fqDlqcnjuJepQQSTZBz7ehkPyjGV5gEA3K9ZqQmuI2ea +KKADWxuKTTY43PBmuV9rMqLm8ZOPlAY= +=NUkY +-----END PGP SIGNATURE----- diff --git a/apps/site/src/pages/about.astro b/apps/site/src/pages/about.astro new file mode 100644 index 0000000..deff720 --- /dev/null +++ b/apps/site/src/pages/about.astro @@ -0,0 +1,146 @@ +--- +import Base from "../layouts/Base.astro"; +import { siteConfig } from "../site.config"; +--- + + +
+
+

About this place

+

+ I’m Ian Andwati, a software engineer. This site is my durable record of + what I learn, build, read, and find worth returning to. +

+
+ +
+

+ I spend my free time on hobby projects and occasionally write about the + experience. I’m especially interested in offensive security, reverse + engineering, computer science, and mathematics. +

+

+ The writings hold longer-form notes, the + portfolio records finished work, and the + bookshelf keeps the sources that shaped both. The + archive is the unfiltered chronology. +

+
+ +
+
+

Contact

+

Say hello

+
+

+ Email is the most reliable way to reach me: + {siteConfig.social.email}. You can also find me on + GitHub. +

+
+ +
+

For sensitive messages

+

PGP

+

+ My public key is available as a plain-text download + and on + keys.openpgp.org. The signed security.txt lists the + same contact and encryption details. +

+
+ Fingerprint + AD58 AAB7 A20D 8523 CBF5 9A79 1F20 FED0 AA05 B0DA +
+
+
+ + + diff --git a/apps/site/src/pages/archive/index.astro b/apps/site/src/pages/archive/index.astro new file mode 100644 index 0000000..18e3b0d --- /dev/null +++ b/apps/site/src/pages/archive/index.astro @@ -0,0 +1,121 @@ +--- +import { getCollection } from "astro:content"; +import Base from "../../layouts/Base.astro"; + +const writings = ( + await getCollection("writings", ({ data }) => !data.draft) +).sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf()); + +const writingsByYear = Map.groupBy(writings, (entry) => + entry.data.date.getFullYear(), +); +--- + + +
+

Archive

+
+ +
+ { + [...writingsByYear].map(([year, entries]) => ( +
+

{year}

+
    + {entries.map((entry) => ( +
  1. + + {entry.data.title} +
  2. + ))} +
+
+ )) + } +
+ + + diff --git a/apps/site/src/site.config.ts b/apps/site/src/site.config.ts index cd08c55..2f84385 100644 --- a/apps/site/src/site.config.ts +++ b/apps/site/src/site.config.ts @@ -12,9 +12,11 @@ export const siteConfig = { "Ian Andwati's personal digital-legacy site covering computer science and mathematics — writings, portfolio, and a curated bookshelf.", nav: [ { name: "Writings", url: "/writings/" }, + { name: "Archive", url: "/archive/" }, { name: "Portfolio", url: "/portfolio/" }, { name: "Bookshelf", url: "/bookshelf/" }, { name: "Blogs", url: "/blogs/" }, + { name: "About", url: "/about/" }, ], social: { github: "https://github.com/andwati", diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..78c0750 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,50 @@ +name: andwati-production + +services: + site: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + expose: + - "80" + + cms: + build: + context: . + dockerfile: apps/cms/Dockerfile + restart: unless-stopped + init: true + expose: + - "1337" + environment: + HOST: 0.0.0.0 + PORT: "1337" + NODE_ENV: production + PUBLIC_URL: ${CMS_PUBLIC_URL:?Set CMS_PUBLIC_URL in Dokploy} + IS_PROXIED: "true" + APP_KEYS: ${APP_KEYS:?Set APP_KEYS in Dokploy} + API_TOKEN_SALT: ${API_TOKEN_SALT:?Set API_TOKEN_SALT in Dokploy} + ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:?Set ADMIN_JWT_SECRET in Dokploy} + TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT:?Set TRANSFER_TOKEN_SALT in Dokploy} + JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in Dokploy} + ENCRYPTION_KEY: ${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in Dokploy} + DATABASE_CLIENT: sqlite + DATABASE_FILENAME: .tmp/data.db + CONTENT_REPOSITORY_ROOT: /content-repository + CONTENT_GIT_REPOSITORY: ${CONTENT_GIT_REPOSITORY:-andwati/andwati.com} + CONTENT_GIT_BRANCH: ${CONTENT_GIT_BRANCH:-main} + CONTENT_GIT_COMMIT_NAME: ${CONTENT_GIT_COMMIT_NAME:-andwati.com CMS} + CONTENT_GIT_COMMIT_EMAIL: ${CONTENT_GIT_COMMIT_EMAIL:-andwati-cms@users.noreply.github.com} + GIT_PUBLISH_ENABLED: "true" + GITHUB_TOKEN: ${GITHUB_TOKEN:?Set a GitHub contents-write token in Dokploy} + DOKPLOY_DEPLOY_WEBHOOK_URL: ${DOKPLOY_DEPLOY_WEBHOOK_URL:?Set the Dokploy deploy webhook URL} + volumes: + - cms_database:/app/apps/cms/.tmp + - cms_uploads:/app/apps/cms/public/uploads + - content_checkout:/content-repository + +volumes: + cms_database: + cms_uploads: + content_checkout: diff --git a/package.json b/package.json index 5518a4e..aed5928 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "andwati-com", "private": true, "type": "module", + "packageManager": "pnpm@11.21.0", "scripts": { "dev": "concurrently -n site,cms -c blue,magenta \"pnpm --filter site dev\" \"pnpm --filter cms develop\"", "dev:site": "pnpm --filter site dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6697496..abbf98b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@types/react-dom': specifier: ^18 version: 18.3.7(@types/react@18.3.31) + tsx: + specifier: ^4.23.13 + version: 4.23.13 typescript: specifier: ^5 version: 5.9.3 @@ -98,7 +101,7 @@ importers: version: 5.3.0 astro: specifier: ^7.2.9 - version: 7.2.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@20.19.43)(terser@5.51.2)(yaml@2.9.0) + version: 7.2.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@20.19.43)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) highlight.js: specifier: ^11.12.0 version: 11.12.0 @@ -7231,6 +7234,11 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -12109,7 +12117,7 @@ snapshots: dependencies: tslib: 2.8.1 - astro@7.2.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@20.19.43)(terser@5.51.2)(yaml@2.9.0): + astro@7.2.9(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3)(@types/node@20.19.43)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: '@astrojs/compiler-rs': 0.4.0(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.3) '@astrojs/internal-helpers': 0.10.4 @@ -12159,8 +12167,8 @@ snapshots: ultrahtml: 1.7.0 unifont: 0.7.5 unstorage: 1.17.5 - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.0) - vitefu: 1.1.3(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.0)) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) + vitefu: 1.1.3(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)) xxhash-wasm: 1.1.0 yargs-parser: 22.0.0 zod: 4.4.3 @@ -16279,6 +16287,12 @@ snapshots: tsscmp@1.0.6: {} + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -16516,7 +16530,7 @@ snapshots: lightningcss: 1.33.0 terser: 5.51.2 - vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.0): + vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -16528,11 +16542,12 @@ snapshots: esbuild: 0.28.2 fsevents: 2.3.3 terser: 5.51.2 + tsx: 4.23.13 yaml: 2.9.0 - vitefu@1.1.3(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.0)): + vitefu@1.1.3(vite@8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)): optionalDependencies: - vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(yaml@2.9.0) + vite: 8.2.2(@types/node@20.19.43)(esbuild@0.28.2)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0) volar-service-css@0.0.71(@volar/language-service@2.4.28(typescript@5.9.3)): dependencies: diff --git a/sws.toml b/sws.toml index 3a247e3..87da029 100644 --- a/sws.toml +++ b/sws.toml @@ -1,9 +1,12 @@ +[general] +page404 = "./404.html" + [advanced] [[advanced.headers]] source = "**" [advanced.headers.headers] -Content-Security-Policy = "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://utteranc.es https://static.cloudflareinsights.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.google-analytics.com; font-src 'self'; connect-src 'self' https://www.google-analytics.com https://cloudflareinsights.com; frame-src https://www.youtube-nocookie.com https://utteranc.es; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" +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'; frame-src https://www.youtube-nocookie.com; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests" X-Frame-Options = "DENY" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin"