init
This commit is contained in:
+575
@@ -0,0 +1,575 @@
|
||||
import express from "express";
|
||||
import { contentHash, randomToken, sha256 } from "./crypto.js";
|
||||
import { config } from "./config.js";
|
||||
import { findApiKeyByToken, newEventId, pool, publicUploadAuth, withTransaction } from "./db.js";
|
||||
import { newDraftId, newInternalId } from "./ids.js";
|
||||
import { renderHome, renderNotFound } from "./render.js";
|
||||
import { createRateLimiter } from "./rate-limit.js";
|
||||
import { getHtmlObject, putHtmlObject } from "./storage.js";
|
||||
import { validateHtml } from "./html-policy.js";
|
||||
import { clientIp } from "./client-ip.js";
|
||||
import { listAccountDrafts } from "./drafts.js";
|
||||
import { registerWebRoutes } from "./web.js";
|
||||
import {
|
||||
getDraftIdFromHost,
|
||||
getDraftPublicUrl,
|
||||
getDraftRawUrl,
|
||||
getHomeUrl,
|
||||
getRequestBaseUrl
|
||||
} from "./public-url.js";
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
app.set("trust proxy", true);
|
||||
const uploadIpRateLimit = createRateLimiter({
|
||||
windowMs: Number(process.env.UPLOAD_IP_RATE_LIMIT_WINDOW_MS || 60_000),
|
||||
max: Number(process.env.UPLOAD_IP_RATE_LIMIT_MAX || 60),
|
||||
keyPrefix: "upload-ip",
|
||||
key: (req) => clientIp(req) || "anonymous"
|
||||
});
|
||||
const uploadKeyRateLimit = createRateLimiter({
|
||||
windowMs: Number(process.env.UPLOAD_RATE_LIMIT_WINDOW_MS || 60_000),
|
||||
max: Number(process.env.UPLOAD_RATE_LIMIT_MAX || 30),
|
||||
keyPrefix: "upload-key",
|
||||
key: (req) => req.auth?.id || clientIp(req) || "anonymous"
|
||||
});
|
||||
|
||||
// Scoped to /api so that draft GETs carrying a stray JSON body (some HTTP
|
||||
// clients always send Content-Type: application/json) can never fail with a
|
||||
// body-parser error instead of the draft HTML.
|
||||
app.use("/api", express.json({ limit: process.env.UPLOAD_BODY_LIMIT || "2mb" }));
|
||||
app.use(noStoreHeaders);
|
||||
|
||||
app.get("/", async (req, res, next) => {
|
||||
try {
|
||||
const draftId = getDraftIdFromRequest(req);
|
||||
if (draftId) {
|
||||
await renderDraft(req, res, { draftId });
|
||||
return;
|
||||
}
|
||||
|
||||
res.type("html").send(renderHome({ publicBaseUrl: getHomeUrlForRequest(req) }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/healthz", async (req, res) => {
|
||||
try {
|
||||
await pool.query("SELECT 1");
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
res.status(503).json({ ok: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/me", requireAuth, (req, res) => {
|
||||
res.json({
|
||||
accountId: req.auth.account_id,
|
||||
accountName: req.auth.account_name,
|
||||
apiKeyId: req.auth.id,
|
||||
apiKeyName: req.auth.name
|
||||
});
|
||||
});
|
||||
|
||||
// The "my docs" feed — shared with the dashboard (src/drafts.js).
|
||||
app.get("/api/drafts", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const drafts = await listAccountDrafts(req.auth.account_id, {
|
||||
requestBaseUrl: getRequestBaseUrl(req)
|
||||
});
|
||||
res.json({ ok: true, drafts });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/api-keys", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const token = `pp_${randomToken(32)}`;
|
||||
const apiKeyId = newInternalId();
|
||||
const name = cleanText(req.body?.name) || "CLI API Key";
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO api_keys (id, account_id, name, key_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`,
|
||||
[apiKeyId, req.auth.account_id, name, sha256(token)]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
apiKey: {
|
||||
id: apiKeyId,
|
||||
name
|
||||
},
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/api-keys/:apiKeyId/revoke", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
UPDATE api_keys
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1
|
||||
AND account_id = $2
|
||||
AND revoked_at IS NULL
|
||||
RETURNING id
|
||||
`,
|
||||
[req.params.apiKeyId, req.auth.account_id]
|
||||
);
|
||||
|
||||
if (!result.rowCount) {
|
||||
return res.status(404).json({ ok: false, error: "API key not found." });
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/uploads", uploadIpRateLimit, optionalUploadAuth, uploadKeyRateLimit, async (req, res, next) => {
|
||||
try {
|
||||
const { html, filename, metadata = {}, draftId, description } = req.body || {};
|
||||
const validation = validateHtml(html, { maxBytes: config.maxHtmlBytes });
|
||||
|
||||
if (!validation.ok) {
|
||||
return res.status(422).json({
|
||||
ok: false,
|
||||
errors: validation.errors,
|
||||
warnings: validation.warnings
|
||||
});
|
||||
}
|
||||
|
||||
const byteLength = Buffer.byteLength(html, "utf8");
|
||||
const nowHash = contentHash(html);
|
||||
const sourceIp = clientIp(req);
|
||||
// Railway's edge sets X-Railway-Request-Id and documents it for correlating
|
||||
// against network logs, so we store it verbatim rather than minting our own.
|
||||
const requestId = cleanText(req.get("x-railway-request-id"));
|
||||
const stats = validation.stats || { hasInlineScript: false, externalImageHosts: [] };
|
||||
|
||||
const result = await withTransaction(async (client) => {
|
||||
const existingDraft = draftId
|
||||
? await findOwnedDraft(client, draftId, req.auth.account_id)
|
||||
: null;
|
||||
|
||||
if (draftId && !existingDraft) {
|
||||
const error = new Error("Draft not found.");
|
||||
error.statusCode = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const draft = existingDraft || {
|
||||
id: newDraftId(),
|
||||
account_id: req.auth.account_id
|
||||
};
|
||||
|
||||
const versionNumber = existingDraft
|
||||
? Number(
|
||||
(
|
||||
await client.query(
|
||||
"SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM draft_versions WHERE draft_id = $1",
|
||||
[draft.id]
|
||||
)
|
||||
).rows[0].next_version
|
||||
)
|
||||
: 1;
|
||||
|
||||
const versionId = newInternalId();
|
||||
const objectKey = `drafts/${draft.id}/versions/${versionId}.html`;
|
||||
const title = validation.title || existingDraft?.title || filename || "Untitled Draft";
|
||||
|
||||
await putHtmlObject(objectKey, html);
|
||||
|
||||
if (!existingDraft) {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO drafts (id, account_id, title, description, repo_org, repo_name, repo_host)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
[
|
||||
draft.id,
|
||||
req.auth.account_id,
|
||||
title,
|
||||
cleanText(description, 1000),
|
||||
cleanText(metadata.repoOrg),
|
||||
cleanText(metadata.repoName),
|
||||
cleanText(metadata.repoHost)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO draft_versions (
|
||||
id, draft_id, version_number, object_key, content_hash, file_size,
|
||||
created_by_api_key_id, source_ip, user_agent, cli_version,
|
||||
git_branch, git_commit_sha, original_filename,
|
||||
git_commit_subject, git_dirty, request_id, has_inline_script,
|
||||
external_image_hosts, ci_run_url, ci_actor
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20)
|
||||
`,
|
||||
[
|
||||
versionId,
|
||||
draft.id,
|
||||
versionNumber,
|
||||
objectKey,
|
||||
nowHash,
|
||||
byteLength,
|
||||
req.auth.id,
|
||||
sourceIp,
|
||||
req.get("user-agent") || null,
|
||||
cleanText(metadata.cliVersion),
|
||||
cleanText(metadata.gitBranch),
|
||||
cleanText(metadata.gitCommitSha),
|
||||
cleanText(filename),
|
||||
cleanText(metadata.gitCommitSubject),
|
||||
typeof metadata.gitDirty === "boolean" ? metadata.gitDirty : null,
|
||||
requestId,
|
||||
stats.hasInlineScript,
|
||||
JSON.stringify(stats.externalImageHosts || []),
|
||||
cleanText(metadata.ciRunUrl),
|
||||
cleanText(metadata.ciActor)
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
UPDATE drafts
|
||||
SET current_version_id = $1,
|
||||
title = $2,
|
||||
description = COALESCE($3, description),
|
||||
repo_org = COALESCE($4, repo_org),
|
||||
repo_name = COALESCE($5, repo_name),
|
||||
repo_host = COALESCE($6, repo_host),
|
||||
updated_at = now()
|
||||
WHERE id = $7
|
||||
`,
|
||||
[
|
||||
versionId,
|
||||
title,
|
||||
cleanText(description, 1000),
|
||||
cleanText(metadata.repoOrg),
|
||||
cleanText(metadata.repoName),
|
||||
cleanText(metadata.repoHost),
|
||||
draft.id
|
||||
]
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO upload_events (
|
||||
id, draft_id, draft_version_id, api_key_id, event_type,
|
||||
source_ip, user_agent, metadata_json
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
`,
|
||||
[
|
||||
newEventId(),
|
||||
draft.id,
|
||||
versionId,
|
||||
req.auth.id,
|
||||
existingDraft ? "draft.updated" : "draft.created",
|
||||
sourceIp,
|
||||
req.get("user-agent") || null,
|
||||
metadata
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
draftId: draft.id,
|
||||
versionId,
|
||||
versionNumber,
|
||||
title,
|
||||
requestId,
|
||||
publicUrl: getDraftPublicUrl({
|
||||
draftId: draft.id,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
requestBaseUrl: getRequestBaseUrl(req)
|
||||
}),
|
||||
rawUrl: getDraftRawUrl({
|
||||
draftId: draft.id,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
requestBaseUrl: getRequestBaseUrl(req)
|
||||
}),
|
||||
warnings: validation.warnings
|
||||
};
|
||||
});
|
||||
|
||||
res.status(draftId ? 200 : 201).json({ ok: true, ...result });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/drafts/:draftId", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`
|
||||
UPDATE drafts
|
||||
SET deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1
|
||||
AND account_id = $2
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
`,
|
||||
[req.params.draftId, req.auth.account_id]
|
||||
);
|
||||
|
||||
if (!result.rowCount) {
|
||||
return res.status(404).json({ ok: false, error: "Draft not found." });
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/drafts/:draftId/disable", requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const reason = cleanText(req.body?.reason) || "Disabled by owner.";
|
||||
const result = await pool.query(
|
||||
`
|
||||
UPDATE drafts
|
||||
SET disabled_at = now(), disabled_reason = $3, updated_at = now()
|
||||
WHERE id = $1
|
||||
AND account_id = $2
|
||||
AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
`,
|
||||
[req.params.draftId, req.auth.account_id, reason]
|
||||
);
|
||||
|
||||
if (!result.rowCount) {
|
||||
return res.status(404).json({ ok: false, error: "Draft not found." });
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
registerWebRoutes(app);
|
||||
|
||||
// Every draft URL serves the raw uploaded HTML. The `/raw` aliases are kept
|
||||
// because the upload API and CLI hand them out as the canonical agent URL.
|
||||
const serveCurrent = async (req, res, next) => {
|
||||
try {
|
||||
const draftId = getDraftIdFromRequest(req);
|
||||
if (!draftId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
await renderDraft(req, res, { draftId });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
const serveVersion = async (req, res, next) => {
|
||||
try {
|
||||
const draftId = getDraftIdFromRequest(req);
|
||||
if (!draftId) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
await renderDraft(req, res, {
|
||||
draftId,
|
||||
versionNumber: Number(req.params.versionNumber)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
app.get("/raw", serveCurrent);
|
||||
app.get("/v/:versionNumber", serveVersion);
|
||||
app.get("/v/:versionNumber/raw", serveVersion);
|
||||
|
||||
app.get(["/d/:draftId", "/d/:draftId/raw"], async (req, res, next) => {
|
||||
try {
|
||||
await renderDraft(req, res, { draftId: req.params.draftId });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get(
|
||||
["/d/:draftId/v/:versionNumber", "/d/:draftId/v/:versionNumber/raw"],
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
await renderDraft(req, res, {
|
||||
draftId: req.params.draftId,
|
||||
versionNumber: Number(req.params.versionNumber)
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).type("html").send(renderNotFound());
|
||||
});
|
||||
|
||||
app.use((error, req, res, _next) => {
|
||||
const status = error.statusCode || 500;
|
||||
const message = status >= 500 ? "Internal server error." : error.message;
|
||||
if (status >= 500) {
|
||||
console.error(error);
|
||||
}
|
||||
res.status(status).json({ ok: false, error: message });
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Serve the exact uploaded HTML, byte for byte, to EVERY client — browsers,
|
||||
// curl, and agent fetchers alike. There is deliberately no browser detection,
|
||||
// iframe wrapper, or consent interstitial: any heuristic that tried to tell an
|
||||
// agent from a browser inevitably misclassified some agent fetcher and hid the
|
||||
// draft content the service exists to share. A draft URL is now just its HTML.
|
||||
async function renderDraft(req, res, { draftId, versionNumber }) {
|
||||
if (
|
||||
versionNumber !== undefined &&
|
||||
(!Number.isInteger(versionNumber) || versionNumber < 1)
|
||||
) {
|
||||
return res.status(404).type("html").send(renderNotFound());
|
||||
}
|
||||
|
||||
const { draft, version } = await findPublicDraftVersion(draftId, versionNumber);
|
||||
if (!draft || !version) {
|
||||
return res.status(404).type("html").send(renderNotFound());
|
||||
}
|
||||
|
||||
const html = await getHtmlObject(version.object_key);
|
||||
res.setHeader("Content-Security-Policy", draftContentSecurityPolicy());
|
||||
res.setHeader("X-Postplan-Draft-Id", draft.id);
|
||||
res.setHeader("X-Postplan-Draft-Version", String(Number(version.version_number)));
|
||||
res.type("html").send(html);
|
||||
}
|
||||
|
||||
// A CSP is the only thing kept from the old serving path. It never alters the
|
||||
// bytes a curl/agent client reads, so it does not gate content in any way; it
|
||||
// only constrains what the page may do if a human opens it in a browser —
|
||||
// blocking script execution, cross-origin network requests, and form posts.
|
||||
// Uploaded drafts are already external-script/-form/-iframe free (see
|
||||
// validateHtml) and live on isolated per-draft origins.
|
||||
function draftContentSecurityPolicy() {
|
||||
return [
|
||||
"default-src 'none'",
|
||||
"script-src 'none'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"img-src https: data:",
|
||||
"connect-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"form-action 'none'"
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
async function findPublicDraftVersion(draftId, versionNumber) {
|
||||
const draftResult = await pool.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM drafts
|
||||
WHERE id = $1
|
||||
AND deleted_at IS NULL
|
||||
AND disabled_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[draftId]
|
||||
);
|
||||
|
||||
const draft = draftResult.rows[0] || null;
|
||||
if (!draft) return { draft: null, version: null };
|
||||
|
||||
const versionResult = versionNumber
|
||||
? await pool.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM draft_versions
|
||||
WHERE draft_id = $1 AND version_number = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[draft.id, versionNumber]
|
||||
)
|
||||
: await pool.query("SELECT * FROM draft_versions WHERE id = $1 LIMIT 1", [
|
||||
draft.current_version_id
|
||||
]);
|
||||
|
||||
return { draft, version: versionResult.rows[0] || null };
|
||||
}
|
||||
|
||||
async function findOwnedDraft(client, draftId, accountId) {
|
||||
const result = await client.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM drafts
|
||||
WHERE id = $1
|
||||
AND account_id = $2
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[draftId, accountId]
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function requireAuth(req, res, next) {
|
||||
const auth = await optionalAuth(req);
|
||||
if (!auth) {
|
||||
return res.status(401).json({ ok: false, error: "Missing or invalid API key." });
|
||||
}
|
||||
req.auth = auth;
|
||||
next();
|
||||
}
|
||||
|
||||
async function optionalUploadAuth(req, _res, next) {
|
||||
req.auth = (await optionalAuth(req)) || publicUploadAuth;
|
||||
next();
|
||||
}
|
||||
|
||||
async function optionalAuth(req) {
|
||||
const header = req.get("authorization") || "";
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) return null;
|
||||
return findApiKeyByToken(match[1].trim());
|
||||
}
|
||||
|
||||
function noStoreHeaders(req, res, next) {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
}
|
||||
|
||||
function getHomeUrlForRequest(req) {
|
||||
return getHomeUrl({
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
requestBaseUrl: getRequestBaseUrl(req)
|
||||
});
|
||||
}
|
||||
|
||||
function getDraftIdFromRequest(req) {
|
||||
return getDraftIdFromHost({
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
host: req.hostname || req.get("host")
|
||||
});
|
||||
}
|
||||
|
||||
function cleanText(value, maxLength = 255) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed.slice(0, maxLength) : null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Railway's edge sets `X-Real-IP` to the client's remote address, and documents
|
||||
// it as the header for identifying the client IP:
|
||||
// https://docs.railway.com/networking/public-networking/specs-and-limits
|
||||
//
|
||||
// It is the only trustworthy client-IP source behind Railway. Express's `req.ip`
|
||||
// (with `trust proxy` enabled) reads the LEFT-MOST `X-Forwarded-For` entry, which
|
||||
// a client can spoof by prepending a fake value — so it must not be used for
|
||||
// abuse logging or rate-limit keys. We prefer `X-Real-IP` and only fall back to
|
||||
// `req.ip` for non-Railway/local runs where the edge header is absent.
|
||||
export function clientIp(req) {
|
||||
const realIp = req.get?.("x-real-ip");
|
||||
if (typeof realIp === "string" && realIp.trim()) {
|
||||
return realIp.trim();
|
||||
}
|
||||
return req.ip || null;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const config = {
|
||||
port: Number(process.env.PORT || 3000),
|
||||
databaseUrl: process.env.DATABASE_URL,
|
||||
bootstrapApiKey: process.env.POSTPLAN_BOOTSTRAP_API_KEY,
|
||||
publicBaseUrl: process.env.POSTPLAN_PUBLIC_BASE_URL,
|
||||
maxHtmlBytes: Number(process.env.MAX_HTML_BYTES || 512 * 1024),
|
||||
// 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"
|
||||
}
|
||||
};
|
||||
|
||||
export function requireEnv(name, value) {
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
|
||||
export function sha256(value) {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function contentHash(value) {
|
||||
return sha256(value);
|
||||
}
|
||||
|
||||
export function randomToken(bytes = 32) {
|
||||
return randomBytes(bytes).toString("base64url");
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import pg from "pg";
|
||||
import { config, requireEnv } from "./config.js";
|
||||
import { sha256 } from "./crypto.js";
|
||||
import { newInternalId } from "./ids.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
export const publicUploadAuth = {
|
||||
id: "key_public_upload",
|
||||
account_id: "acct_public_upload",
|
||||
name: "Public Uploads",
|
||||
account_name: "Public Uploads"
|
||||
};
|
||||
|
||||
export const pool = new Pool({
|
||||
connectionString: requireEnv("DATABASE_URL", config.databaseUrl)
|
||||
});
|
||||
|
||||
export async function initDb() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
name TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_used_at TIMESTAMPTZ,
|
||||
revoked_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS drafts (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
current_version_id TEXT,
|
||||
repo_org TEXT,
|
||||
repo_name TEXT,
|
||||
repo_host TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
disabled_at TIMESTAMPTZ,
|
||||
disabled_reason TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS draft_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
draft_id TEXT NOT NULL REFERENCES drafts(id),
|
||||
version_number INTEGER NOT NULL,
|
||||
object_key TEXT NOT NULL,
|
||||
content_hash TEXT NOT NULL,
|
||||
file_size INTEGER NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by_api_key_id TEXT NOT NULL REFERENCES api_keys(id),
|
||||
source_ip TEXT,
|
||||
user_agent TEXT,
|
||||
cli_version TEXT,
|
||||
git_branch TEXT,
|
||||
git_commit_sha TEXT,
|
||||
git_commit_subject TEXT,
|
||||
git_dirty BOOLEAN,
|
||||
original_filename TEXT,
|
||||
request_id TEXT,
|
||||
has_inline_script BOOLEAN,
|
||||
external_image_hosts JSONB,
|
||||
ci_run_url TEXT,
|
||||
ci_actor TEXT,
|
||||
UNIQUE (draft_id, version_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS upload_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
draft_id TEXT NOT NULL REFERENCES drafts(id),
|
||||
draft_version_id TEXT REFERENCES draft_versions(id),
|
||||
api_key_id TEXT NOT NULL REFERENCES api_keys(id),
|
||||
event_type TEXT NOT NULL,
|
||||
source_ip TEXT,
|
||||
user_agent TEXT,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identities (
|
||||
id TEXT PRIMARY KEY,
|
||||
account_id TEXT NOT NULL REFERENCES accounts(id),
|
||||
provider TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
email TEXT,
|
||||
email_verified BOOLEAN,
|
||||
display_name TEXT,
|
||||
picture_url TEXT,
|
||||
pii_subject TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_login_at TIMESTAMPTZ,
|
||||
UNIQUE (provider, subject)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS draft_versions_draft_id_idx ON draft_versions(draft_id);
|
||||
CREATE INDEX IF NOT EXISTS upload_events_draft_id_idx ON upload_events(draft_id);
|
||||
CREATE INDEX IF NOT EXISTS drafts_account_id_idx ON drafts(account_id);
|
||||
|
||||
-- Backfill columns for databases created before they were introduced.
|
||||
ALTER TABLE identities ADD COLUMN IF NOT EXISTS email TEXT;
|
||||
ALTER TABLE identities ADD COLUMN IF NOT EXISTS email_verified BOOLEAN;
|
||||
ALTER TABLE identities ADD COLUMN IF NOT EXISTS display_name TEXT;
|
||||
ALTER TABLE identities ADD COLUMN IF NOT EXISTS picture_url TEXT;
|
||||
ALTER TABLE identities ADD COLUMN IF NOT EXISTS pii_subject TEXT;
|
||||
ALTER TABLE drafts ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
ALTER TABLE drafts ADD COLUMN IF NOT EXISTS repo_host TEXT;
|
||||
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS git_commit_subject TEXT;
|
||||
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS git_dirty BOOLEAN;
|
||||
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS request_id TEXT;
|
||||
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS has_inline_script BOOLEAN;
|
||||
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS external_image_hosts JSONB;
|
||||
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS ci_run_url TEXT;
|
||||
ALTER TABLE draft_versions ADD COLUMN IF NOT EXISTS ci_actor TEXT;
|
||||
`);
|
||||
|
||||
await ensurePublicUploadApiKey();
|
||||
}
|
||||
|
||||
export async function ensureBootstrapApiKey() {
|
||||
if (!config.bootstrapApiKey) return;
|
||||
|
||||
const accountId = "acct_bootstrap";
|
||||
const apiKeyId = "key_bootstrap";
|
||||
const keyHash = sha256(config.bootstrapApiKey);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO accounts (id, name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (id) DO UPDATE SET updated_at = now()
|
||||
`,
|
||||
[accountId, "Bootstrap Account"]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO api_keys (id, account_id, name, key_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET key_hash = EXCLUDED.key_hash,
|
||||
name = EXCLUDED.name,
|
||||
revoked_at = NULL
|
||||
`,
|
||||
[apiKeyId, accountId, "Bootstrap API Key", keyHash]
|
||||
);
|
||||
}
|
||||
|
||||
export async function findApiKeyByToken(token) {
|
||||
const keyHash = sha256(token);
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT api_keys.id, api_keys.account_id, api_keys.name, accounts.name AS account_name
|
||||
FROM api_keys
|
||||
JOIN accounts ON accounts.id = api_keys.account_id
|
||||
WHERE api_keys.key_hash = $1
|
||||
AND api_keys.id <> $2
|
||||
AND api_keys.revoked_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[keyHash, publicUploadAuth.id]
|
||||
);
|
||||
|
||||
const apiKey = result.rows[0] || null;
|
||||
if (apiKey) {
|
||||
await pool.query("UPDATE api_keys SET last_used_at = now() WHERE id = $1", [apiKey.id]);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
async function ensurePublicUploadApiKey() {
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO accounts (id, name)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET name = EXCLUDED.name,
|
||||
updated_at = now()
|
||||
`,
|
||||
[publicUploadAuth.account_id, publicUploadAuth.account_name]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
`
|
||||
INSERT INTO api_keys (id, account_id, name, key_hash)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET key_hash = EXCLUDED.key_hash,
|
||||
name = EXCLUDED.name,
|
||||
revoked_at = NULL
|
||||
`,
|
||||
[
|
||||
publicUploadAuth.id,
|
||||
publicUploadAuth.account_id,
|
||||
publicUploadAuth.name,
|
||||
sha256("postplan-public-upload-sentinel")
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
export async function withTransaction(work) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
const value = await work(client);
|
||||
await client.query("COMMIT");
|
||||
return value;
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export function newEventId() {
|
||||
return newInternalId();
|
||||
}
|
||||
|
||||
// Maps a verified external identity (e.g. shoo's pairwise_sub) to a postplan
|
||||
// account, creating both on first sign-in. Profile claims (from shoo's pii
|
||||
// consent) are refreshed on every login — they can change at Google any time.
|
||||
// Returns { accountId, accountName, email, pictureUrl }.
|
||||
export async function findOrCreateAccountForIdentity({ provider, subject, profile = {} }) {
|
||||
try {
|
||||
return await upsertIdentity({ provider, subject, profile });
|
||||
} catch (error) {
|
||||
// Two concurrent first sign-ins (e.g. two devices) can both miss the
|
||||
// SELECT and collide on UNIQUE(provider, subject). The loser's transaction
|
||||
// rolled back entirely (including its orphan account), so one retry hits
|
||||
// the existing-identity path and succeeds.
|
||||
if (error.code === "23505") {
|
||||
return upsertIdentity({ provider, subject, profile });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertIdentity({ provider, subject, profile }) {
|
||||
const profileParams = [
|
||||
profile.email ?? null,
|
||||
profile.emailVerified ?? null,
|
||||
profile.displayName ?? null,
|
||||
profile.pictureUrl ?? null,
|
||||
profile.piiSubject ?? null
|
||||
];
|
||||
|
||||
return withTransaction(async (client) => {
|
||||
const existing = await client.query(
|
||||
`
|
||||
SELECT identities.account_id, accounts.name AS account_name
|
||||
FROM identities
|
||||
JOIN accounts ON accounts.id = identities.account_id
|
||||
WHERE identities.provider = $1 AND identities.subject = $2
|
||||
LIMIT 1
|
||||
`,
|
||||
[provider, subject]
|
||||
);
|
||||
|
||||
if (existing.rows[0]) {
|
||||
const accountId = existing.rows[0].account_id;
|
||||
// The verified token is authoritative for profile fields: a claim absent
|
||||
// from this login (e.g. the user removed their Google picture) clears the
|
||||
// stored value, so DB, session, and header always agree. pii_subject is
|
||||
// the one exception — it is a stable identifier, not editable profile,
|
||||
// and a transient absence must not unlink the account.
|
||||
await client.query(
|
||||
`
|
||||
UPDATE identities
|
||||
SET last_login_at = now(),
|
||||
email = $3,
|
||||
email_verified = $4,
|
||||
display_name = $5,
|
||||
picture_url = $6,
|
||||
pii_subject = COALESCE($7, pii_subject)
|
||||
WHERE provider = $1 AND subject = $2
|
||||
`,
|
||||
[provider, subject, ...profileParams]
|
||||
);
|
||||
|
||||
// Same authoritative-token rule for the derived account name: if the
|
||||
// user clears their Google name/email, the account label degrades to the
|
||||
// neutral fallback instead of retaining old PII.
|
||||
const accountName =
|
||||
profile.displayName || profile.email || `Postplan ${subject.slice(-6)}`;
|
||||
if (accountName !== existing.rows[0].account_name) {
|
||||
await client.query("UPDATE accounts SET name = $2, updated_at = now() WHERE id = $1", [
|
||||
accountId,
|
||||
accountName
|
||||
]);
|
||||
}
|
||||
return { accountId, accountName, email: profile.email ?? null, pictureUrl: profile.pictureUrl ?? null };
|
||||
}
|
||||
|
||||
const accountId = `acct_${newInternalId()}`;
|
||||
const accountName = profile.displayName || profile.email || `Postplan ${subject.slice(-6)}`;
|
||||
await client.query("INSERT INTO accounts (id, name) VALUES ($1, $2)", [
|
||||
accountId,
|
||||
accountName
|
||||
]);
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO identities (
|
||||
id, account_id, provider, subject,
|
||||
email, email_verified, display_name, picture_url, pii_subject,
|
||||
last_login_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, now())
|
||||
`,
|
||||
[newInternalId(), accountId, provider, subject, ...profileParams]
|
||||
);
|
||||
return { accountId, accountName, email: profile.email ?? null, pictureUrl: profile.pictureUrl ?? null };
|
||||
});
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { pool } from "./db.js";
|
||||
import { config } from "./config.js";
|
||||
import { getDraftPublicUrl, getDraftRawUrl } from "./public-url.js";
|
||||
|
||||
// The "my docs" feed: every draft owned by an account, newest first, with the
|
||||
// aggregates a dashboard needs (latest version, version count, repo). Shared
|
||||
// by GET /api/drafts and the server-rendered dashboard.
|
||||
export async function listAccountDrafts(accountId, { requestBaseUrl }) {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT
|
||||
d.id,
|
||||
d.title,
|
||||
d.description,
|
||||
d.repo_org,
|
||||
d.repo_name,
|
||||
d.repo_host,
|
||||
d.created_at,
|
||||
d.updated_at,
|
||||
d.disabled_at,
|
||||
cv.version_number AS latest_version_number,
|
||||
cv.created_at AS latest_version_at,
|
||||
COALESCE(vc.version_count, 0) AS version_count
|
||||
FROM drafts d
|
||||
LEFT JOIN draft_versions cv ON cv.id = d.current_version_id
|
||||
LEFT JOIN (
|
||||
SELECT draft_id, COUNT(*)::int AS version_count
|
||||
FROM draft_versions
|
||||
GROUP BY draft_id
|
||||
) vc ON vc.draft_id = d.id
|
||||
WHERE d.account_id = $1
|
||||
AND d.deleted_at IS NULL
|
||||
ORDER BY d.updated_at DESC
|
||||
`,
|
||||
[accountId]
|
||||
);
|
||||
|
||||
return result.rows.map((row) => ({
|
||||
draftId: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
repoOrg: row.repo_org,
|
||||
repoName: row.repo_name,
|
||||
repoHost: row.repo_host,
|
||||
latestVersionNumber:
|
||||
row.latest_version_number === null ? null : Number(row.latest_version_number),
|
||||
versionCount: Number(row.version_count),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
latestVersionAt: row.latest_version_at,
|
||||
disabled: Boolean(row.disabled_at),
|
||||
publicUrl: getDraftPublicUrl({
|
||||
draftId: row.id,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
requestBaseUrl
|
||||
}),
|
||||
rawUrl: getDraftRawUrl({
|
||||
draftId: row.id,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
requestBaseUrl
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getAccountDraftWithVersions(accountId, draftId, { requestBaseUrl }) {
|
||||
const draftResult = await pool.query(
|
||||
`
|
||||
SELECT *
|
||||
FROM drafts
|
||||
WHERE id = $1 AND account_id = $2 AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
`,
|
||||
[draftId, accountId]
|
||||
);
|
||||
const draft = draftResult.rows[0];
|
||||
if (!draft) return null;
|
||||
|
||||
const versionsResult = await pool.query(
|
||||
`
|
||||
SELECT id, version_number, created_at, git_branch, git_commit_sha,
|
||||
git_commit_subject, git_dirty, file_size
|
||||
FROM draft_versions
|
||||
WHERE draft_id = $1
|
||||
ORDER BY version_number DESC
|
||||
`,
|
||||
[draftId]
|
||||
);
|
||||
|
||||
return {
|
||||
draft: {
|
||||
draftId: draft.id,
|
||||
title: draft.title,
|
||||
description: draft.description,
|
||||
publicUrl: getDraftPublicUrl({
|
||||
draftId: draft.id,
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
requestBaseUrl
|
||||
})
|
||||
},
|
||||
versions: versionsResult.rows
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as parse5 from "parse5";
|
||||
|
||||
const BLOCKED_TAGS = new Set([
|
||||
"form",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"applet",
|
||||
"base",
|
||||
"link"
|
||||
]);
|
||||
|
||||
const URL_ATTRS = new Set([
|
||||
"href",
|
||||
"src",
|
||||
"action",
|
||||
"formaction",
|
||||
"poster",
|
||||
"srcdoc",
|
||||
"xlink:href"
|
||||
]);
|
||||
|
||||
const BLOCKED_PROTOCOLS = ["javascript:", "vbscript:", "file:"];
|
||||
const ALLOWED_SCRIPT_TYPES = new Set(["", "text/javascript", "application/javascript"]);
|
||||
|
||||
// Far above any real document (browsers themselves flatten around 512), but
|
||||
// well below where the recursive parse5 serializer used for the framed view
|
||||
// would overflow the call stack (~2000+ levels).
|
||||
const MAX_DEPTH = 512;
|
||||
|
||||
export function validateHtml(html, options = {}) {
|
||||
const maxBytes = options.maxBytes ?? 512 * 1024;
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (typeof html !== "string" || html.trim() === "") {
|
||||
errors.push("HTML document is empty.");
|
||||
return { ok: false, errors, warnings, title: null, hasScripts: false, stats: emptyStats() };
|
||||
}
|
||||
|
||||
const byteLength = Buffer.byteLength(html, "utf8");
|
||||
if (byteLength > maxBytes) {
|
||||
errors.push(`HTML document is ${byteLength} bytes; maximum is ${maxBytes} bytes.`);
|
||||
}
|
||||
|
||||
let document;
|
||||
try {
|
||||
// scriptingEnabled: false so <noscript> children parse as real elements:
|
||||
// the hosted viewer renders drafts in an iframe without allow-scripts
|
||||
// unless consented, and the policy must see what that frame shows.
|
||||
document = parse5.parse(html, { scriptingEnabled: false });
|
||||
} catch {
|
||||
errors.push("HTML document could not be parsed.");
|
||||
return { ok: false, errors, warnings, title: null, hasScripts: false, stats: emptyStats() };
|
||||
}
|
||||
|
||||
let title = null;
|
||||
let hasScripts = false;
|
||||
const externalImageHosts = new Set();
|
||||
|
||||
function visit(node) {
|
||||
if (node.tagName) {
|
||||
const tagName = node.tagName.toLowerCase();
|
||||
|
||||
if (BLOCKED_TAGS.has(tagName)) {
|
||||
errors.push(`Blocked <${tagName}> tag found.`);
|
||||
}
|
||||
|
||||
if (tagName === "script") {
|
||||
hasScripts = true;
|
||||
const attributes = new Map(
|
||||
(node.attrs || []).map((attr) => [attr.name.toLowerCase(), String(attr.value || "").trim()])
|
||||
);
|
||||
if (attributes.has("src")) {
|
||||
errors.push("External script sources are not allowed.");
|
||||
}
|
||||
|
||||
const scriptType = (attributes.get("type") || "").toLowerCase();
|
||||
if (!ALLOWED_SCRIPT_TYPES.has(scriptType)) {
|
||||
errors.push(`Unsupported script type "${scriptType}" found.`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const attr of node.attrs || []) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const value = String(attr.value || "").trim();
|
||||
|
||||
if (name.startsWith("on")) {
|
||||
errors.push(`Blocked inline event handler attribute "${name}" found.`);
|
||||
}
|
||||
|
||||
if (name === "srcdoc") {
|
||||
errors.push('Blocked "srcdoc" attribute found.');
|
||||
}
|
||||
|
||||
if (URL_ATTRS.has(name)) {
|
||||
const normalized = value.replace(/[\u0000-\u0020]+/g, "").toLowerCase();
|
||||
if (BLOCKED_PROTOCOLS.some((protocol) => normalized.startsWith(protocol))) {
|
||||
errors.push(`Blocked unsafe URL in "${name}" attribute.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (name === "style" && /expression\s*\(|behavior\s*:|url\s*\(\s*javascript:/i.test(value)) {
|
||||
errors.push("Blocked unsafe inline CSS.");
|
||||
}
|
||||
}
|
||||
|
||||
if (tagName === "meta") {
|
||||
const httpEquiv = (node.attrs || []).find((attr) => attr.name.toLowerCase() === "http-equiv");
|
||||
if (httpEquiv && httpEquiv.value.trim().toLowerCase() === "refresh") {
|
||||
errors.push("Blocked meta refresh tag found.");
|
||||
}
|
||||
}
|
||||
|
||||
// Images are the one external resource the serving CSP allows (img-src
|
||||
// https: data:), so record which hosts a draft pulls from for later review.
|
||||
if (tagName === "img") {
|
||||
const src = (node.attrs || []).find((attr) => attr.name.toLowerCase() === "src");
|
||||
const host = externalHost(src?.value);
|
||||
if (host) externalImageHosts.add(host);
|
||||
}
|
||||
}
|
||||
|
||||
if (node.tagName === "title" && !title) {
|
||||
title = collectText(node).trim().slice(0, 140) || null;
|
||||
}
|
||||
}
|
||||
|
||||
let tooDeep = false;
|
||||
const stack = [{ node: document, depth: 0 }];
|
||||
while (stack.length) {
|
||||
const { node, depth } = stack.pop();
|
||||
visit(node);
|
||||
if (depth >= MAX_DEPTH) {
|
||||
tooDeep = true;
|
||||
continue;
|
||||
}
|
||||
const children = node.childNodes || [];
|
||||
for (let i = children.length - 1; i >= 0; i--) {
|
||||
stack.push({ node: children[i], depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
if (tooDeep) {
|
||||
errors.push(`HTML is nested more than ${MAX_DEPTH} levels deep.`);
|
||||
}
|
||||
|
||||
if (!title) {
|
||||
warnings.push("No <title> found; Postplan will use a generic title.");
|
||||
}
|
||||
|
||||
return {
|
||||
ok: errors.length === 0,
|
||||
errors: [...new Set(errors)],
|
||||
warnings: [...new Set(warnings)],
|
||||
title,
|
||||
hasScripts,
|
||||
stats: {
|
||||
hasInlineScript: hasScripts,
|
||||
externalImageHosts: [...externalImageHosts].sort()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function emptyStats() {
|
||||
return { hasInlineScript: false, externalImageHosts: [] };
|
||||
}
|
||||
|
||||
// Returns the lowercased host of an absolute http(s) (or protocol-relative) URL,
|
||||
// or null for relative paths, data: URIs, and anything unparseable.
|
||||
function externalHost(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return null;
|
||||
const candidate = raw.startsWith("//") ? `https:${raw}` : raw;
|
||||
try {
|
||||
const url = new URL(candidate);
|
||||
if (url.protocol === "http:" || url.protocol === "https:") {
|
||||
return url.hostname.toLowerCase();
|
||||
}
|
||||
} catch {
|
||||
// relative path, data: URI, etc. — not an external host
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectText(node) {
|
||||
let value = "";
|
||||
for (const child of node.childNodes || []) {
|
||||
if (child.nodeName === "#text") value += child.value || "";
|
||||
value += collectText(child);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import { customAlphabet } from "nanoid";
|
||||
|
||||
const draftId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyz", 12);
|
||||
const internalId = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 20);
|
||||
|
||||
export function newDraftId() {
|
||||
return draftId();
|
||||
}
|
||||
|
||||
export function newInternalId() {
|
||||
return internalId();
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
const DRAFT_ID_PATTERN = /^[a-z0-9]{12}$/;
|
||||
|
||||
export function getRequestBaseUrl(req) {
|
||||
const forwardedProto = req.get("x-forwarded-proto");
|
||||
const protocol = forwardedProto
|
||||
? forwardedProto.split(",")[0].trim()
|
||||
: req.protocol || "http";
|
||||
return `${protocol}://${req.get("host")}`;
|
||||
}
|
||||
|
||||
export function getHomeUrl({ publicBaseUrl, requestBaseUrl }) {
|
||||
const configured = normalizeUrl(publicBaseUrl);
|
||||
const wildcard = parseWildcardBaseUrl(configured);
|
||||
|
||||
if (wildcard) {
|
||||
wildcard.hostname = wildcard.hostname.slice(2);
|
||||
wildcard.pathname = "/";
|
||||
wildcard.search = "";
|
||||
wildcard.hash = "";
|
||||
return stripTrailingSlash(wildcard.toString());
|
||||
}
|
||||
|
||||
return configured || normalizeUrl(requestBaseUrl);
|
||||
}
|
||||
|
||||
export function getDraftPublicUrl({ draftId, publicBaseUrl, requestBaseUrl }) {
|
||||
const configured = normalizeUrl(publicBaseUrl);
|
||||
const wildcard = parseWildcardBaseUrl(configured);
|
||||
|
||||
if (wildcard) {
|
||||
wildcard.hostname = `${draftId}.${wildcard.hostname.slice(2)}`;
|
||||
wildcard.pathname = "/";
|
||||
wildcard.search = "";
|
||||
wildcard.hash = "";
|
||||
return stripTrailingSlash(wildcard.toString());
|
||||
}
|
||||
|
||||
const baseUrl = configured || normalizeUrl(requestBaseUrl);
|
||||
return `${baseUrl}/d/${draftId}`;
|
||||
}
|
||||
|
||||
export function getDraftRawUrl({ draftId, publicBaseUrl, requestBaseUrl }) {
|
||||
const configured = normalizeUrl(publicBaseUrl);
|
||||
const wildcard = parseWildcardBaseUrl(configured);
|
||||
|
||||
if (wildcard) {
|
||||
wildcard.hostname = wildcard.hostname.slice(2);
|
||||
wildcard.pathname = `/d/${draftId}/raw`;
|
||||
wildcard.search = "";
|
||||
wildcard.hash = "";
|
||||
return wildcard.toString();
|
||||
}
|
||||
|
||||
const baseUrl = configured || normalizeUrl(requestBaseUrl);
|
||||
return `${baseUrl}/d/${draftId}/raw`;
|
||||
}
|
||||
|
||||
export function getDraftIdFromHost({ publicBaseUrl, host }) {
|
||||
const wildcard = parseWildcardBaseUrl(publicBaseUrl);
|
||||
if (!wildcard) return null;
|
||||
|
||||
const rootHost = wildcard.hostname.slice(2).toLowerCase();
|
||||
const requestHost = parseHost(host);
|
||||
if (!requestHost || !requestHost.endsWith(`.${rootHost}`)) return null;
|
||||
|
||||
const draftId = requestHost.slice(0, -(rootHost.length + 1));
|
||||
if (draftId.includes(".") || !DRAFT_ID_PATTERN.test(draftId)) return null;
|
||||
return draftId;
|
||||
}
|
||||
|
||||
function parseWildcardBaseUrl(value) {
|
||||
const url = parseUrl(value);
|
||||
if (!url || !url.hostname.startsWith("*.")) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
function parseHost(value) {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
return new URL(`http://${normalized}`).hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUrl(value) {
|
||||
const normalized = normalizeUrl(value);
|
||||
if (!normalized) return null;
|
||||
|
||||
try {
|
||||
return new URL(normalized);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
return value.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
const buckets = new Map();
|
||||
|
||||
export function createRateLimiter({ windowMs, max, keyPrefix, key }) {
|
||||
return function rateLimiter(req, res, next) {
|
||||
const now = Date.now();
|
||||
const identity = key ? key(req) : req.auth?.id || req.ip || "anonymous";
|
||||
const bucketKey = `${keyPrefix}:${identity}`;
|
||||
const current = buckets.get(bucketKey);
|
||||
|
||||
if (!current || current.resetAt <= now) {
|
||||
buckets.set(bucketKey, { count: 1, resetAt: now + windowMs });
|
||||
return next();
|
||||
}
|
||||
|
||||
current.count += 1;
|
||||
if (current.count > max) {
|
||||
res.setHeader("Retry-After", String(Math.ceil((current.resetAt - now) / 1000)));
|
||||
return res.status(429).json({ ok: false, error: "Upload rate limit exceeded." });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
// Server-rendered dashboard pages. These are postplan's own UI (apex domain
|
||||
// only) — unlike draft serving they may use inline styles/JS freely; the
|
||||
// draft-serving CSP never applies here.
|
||||
|
||||
export function renderSignIn({ next }) {
|
||||
const target = `/auth/sign-in?next=${encodeURIComponent(next || "/dashboard")}`;
|
||||
return webPage({
|
||||
title: "Sign in — Postplan",
|
||||
body: `
|
||||
<main class="narrow center">
|
||||
<h1>Postplan</h1>
|
||||
<p class="muted">Sign in to see the drafts you've published.</p>
|
||||
<p><a class="button" href="${escapeHtml(target)}">Continue with shoo</a></p>
|
||||
<p class="muted small">Publishing from the CLI stays anonymous unless you attach a key.</p>
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
export function renderAuthError({ message }) {
|
||||
return webPage({
|
||||
title: "Sign-in problem — Postplan",
|
||||
body: `
|
||||
<main class="narrow center">
|
||||
<h1>Sign-in problem</h1>
|
||||
<p class="muted">${escapeHtml(message)}</p>
|
||||
<p><a class="button" href="/auth/sign-in">Try again</a></p>
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
export function renderDashboard({ session, drafts }) {
|
||||
const groups = groupByRepo(drafts);
|
||||
const sections = groups
|
||||
.map(
|
||||
(group) => `
|
||||
<section>
|
||||
<h2>${escapeHtml(group.label)}${
|
||||
group.href
|
||||
? ` <a class="small repo-link" href="${escapeHtml(group.href)}" target="_blank" rel="noopener noreferrer">GitHub ↗</a>`
|
||||
: ""
|
||||
}</h2>
|
||||
${group.drafts.map(renderDraftRow).join("\n")}
|
||||
</section>
|
||||
`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return webPage({
|
||||
title: "My drafts — Postplan",
|
||||
header: pageHeader({ session, active: "dashboard" }),
|
||||
body: `
|
||||
<main>
|
||||
<h1>My drafts</h1>
|
||||
${
|
||||
drafts.length
|
||||
? sections
|
||||
: `<p class="muted">No drafts yet. Publish one with <code>postplan upload plan.html</code> using a key from <a href="/cli/auth">CLI setup</a>.</p>`
|
||||
}
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
export function renderDraftDetail({ session, draft, versions }) {
|
||||
const rows = versions
|
||||
.map(
|
||||
(v) => `
|
||||
<tr>
|
||||
<td><a href="${escapeHtml(draft.publicUrl)}/v/${Number(v.version_number)}" target="_blank" rel="noopener noreferrer">v${Number(v.version_number)}</a></td>
|
||||
<td>${escapeHtml(v.git_commit_subject || "")}${v.git_dirty ? ' <span class="pill warn">dirty</span>' : ""}</td>
|
||||
<td class="muted">${escapeHtml(v.git_branch || "")} ${escapeHtml((v.git_commit_sha || "").slice(0, 7))}</td>
|
||||
<td class="muted">${escapeHtml(formatDate(v.created_at))}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return webPage({
|
||||
title: `${draft.title} — Postplan`,
|
||||
header: pageHeader({ session, active: "dashboard" }),
|
||||
body: `
|
||||
<main>
|
||||
<p class="small"><a href="/dashboard">← My drafts</a></p>
|
||||
<h1>${escapeHtml(draft.title)}</h1>
|
||||
${draft.description ? `<p class="muted">${escapeHtml(draft.description)}</p>` : ""}
|
||||
<p><a href="${escapeHtml(draft.publicUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(draft.publicUrl)}</a></p>
|
||||
<table>
|
||||
<tr><th>Version</th><th>Commit</th><th>Ref</th><th>Published</th></tr>
|
||||
${rows}
|
||||
</table>
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
export function renderCliAuth({ session, keys = [] }) {
|
||||
const keyRows = keys
|
||||
.map(
|
||||
(key) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(key.name)}</td>
|
||||
<td class="muted">${escapeHtml(formatDate(key.created_at))}</td>
|
||||
<td class="muted">${key.last_used_at ? escapeHtml(formatDate(key.last_used_at)) : "never used"}</td>
|
||||
<td>
|
||||
<form method="post" action="/cli/auth/keys/${escapeHtml(key.id)}/revoke">
|
||||
<button class="linklike" type="submit">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return webPage({
|
||||
title: "CLI setup — Postplan",
|
||||
header: pageHeader({ session, active: "cli" }),
|
||||
body: `
|
||||
<main class="narrow">
|
||||
<h1>Connect your CLI</h1>
|
||||
<p class="muted">Generate a key, then paste it into the waiting <code>postplan auth login</code> prompt in your terminal.</p>
|
||||
<form method="post" action="/cli/auth/keys">
|
||||
<button class="button" type="submit">Generate a new API key</button>
|
||||
</form>
|
||||
<p class="muted small">Each visit can mint a fresh key. Keys are shown once.</p>
|
||||
${
|
||||
keys.length
|
||||
? `<h2>Active keys</h2>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Created</th><th>Last used</th><th></th></tr>
|
||||
${keyRows}
|
||||
</table>`
|
||||
: ""
|
||||
}
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
export function renderCliAuthKey({ session, token, keyName }) {
|
||||
return webPage({
|
||||
title: "Your new API key — Postplan",
|
||||
header: pageHeader({ session, active: "cli" }),
|
||||
body: `
|
||||
<main class="narrow">
|
||||
<h1>Your new API key</h1>
|
||||
<p class="muted">Named <strong>${escapeHtml(keyName)}</strong>. Shown once — copy it now and paste it into your terminal.</p>
|
||||
<div class="keybox">
|
||||
<code id="key">${escapeHtml(token)}</code>
|
||||
<button class="button" id="copy" type="button">Copy</button>
|
||||
</div>
|
||||
<p class="muted small">Terminal: <code>postplan auth login</code> (or <code>postplan auth set <key></code>).</p>
|
||||
<script>
|
||||
document.getElementById("copy").addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(document.getElementById("key").textContent);
|
||||
document.getElementById("copy").textContent = "Copied";
|
||||
});
|
||||
</script>
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
function renderDraftRow(draft) {
|
||||
// The title is the one-click "open the plan" action (new tab); the internal
|
||||
// detail/version-history screen hangs off the separate Details link.
|
||||
return `
|
||||
<div class="row">
|
||||
<div>
|
||||
<a class="row-title" href="${escapeHtml(draft.publicUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(draft.title)}</a>
|
||||
${draft.disabled ? '<span class="pill warn">disabled</span>' : ""}
|
||||
${draft.description ? `<div class="muted small">${escapeHtml(draft.description)}</div>` : ""}
|
||||
</div>
|
||||
<div class="row-meta muted small">
|
||||
<a href="/dashboard/drafts/${escapeHtml(draft.draftId)}">Details</a> ·
|
||||
v${draft.latestVersionNumber ?? "—"} · ${draft.versionCount} version${draft.versionCount === 1 ? "" : "s"} · ${escapeHtml(formatDate(draft.updatedAt))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function groupByRepo(drafts) {
|
||||
const map = new Map();
|
||||
for (const draft of drafts) {
|
||||
const hasRepo = draft.repoOrg && draft.repoName;
|
||||
const key = hasRepo ? `${draft.repoOrg}/${draft.repoName}` : "";
|
||||
if (!map.has(key)) {
|
||||
// Drafts uploaded by pre-0.0.3 CLIs have no repo_host; default those to
|
||||
// github.com so the one-click repo link still works. Any member draft
|
||||
// with a recorded host upgrades the group's link below.
|
||||
map.set(key, {
|
||||
label: hasRepo ? key : "No repository",
|
||||
href: hasRepo
|
||||
? `https://${draft.repoHost || "github.com"}/${draft.repoOrg}/${draft.repoName}`
|
||||
: null,
|
||||
drafts: []
|
||||
});
|
||||
}
|
||||
const group = map.get(key);
|
||||
if (hasRepo && draft.repoHost) {
|
||||
group.href = `https://${draft.repoHost}/${draft.repoOrg}/${draft.repoName}`;
|
||||
}
|
||||
group.drafts.push(draft);
|
||||
}
|
||||
// Repo groups first (already newest-first within), "No repository" last.
|
||||
return [...map.entries()].sort(([a], [b]) => (a === "") - (b === "")).map(([, g]) => g);
|
||||
}
|
||||
|
||||
function pageHeader({ session = {}, active }) {
|
||||
// Claims arrive via shoo's verified id_token, but only render an avatar for
|
||||
// plain https URLs anyway.
|
||||
const avatar =
|
||||
typeof session.pictureUrl === "string" && session.pictureUrl.startsWith("https://")
|
||||
? `<img class="avatar" src="${escapeHtml(session.pictureUrl)}" alt="" referrerpolicy="no-referrer">`
|
||||
: "";
|
||||
// The email is PII: render it blurred until hover/focus (shoo's own /me
|
||||
// pattern) so it never leaks on screenshares or streams. A user with no
|
||||
// Google display name gets their email as the account name — in that case
|
||||
// skip the plain name and show only the blurred email.
|
||||
const name =
|
||||
session.accountName && session.accountName !== session.email
|
||||
? `<span class="muted small">${escapeHtml(session.accountName)}</span>`
|
||||
: "";
|
||||
const email = session.email
|
||||
? `<span class="muted small pii" tabindex="0" title="Hover to reveal">${escapeHtml(session.email)}</span>`
|
||||
: "";
|
||||
|
||||
return `
|
||||
<header class="top">
|
||||
<nav>
|
||||
<a href="/dashboard" class="${active === "dashboard" ? "active" : ""}">My drafts</a>
|
||||
<a href="/cli/auth" class="${active === "cli" ? "active" : ""}">CLI setup</a>
|
||||
</nav>
|
||||
<form method="post" action="/auth/sign-out">
|
||||
${avatar}
|
||||
${name}
|
||||
${email}
|
||||
<button class="linklike" type="submit">Sign out</button>
|
||||
</form>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? "" : date.toISOString().slice(0, 16).replace("T", " ");
|
||||
}
|
||||
|
||||
function webPage({ title, body, header = "" }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body { margin: 0; background: #f8fafc; color: #111827; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
main { max-width: 860px; margin: 32px auto 80px; padding: 0 20px; }
|
||||
main.narrow { max-width: 560px; }
|
||||
main.center { text-align: center; margin-top: 96px; }
|
||||
h1 { font-size: 30px; margin: 0 0 14px; }
|
||||
h2 { font-size: 17px; margin: 30px 0 6px; }
|
||||
p { line-height: 1.6; }
|
||||
a { color: #1d4ed8; }
|
||||
code { background: #eef2f7; border: 1px solid #d1d5db; border-radius: 5px; padding: 1px 5px; font-size: 14px; }
|
||||
.muted { color: #6b7280; }
|
||||
.small { font-size: 13px; }
|
||||
.button { display: inline-block; background: #111827; color: #fff; border: 0; border-radius: 8px; padding: 10px 18px; font-size: 15px; text-decoration: none; cursor: pointer; }
|
||||
.linklike { background: none; border: 0; color: #1d4ed8; cursor: pointer; font-size: 13px; padding: 0; margin-left: 10px; text-decoration: underline; }
|
||||
.top { display: flex; justify-content: space-between; align-items: center; max-width: 860px; margin: 0 auto; padding: 14px 20px; border-bottom: 1px solid #e5e7eb; }
|
||||
.top nav a { margin-right: 16px; text-decoration: none; color: #374151; }
|
||||
.top nav a.active { color: #111827; font-weight: 600; }
|
||||
.top form { display: inline-flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 26px; height: 26px; border-radius: 50%; vertical-align: middle; }
|
||||
.repo-link { font-weight: 400; text-decoration: none; margin-left: 6px; }
|
||||
.row-meta a { color: #6b7280; }
|
||||
.pii { filter: blur(4px); border-radius: 3px; transition: filter .15s ease; cursor: pointer; }
|
||||
.pii:hover, .pii:focus { filter: none; outline: none; }
|
||||
.row { display: flex; justify-content: space-between; gap: 14px; align-items: baseline; padding: 10px 0; border-bottom: 1px solid #e5e7eb; }
|
||||
.row-title { font-weight: 600; text-decoration: none; }
|
||||
.row-meta { white-space: nowrap; }
|
||||
.pill { font-size: 11px; border-radius: 999px; padding: 2px 8px; margin-left: 6px; }
|
||||
.pill.warn { background: #fef3c7; color: #92400e; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 14px; }
|
||||
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid #e5e7eb; font-size: 14px; }
|
||||
th { color: #6b7280; font-weight: 600; font-size: 12px; text-transform: uppercase; }
|
||||
.keybox { display: flex; gap: 10px; align-items: center; background: #fff; border: 1px solid #d1d5db; border-radius: 8px; padding: 14px; margin: 14px 0; }
|
||||
.keybox code { flex: 1; word-break: break-all; background: none; border: 0; font-size: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>${header}${body}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
export function renderHome({ publicBaseUrl }) {
|
||||
return htmlPage({
|
||||
title: "Postplan",
|
||||
body: `
|
||||
<main class="home">
|
||||
<h1>Postplan</h1>
|
||||
<p>Authenticated static HTML draft publishing for agents.</p>
|
||||
<pre>npx postplan upload ./plan.html</pre>
|
||||
<p><a href="/dashboard">My drafts</a> · <a href="/cli/auth">CLI setup</a></p>
|
||||
<p>Health: <a href="/healthz">/healthz</a></p>
|
||||
<p>Public base URL: ${escapeHtml(publicBaseUrl || "not configured")}</p>
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
export function renderNotFound() {
|
||||
return htmlPage({
|
||||
title: "Draft not found",
|
||||
body: `
|
||||
<main class="home">
|
||||
<h1>Draft not found</h1>
|
||||
<p>The requested draft is unavailable.</p>
|
||||
</main>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
function htmlPage({ title, body }) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #f8fafc;
|
||||
color: #111827;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.home {
|
||||
max-width: 760px;
|
||||
margin: 64px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 40px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #374151;
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow-x: auto;
|
||||
padding: 14px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #ffffff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>${body}</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createApp } from "./api.js";
|
||||
import { config } from "./config.js";
|
||||
import { ensureBootstrapApiKey, initDb } from "./db.js";
|
||||
import { assertStorageConfigured } from "./storage.js";
|
||||
|
||||
async function main() {
|
||||
assertStorageConfigured();
|
||||
await initDb();
|
||||
await ensureBootstrapApiKey();
|
||||
|
||||
const app = createApp();
|
||||
app.listen(config.port, () => {
|
||||
console.log(`Postplan listening on port ${config.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRemoteJWKSet, jwtVerify } from "jose";
|
||||
import { config } from "./config.js";
|
||||
import { randomToken } from "./crypto.js";
|
||||
|
||||
// shoo (shoo.dev) protocol facts, extracted from its source:
|
||||
// - Clients are auto-registered by redirect_uri origin; client_id is always
|
||||
// derived as `origin:<origin>` and never needs a secret.
|
||||
// - /authorize requires redirect_uri, state, code_challenge (S256 only).
|
||||
// - /token takes application/x-www-form-urlencoded, and redirect_uri must be
|
||||
// byte-identical to the one sent to /authorize. Codes are single-use, 120s.
|
||||
// - The id_token is ES256; aud is `origin:<origin>`; the stable per-site user
|
||||
// id is `pairwise_sub` (deterministic, survives revoke + re-auth).
|
||||
// - The only error shoo redirects back is ?error=access_denied — everything
|
||||
// else renders on shoo itself.
|
||||
|
||||
let jwksCache = null;
|
||||
let issuerCache = null;
|
||||
|
||||
export function buildPkce() {
|
||||
const verifier = randomToken(32);
|
||||
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
||||
return { verifier, challenge, state: randomToken(24) };
|
||||
}
|
||||
|
||||
export function buildAuthorizeUrl({ redirectUri, state, challenge }) {
|
||||
const url = new URL(`${config.shooBaseUrl}/authorize`);
|
||||
url.searchParams.set("redirect_uri", redirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("code_challenge", challenge);
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
// Request profile claims (email/email_verified/name/picture/pii_sub). shoo
|
||||
// shows a one-time consent screen per user; declining it denies the sign-in.
|
||||
url.searchParams.set("pii", "true");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export async function exchangeCode({ code, verifier, redirectUri }) {
|
||||
const response = await fetch(`${config.shooBaseUrl}/token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
redirect_uri: redirectUri,
|
||||
code,
|
||||
code_verifier: verifier
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000)
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(`shoo token exchange failed: ${body.error || response.status}`);
|
||||
}
|
||||
if (typeof body.id_token !== "string") {
|
||||
throw new Error("shoo token exchange returned no id_token.");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// Verifies the ES256 id_token against shoo's JWKS and returns its claims.
|
||||
// audOrigin must be this deployment's public origin (e.g. https://postplan.dev).
|
||||
export async function verifyIdToken(idToken, { audOrigin }) {
|
||||
const audience = `origin:${new URL(audOrigin).origin}`;
|
||||
const { payload } = await jwtVerify(idToken, getJwks(), {
|
||||
issuer: await getIssuer(),
|
||||
audience,
|
||||
algorithms: ["ES256"]
|
||||
});
|
||||
if (typeof payload.pairwise_sub !== "string" || !payload.pairwise_sub) {
|
||||
throw new Error("shoo id_token is missing pairwise_sub.");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getJwks() {
|
||||
jwksCache ||= createRemoteJWKSet(
|
||||
new URL(`${config.shooBaseUrl}/.well-known/jwks.json`)
|
||||
);
|
||||
return jwksCache;
|
||||
}
|
||||
|
||||
// The issuer string is whatever shoo's discovery document says (it may differ
|
||||
// from the base URL), so fetch it once instead of assuming.
|
||||
async function getIssuer() {
|
||||
issuerCache ||= (async () => {
|
||||
const response = await fetch(
|
||||
`${config.shooBaseUrl}/.well-known/openid-configuration`,
|
||||
{ signal: AbortSignal.timeout(10_000) }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`shoo discovery failed: ${response.status}`);
|
||||
}
|
||||
const body = await response.json();
|
||||
if (typeof body.issuer !== "string") {
|
||||
throw new Error("shoo discovery document has no issuer.");
|
||||
}
|
||||
return body.issuer;
|
||||
})().catch((error) => {
|
||||
issuerCache = null;
|
||||
throw error;
|
||||
});
|
||||
return issuerCache;
|
||||
}
|
||||
|
||||
// Test hook: reset module caches (jwks/issuer) between test servers.
|
||||
export function resetShooCaches() {
|
||||
jwksCache = null;
|
||||
issuerCache = null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { config, requireEnv } 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 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"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export async function getHtmlObject(key) {
|
||||
assertStorageConfigured();
|
||||
const result = await getClient().send(
|
||||
new GetObjectCommand({
|
||||
Bucket: config.s3.bucketName,
|
||||
Key: key
|
||||
})
|
||||
);
|
||||
return streamToString(result.Body);
|
||||
}
|
||||
|
||||
async function streamToString(stream) {
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks).toString("utf8");
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { config } from "./config.js";
|
||||
|
||||
export const SESSION_COOKIE = "postplan_session";
|
||||
export const AUTH_STATE_COOKIE = "postplan_auth_state";
|
||||
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
|
||||
const AUTH_STATE_TTL_SECONDS = 10 * 60;
|
||||
|
||||
// Compact HMAC-signed tokens (base64url(JSON payload) + "." + HMAC-SHA256),
|
||||
// the same shape shoo uses for its own sessions. Stateless: nothing to store
|
||||
// or clean up server-side, and a restart invalidates nothing.
|
||||
export function signToken(payload, secret, ttlSeconds) {
|
||||
const body = Buffer.from(
|
||||
JSON.stringify({ ...payload, exp: nowSeconds() + ttlSeconds })
|
||||
).toString("base64url");
|
||||
return `${body}.${hmac(body, secret)}`;
|
||||
}
|
||||
|
||||
export function verifyToken(token, secret) {
|
||||
if (typeof token !== "string" || !token.includes(".")) return null;
|
||||
const [body, signature] = token.split(".");
|
||||
const expected = hmac(body, secret);
|
||||
const a = Buffer.from(signature || "");
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||
if (!Number.isFinite(payload.exp) || payload.exp < nowSeconds()) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionCookie({ accountId, accountName, email, pictureUrl }) {
|
||||
const token = signToken(
|
||||
{ accountId, accountName, email: email ?? null, pictureUrl: pictureUrl ?? null },
|
||||
requireSecret(),
|
||||
SESSION_TTL_SECONDS
|
||||
);
|
||||
return serializeCookie(SESSION_COOKIE, token, { maxAge: SESSION_TTL_SECONDS });
|
||||
}
|
||||
|
||||
export function clearSessionCookie() {
|
||||
return serializeCookie(SESSION_COOKIE, "", { maxAge: 0 });
|
||||
}
|
||||
|
||||
export function createAuthStateCookie(payload) {
|
||||
const token = signToken(payload, requireSecret(), AUTH_STATE_TTL_SECONDS);
|
||||
return serializeCookie(AUTH_STATE_COOKIE, token, { maxAge: AUTH_STATE_TTL_SECONDS });
|
||||
}
|
||||
|
||||
export function clearAuthStateCookie() {
|
||||
return serializeCookie(AUTH_STATE_COOKIE, "", { maxAge: 0 });
|
||||
}
|
||||
|
||||
export function readSession(req) {
|
||||
if (!config.sessionSecret) return null;
|
||||
const token = readCookie(req, SESSION_COOKIE);
|
||||
if (!token) return null;
|
||||
const payload = verifyToken(token, config.sessionSecret);
|
||||
return payload?.accountId ? payload : null;
|
||||
}
|
||||
|
||||
export function readAuthState(req) {
|
||||
if (!config.sessionSecret) return null;
|
||||
const token = readCookie(req, AUTH_STATE_COOKIE);
|
||||
return token ? verifyToken(token, config.sessionSecret) : null;
|
||||
}
|
||||
|
||||
export function readCookie(req, name) {
|
||||
const header = req.get("cookie") || "";
|
||||
for (const part of header.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
if (part.slice(0, eq).trim() === name) {
|
||||
// A malformed value (bad percent-escape) must read as "no cookie", not
|
||||
// throw — otherwise one bad cookie 500s every web page until cleared.
|
||||
try {
|
||||
return decodeURIComponent(part.slice(eq + 1).trim());
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function serializeCookie(name, value, { maxAge }) {
|
||||
const attributes = [
|
||||
`${name}=${encodeURIComponent(value)}`,
|
||||
"Path=/",
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
`Max-Age=${maxAge}`
|
||||
];
|
||||
if (process.env.NODE_ENV !== "development") attributes.push("Secure");
|
||||
return attributes.join("; ");
|
||||
}
|
||||
|
||||
function hmac(value, secret) {
|
||||
return createHmac("sha256", secret).update(value).digest("base64url");
|
||||
}
|
||||
|
||||
function requireSecret() {
|
||||
if (!config.sessionSecret) {
|
||||
throw new Error("POSTPLAN_SESSION_SECRET is not configured.");
|
||||
}
|
||||
return config.sessionSecret;
|
||||
}
|
||||
|
||||
function nowSeconds() {
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
import { config } from "./config.js";
|
||||
import { findOrCreateAccountForIdentity, pool } from "./db.js";
|
||||
import { newInternalId } from "./ids.js";
|
||||
import { clientIp } from "./client-ip.js";
|
||||
import { createRateLimiter } from "./rate-limit.js";
|
||||
import { randomToken, sha256 } from "./crypto.js";
|
||||
import { getAccountDraftWithVersions, listAccountDrafts } from "./drafts.js";
|
||||
import { getDraftIdFromHost, getHomeUrl, getRequestBaseUrl } from "./public-url.js";
|
||||
import { buildAuthorizeUrl, buildPkce, exchangeCode, verifyIdToken } from "./shoo.js";
|
||||
import {
|
||||
clearAuthStateCookie,
|
||||
clearSessionCookie,
|
||||
createAuthStateCookie,
|
||||
createSessionCookie,
|
||||
readAuthState,
|
||||
readSession
|
||||
} from "./web-auth.js";
|
||||
import {
|
||||
renderAuthError,
|
||||
renderCliAuth,
|
||||
renderCliAuthKey,
|
||||
renderDashboard,
|
||||
renderDraftDetail,
|
||||
renderSignIn
|
||||
} from "./render-web.js";
|
||||
|
||||
// Server-rendered web UI: shoo sign-in, the drafts dashboard, and the /cli/auth
|
||||
// key page. Apex-domain only — on draft subdomains these paths fall through to
|
||||
// the 404 handler so a draft origin can never serve dashboard UI.
|
||||
export function registerWebRoutes(app) {
|
||||
const web = [onlyApex, requireConfigured];
|
||||
const keyMintRateLimit = createRateLimiter({
|
||||
windowMs: Number(process.env.KEY_MINT_RATE_LIMIT_WINDOW_MS || 3_600_000),
|
||||
max: Number(process.env.KEY_MINT_RATE_LIMIT_MAX || 10),
|
||||
keyPrefix: "key-mint",
|
||||
key: (req) => readSession(req)?.accountId || clientIp(req) || "anonymous"
|
||||
});
|
||||
|
||||
app.get("/auth/sign-in", ...web, (req, res) => {
|
||||
const { verifier, challenge, state } = buildPkce();
|
||||
const next = safeNextPath(req.query.next);
|
||||
res.append(
|
||||
"Set-Cookie",
|
||||
createAuthStateCookie({ state, verifier, next })
|
||||
);
|
||||
res.redirect(buildAuthorizeUrl({ redirectUri: callbackUrl(), state, challenge }));
|
||||
});
|
||||
|
||||
app.get("/auth/callback", ...web, async (req, res, next) => {
|
||||
try {
|
||||
res.append("Set-Cookie", clearAuthStateCookie());
|
||||
|
||||
// The only error shoo redirects back is user consent denial.
|
||||
if (req.query.error === "access_denied") {
|
||||
return res
|
||||
.status(403)
|
||||
.type("html")
|
||||
.send(
|
||||
renderAuthError({
|
||||
message:
|
||||
"Sign-in was cancelled or consent was declined. Postplan uses your email and profile picture to identify your account — retry and approve to continue."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const authState = readAuthState(req);
|
||||
const { code, state } = req.query;
|
||||
if (!authState || typeof state !== "string" || state !== authState.state) {
|
||||
return res
|
||||
.status(400)
|
||||
.type("html")
|
||||
.send(renderAuthError({ message: "Sign-in expired or state mismatch. Please retry." }));
|
||||
}
|
||||
if (typeof code !== "string" || !code) {
|
||||
return res
|
||||
.status(400)
|
||||
.type("html")
|
||||
.send(renderAuthError({ message: "Missing authorization code." }));
|
||||
}
|
||||
|
||||
// Exchange/verification failures are expected OAuth outcomes (expired
|
||||
// or replayed 120s codes, shoo hiccups) — render a retryable page, not
|
||||
// the JSON 500 handler.
|
||||
let claims;
|
||||
try {
|
||||
const tokens = await exchangeCode({
|
||||
code,
|
||||
verifier: authState.verifier,
|
||||
redirectUri: callbackUrl()
|
||||
});
|
||||
claims = await verifyIdToken(tokens.id_token, { audOrigin: webOrigin() });
|
||||
} catch (error) {
|
||||
console.error("shoo sign-in failed:", error.message);
|
||||
return res
|
||||
.status(502)
|
||||
.type("html")
|
||||
.send(renderAuthError({ message: "Sign-in could not be completed. Please retry." }));
|
||||
}
|
||||
|
||||
const account = await findOrCreateAccountForIdentity({
|
||||
provider: "shoo",
|
||||
subject: claims.pairwise_sub,
|
||||
// Profile claims are present only with pii consent, and each is
|
||||
// individually optional (depends on the Google profile). Blank or
|
||||
// whitespace-only strings mean "absent", never a stored value.
|
||||
profile: {
|
||||
email: claimText(claims.email),
|
||||
emailVerified: typeof claims.email_verified === "boolean" ? claims.email_verified : null,
|
||||
displayName: claimText(claims.name),
|
||||
pictureUrl: claimText(claims.picture),
|
||||
piiSubject: claimText(claims.pii_sub)
|
||||
}
|
||||
});
|
||||
|
||||
res.append("Set-Cookie", createSessionCookie(account));
|
||||
res.redirect(safeNextPath(authState.next));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/auth/sign-out", onlyApex, (req, res) => {
|
||||
res.append("Set-Cookie", clearSessionCookie());
|
||||
res.redirect("/");
|
||||
});
|
||||
|
||||
app.get("/dashboard", ...web, async (req, res, next) => {
|
||||
try {
|
||||
const session = readSession(req);
|
||||
if (!session) {
|
||||
return res.type("html").send(renderSignIn({ next: "/dashboard" }));
|
||||
}
|
||||
const drafts = await listAccountDrafts(session.accountId, {
|
||||
requestBaseUrl: getRequestBaseUrl(req)
|
||||
});
|
||||
res.type("html").send(renderDashboard({ session, drafts }));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/dashboard/drafts/:draftId", ...web, async (req, res, next) => {
|
||||
try {
|
||||
const session = readSession(req);
|
||||
if (!session) {
|
||||
return res.type("html").send(renderSignIn({ next: "/dashboard" }));
|
||||
}
|
||||
const result = await getAccountDraftWithVersions(session.accountId, req.params.draftId, {
|
||||
requestBaseUrl: getRequestBaseUrl(req)
|
||||
});
|
||||
if (!result) return next();
|
||||
res.type("html").send(
|
||||
renderDraftDetail({
|
||||
session,
|
||||
draft: result.draft,
|
||||
versions: result.versions
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/cli/auth", ...web, async (req, res, next) => {
|
||||
try {
|
||||
const session = readSession(req);
|
||||
if (!session) {
|
||||
return res.type("html").send(renderSignIn({ next: "/cli/auth" }));
|
||||
}
|
||||
res.type("html").send(
|
||||
renderCliAuth({
|
||||
session,
|
||||
keys: await listAccountApiKeys(session.accountId)
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Mints a fresh named key for the signed-in account and shows it once.
|
||||
// POST + SameSite=Lax session cookie keeps cross-site requests out.
|
||||
app.post("/cli/auth/keys", ...web, keyMintRateLimit, async (req, res, next) => {
|
||||
try {
|
||||
const session = readSession(req);
|
||||
if (!session) {
|
||||
return res.type("html").send(renderSignIn({ next: "/cli/auth" }));
|
||||
}
|
||||
|
||||
const token = `pp_${randomToken(32)}`;
|
||||
const keyName = `CLI · ${new Date().toISOString().slice(0, 10)}`;
|
||||
await pool.query(
|
||||
"INSERT INTO api_keys (id, account_id, name, key_hash) VALUES ($1, $2, $3, $4)",
|
||||
[newInternalId(), session.accountId, keyName, sha256(token)]
|
||||
);
|
||||
|
||||
res.type("html").send(
|
||||
renderCliAuthKey({ session, token, keyName })
|
||||
);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/cli/auth/keys/:apiKeyId/revoke", ...web, async (req, res, next) => {
|
||||
try {
|
||||
const session = readSession(req);
|
||||
if (!session) {
|
||||
return res.type("html").send(renderSignIn({ next: "/cli/auth" }));
|
||||
}
|
||||
await pool.query(
|
||||
`
|
||||
UPDATE api_keys
|
||||
SET revoked_at = now()
|
||||
WHERE id = $1 AND account_id = $2 AND revoked_at IS NULL
|
||||
`,
|
||||
[req.params.apiKeyId, session.accountId]
|
||||
);
|
||||
res.redirect("/cli/auth");
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listAccountApiKeys(accountId) {
|
||||
const result = await pool.query(
|
||||
`
|
||||
SELECT id, name, created_at, last_used_at
|
||||
FROM api_keys
|
||||
WHERE account_id = $1 AND revoked_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`,
|
||||
[accountId]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
// Web sign-in needs a session secret and a configured public base URL (the
|
||||
// shoo redirect_uri must be a stable, exact string — never request-derived).
|
||||
function requireConfigured(req, res, next) {
|
||||
if (!config.sessionSecret || !config.publicBaseUrl) {
|
||||
return res
|
||||
.status(503)
|
||||
.type("html")
|
||||
.send(
|
||||
renderAuthError({
|
||||
message:
|
||||
"Web sign-in is not configured on this deployment (POSTPLAN_SESSION_SECRET / POSTPLAN_PUBLIC_BASE_URL)."
|
||||
})
|
||||
);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
function onlyApex(req, res, next) {
|
||||
const draftId = getDraftIdFromHost({
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
host: req.hostname || req.get("host")
|
||||
});
|
||||
if (draftId) return next("route");
|
||||
next();
|
||||
}
|
||||
|
||||
function webOrigin() {
|
||||
return getHomeUrl({ publicBaseUrl: config.publicBaseUrl, requestBaseUrl: "" });
|
||||
}
|
||||
|
||||
function callbackUrl() {
|
||||
return `${webOrigin()}/auth/callback`;
|
||||
}
|
||||
|
||||
function claimText(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
// Only allow same-site relative paths as post-login destinations, so the
|
||||
// `next` param can never become an open redirect.
|
||||
function safeNextPath(value) {
|
||||
if (typeof value !== "string") return "/dashboard";
|
||||
if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) {
|
||||
return "/dashboard";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
Reference in New Issue
Block a user