From 8ac7db77fcdacab3f11fd04eb584141c7f92e1b7 Mon Sep 17 00:00:00 2001 From: Joshua Higgins Date: Sat, 22 Aug 2026 00:39:53 -0400 Subject: [PATCH] Replace S3 storage with Docker-mounted filesystem - Add Docker and Compose deployment with persistent HTML and Postgres volumes - Remove AWS storage configuration and dependency - Document local Docker setup and filesystem storage --- .dockerignore | 11 ++++++ .gitignore | 3 ++ Dockerfile | 20 +++++++++++ README.md | 28 +++++---------- docker-compose.yml | 48 +++++++++++++++++++++++++ package.json | 1 - src/config.js | 13 +++---- src/server.js | 2 +- src/storage.js | 89 +++++++++++++++++++++------------------------- 9 files changed, 136 insertions(+), 79 deletions(-) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..822185a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +node_modules +data +*.md +skills +bin +LICENSE +docker-compose.yml +Dockerfile +.dockerignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a045a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +data +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e19f0f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./ +RUN npm install --omit=dev && npm cache clean --force + +COPY src ./src + +ENV NODE_ENV=production +ENV POSTPLAN_HTML_DIR=/data/html + +RUN mkdir -p /data/html + +EXPOSE 3000 + +HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=10 \ + CMD node -e "fetch('http://127.0.0.1:3000/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "src/server.js"] diff --git a/README.md b/README.md index 93e6bfa..c07e108 100644 --- a/README.md +++ b/README.md @@ -44,14 +44,11 @@ Required service variables: - `DATABASE_URL` - `POSTPLAN_BOOTSTRAP_API_KEY` -- `AWS_ENDPOINT_URL` -- `AWS_ACCESS_KEY_ID` -- `AWS_SECRET_ACCESS_KEY` -- `AWS_S3_BUCKET_NAME` -- `AWS_DEFAULT_REGION` Optional service variables: +- `POSTPLAN_HTML_DIR` - directory for uploaded HTML files (default `./data/html`; Docker Compose mounts this at `/data/html`). + - `POSTPLAN_PUBLIC_BASE_URL` - set to a normal base URL for `/d/` URLs, or a wildcard URL such as `https://*.postplan.dev` for draft subdomains. - `POSTPLAN_SESSION_SECRET` - together with `POSTPLAN_PUBLIC_BASE_URL`, enables web sign-in (the dashboard and `/cli/auth`). If either is absent, those routes return 503 and uploads/serving are unaffected. - `SHOO_BASE_URL` - identity broker for web sign-in (default `https://shoo.dev`). @@ -88,30 +85,21 @@ The upload API returns both `publicUrl` and `rawUrl`, and the CLI prints the raw - `https://postplan.dev/d//raw` - `https://postplan.dev/d//v//raw` -## Railway Provisioning +## Docker -After `railway login` succeeds, provision and deploy the first-pass stack: +Uploaded HTML is stored on the filesystem. Compose bind-mounts `./data/html` to `/data/html` in the app container so drafts persist across rebuilds. Override the host path with `POSTPLAN_HTML_VOLUME`. ```sh -chmod +x scripts/provision-railway.sh -scripts/provision-railway.sh +docker compose up --build ``` -The script creates a Railway project, app service, Postgres service, object storage bucket, service variables, Railway domain, and first deployment. It stores the generated bootstrap API key under `~/.postplan/deployments/`. - -Verify the deployed service: - -```sh -POSTPLAN_URL=https://your-railway-domain \ -POSTPLAN_API_KEY=your-bootstrap-or-cli-key \ -scripts/verify-deployment.sh -``` +The API listens on `http://localhost:3000`. Point the CLI at it with `--api-url http://localhost:3000`. The default bootstrap key is `dev-bootstrap-key`; set `POSTPLAN_BOOTSTRAP_API_KEY` to override it. Create a named key from the bootstrap key: ```sh -curl -X POST "$POSTPLAN_URL/api/api-keys" \ - -H "Authorization: Bearer $POSTPLAN_BOOTSTRAP_API_KEY" \ +curl -X POST "http://localhost:3000/api/api-keys" \ + -H "Authorization: Bearer ${POSTPLAN_BOOTSTRAP_API_KEY:-dev-bootstrap-key}" \ -H "Content-Type: application/json" \ -d '{"name":"local-cli"}' ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..871db79 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,48 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: postplan + POSTGRES_PASSWORD: postplan + POSTGRES_DB: postplan + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postplan -d postplan"] + interval: 5s + timeout: 5s + retries: 10 + restart: unless-stopped + + app: + build: . + ports: + - "${PORT:-3000}:3000" + environment: + PORT: 3000 + DATABASE_URL: postgres://postplan:postplan@db:5432/postplan + POSTPLAN_BOOTSTRAP_API_KEY: ${POSTPLAN_BOOTSTRAP_API_KEY:-dev-bootstrap-key} + POSTPLAN_HTML_DIR: /data/html + POSTPLAN_PUBLIC_BASE_URL: ${POSTPLAN_PUBLIC_BASE_URL:-http://localhost:3000} + POSTPLAN_SESSION_SECRET: ${POSTPLAN_SESSION_SECRET:-} + volumes: + - ${POSTPLAN_HTML_VOLUME:-./data/html}:/data/html + depends_on: + db: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 10s + restart: unless-stopped + +volumes: + postgres-data: diff --git a/package.json b/package.json index 2a853e5..75041e7 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "node": ">=20" }, "dependencies": { - "@aws-sdk/client-s3": "^3.835.0", "commander": "^14.0.0", "express": "^5.1.0", "jose": "^6.2.3", diff --git a/src/config.js b/src/config.js index f59f6d8..9a7fc60 100644 --- a/src/config.js +++ b/src/config.js @@ -4,18 +4,13 @@ export const config = { bootstrapApiKey: process.env.POSTPLAN_BOOTSTRAP_API_KEY, publicBaseUrl: process.env.POSTPLAN_PUBLIC_BASE_URL, maxHtmlBytes: Number(process.env.MAX_HTML_BYTES || 512 * 1024), + // Directory for uploaded HTML. In Docker this is /data/html and should be a + // mounted volume so drafts survive container recreation. + htmlDir: process.env.POSTPLAN_HTML_DIR || "./data/html", // Web sign-in (dashboard). Absent POSTPLAN_SESSION_SECRET, all web-auth // routes respond 503 and the API/serving paths are unaffected. sessionSecret: process.env.POSTPLAN_SESSION_SECRET, - shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, ""), - s3: { - endpoint: process.env.AWS_ENDPOINT_URL || process.env.S3_ENDPOINT, - accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID, - secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY, - bucketName: process.env.AWS_S3_BUCKET_NAME || process.env.S3_BUCKET_NAME, - region: process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || "auto", - forcePathStyle: (process.env.AWS_S3_FORCE_PATH_STYLE || "true") !== "false" - } + shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, "") }; export function requireEnv(name, value) { diff --git a/src/server.js b/src/server.js index 7e53fad..5ece4ea 100644 --- a/src/server.js +++ b/src/server.js @@ -4,7 +4,7 @@ import { ensureBootstrapApiKey, initDb } from "./db.js"; import { assertStorageConfigured } from "./storage.js"; async function main() { - assertStorageConfigured(); + await assertStorageConfigured(); await initDb(); await ensureBootstrapApiKey(); diff --git a/src/storage.js b/src/storage.js index 8e0f9b4..7a785c1 100644 --- a/src/storage.js +++ b/src/storage.js @@ -1,59 +1,52 @@ -import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { config, requireEnv } from "./config.js"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { config } from "./config.js"; -let client; - -function getClient() { - if (client) return client; - - client = new S3Client({ - endpoint: requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint), - region: requireEnv("AWS_DEFAULT_REGION", config.s3.region), - forcePathStyle: config.s3.forcePathStyle, - credentials: { - accessKeyId: requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId), - secretAccessKey: requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey) - } - }); - - return client; -} - -export function assertStorageConfigured() { - requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint); - requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId); - requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey); - requireEnv("AWS_S3_BUCKET_NAME", config.s3.bucketName); +export async function assertStorageConfigured() { + const root = htmlRoot(); + await fs.mkdir(root, { recursive: true }); + await fs.access(root, fs.constants.W_OK); } export async function putHtmlObject(key, html) { - assertStorageConfigured(); - await getClient().send( - new PutObjectCommand({ - Bucket: config.s3.bucketName, - Key: key, - Body: html, - ContentType: "text/html; charset=utf-8", - CacheControl: "no-store" - }) - ); + const filePath = resolveObjectPath(key); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, html, "utf8"); } export async function getHtmlObject(key) { - assertStorageConfigured(); - const result = await getClient().send( - new GetObjectCommand({ - Bucket: config.s3.bucketName, - Key: key - }) - ); - return streamToString(result.Body); + const filePath = resolveObjectPath(key); + try { + return await fs.readFile(filePath, "utf8"); + } catch (error) { + if (error.code === "ENOENT") { + const missing = new Error("HTML object not found."); + missing.statusCode = 404; + throw missing; + } + throw error; + } } -async function streamToString(stream) { - const chunks = []; - for await (const chunk of stream) { - chunks.push(Buffer.from(chunk)); +function htmlRoot() { + return path.resolve(config.htmlDir); +} + +function resolveObjectPath(key) { + const root = htmlRoot(); + const normalizedKey = String(key || "").replace(/\\/g, "/"); + if (!normalizedKey || path.isAbsolute(normalizedKey) || normalizedKey.includes("\0")) { + throw new Error("Invalid object key."); } - return Buffer.concat(chunks).toString("utf8"); + const parts = normalizedKey.split("/").filter((part) => part && part !== "."); + if (parts.length === 0 || parts.some((part) => part === "..")) { + throw new Error("Invalid object key."); + } + + const resolved = path.resolve(root, ...parts); + const relative = path.relative(root, resolved); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Invalid object key."); + } + return resolved; } -- 2.54.0