Compare commits
2
Commits
75719cefc0
...
main
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
data
|
||||||
|
*.md
|
||||||
|
skills
|
||||||
|
bin
|
||||||
|
LICENSE
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
data
|
||||||
|
.env
|
||||||
+20
@@ -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"]
|
||||||
@@ -44,14 +44,11 @@ Required service variables:
|
|||||||
|
|
||||||
- `DATABASE_URL`
|
- `DATABASE_URL`
|
||||||
- `POSTPLAN_BOOTSTRAP_API_KEY`
|
- `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:
|
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/<draft-id>` URLs, or a wildcard URL such as `https://*.postplan.dev` for draft subdomains.
|
- `POSTPLAN_PUBLIC_BASE_URL` - set to a normal base URL for `/d/<draft-id>` 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.
|
- `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`).
|
- `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/<draft-id>/raw`
|
- `https://postplan.dev/d/<draft-id>/raw`
|
||||||
- `https://postplan.dev/d/<draft-id>/v/<n>/raw`
|
- `https://postplan.dev/d/<draft-id>/v/<n>/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
|
```sh
|
||||||
chmod +x scripts/provision-railway.sh
|
docker compose up --build
|
||||||
scripts/provision-railway.sh
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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/`.
|
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.
|
||||||
|
|
||||||
Verify the deployed service:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
POSTPLAN_URL=https://your-railway-domain \
|
|
||||||
POSTPLAN_API_KEY=your-bootstrap-or-cli-key \
|
|
||||||
scripts/verify-deployment.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Create a named key from the bootstrap key:
|
Create a named key from the bootstrap key:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -X POST "$POSTPLAN_URL/api/api-keys" \
|
curl -X POST "http://localhost:3000/api/api-keys" \
|
||||||
-H "Authorization: Bearer $POSTPLAN_BOOTSTRAP_API_KEY" \
|
-H "Authorization: Bearer ${POSTPLAN_BOOTSTRAP_API_KEY:-dev-bootstrap-key}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"name":"local-cli"}'
|
-d '{"name":"local-cli"}'
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -22,7 +22,6 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.835.0",
|
|
||||||
"commander": "^14.0.0",
|
"commander": "^14.0.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"jose": "^6.2.3",
|
"jose": "^6.2.3",
|
||||||
|
|||||||
+4
-9
@@ -4,18 +4,13 @@ export const config = {
|
|||||||
bootstrapApiKey: process.env.POSTPLAN_BOOTSTRAP_API_KEY,
|
bootstrapApiKey: process.env.POSTPLAN_BOOTSTRAP_API_KEY,
|
||||||
publicBaseUrl: process.env.POSTPLAN_PUBLIC_BASE_URL,
|
publicBaseUrl: process.env.POSTPLAN_PUBLIC_BASE_URL,
|
||||||
maxHtmlBytes: Number(process.env.MAX_HTML_BYTES || 512 * 1024),
|
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
|
// Web sign-in (dashboard). Absent POSTPLAN_SESSION_SECRET, all web-auth
|
||||||
// routes respond 503 and the API/serving paths are unaffected.
|
// routes respond 503 and the API/serving paths are unaffected.
|
||||||
sessionSecret: process.env.POSTPLAN_SESSION_SECRET,
|
sessionSecret: process.env.POSTPLAN_SESSION_SECRET,
|
||||||
shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, ""),
|
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"
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function requireEnv(name, value) {
|
export function requireEnv(name, value) {
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import { ensureBootstrapApiKey, initDb } from "./db.js";
|
|||||||
import { assertStorageConfigured } from "./storage.js";
|
import { assertStorageConfigured } from "./storage.js";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
assertStorageConfigured();
|
await assertStorageConfigured();
|
||||||
await initDb();
|
await initDb();
|
||||||
await ensureBootstrapApiKey();
|
await ensureBootstrapApiKey();
|
||||||
|
|
||||||
|
|||||||
+41
-48
@@ -1,59 +1,52 @@
|
|||||||
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
import fs from "node:fs/promises";
|
||||||
import { config, requireEnv } from "./config.js";
|
import path from "node:path";
|
||||||
|
import { config } from "./config.js";
|
||||||
|
|
||||||
let client;
|
export async function assertStorageConfigured() {
|
||||||
|
const root = htmlRoot();
|
||||||
function getClient() {
|
await fs.mkdir(root, { recursive: true });
|
||||||
if (client) return client;
|
await fs.access(root, fs.constants.W_OK);
|
||||||
|
|
||||||
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 putHtmlObject(key, html) {
|
export async function putHtmlObject(key, html) {
|
||||||
assertStorageConfigured();
|
const filePath = resolveObjectPath(key);
|
||||||
await getClient().send(
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
new PutObjectCommand({
|
await fs.writeFile(filePath, html, "utf8");
|
||||||
Bucket: config.s3.bucketName,
|
|
||||||
Key: key,
|
|
||||||
Body: html,
|
|
||||||
ContentType: "text/html; charset=utf-8",
|
|
||||||
CacheControl: "no-store"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHtmlObject(key) {
|
export async function getHtmlObject(key) {
|
||||||
assertStorageConfigured();
|
const filePath = resolveObjectPath(key);
|
||||||
const result = await getClient().send(
|
try {
|
||||||
new GetObjectCommand({
|
return await fs.readFile(filePath, "utf8");
|
||||||
Bucket: config.s3.bucketName,
|
} catch (error) {
|
||||||
Key: key
|
if (error.code === "ENOENT") {
|
||||||
})
|
const missing = new Error("HTML object not found.");
|
||||||
);
|
missing.statusCode = 404;
|
||||||
return streamToString(result.Body);
|
throw missing;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function streamToString(stream) {
|
function htmlRoot() {
|
||||||
const chunks = [];
|
return path.resolve(config.htmlDir);
|
||||||
for await (const chunk of stream) {
|
|
||||||
chunks.push(Buffer.from(chunk));
|
|
||||||
}
|
}
|
||||||
return Buffer.concat(chunks).toString("utf8");
|
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user