Replace S3 storage with Docker-mounted filesystem

- Add Docker and Compose deployment with persistent HTML and Postgres volumes
- Remove AWS storage configuration and dependency
- Document local Docker setup and filesystem storage
This commit is contained in:
2026-08-22 00:39:53 -04:00 Verified
parent 75719cefc0
commit 8ac7db77fc
9 changed files with 136 additions and 79 deletions
+4 -9
View File
@@ -4,18 +4,13 @@ export const config = {
bootstrapApiKey: process.env.POSTPLAN_BOOTSTRAP_API_KEY,
publicBaseUrl: process.env.POSTPLAN_PUBLIC_BASE_URL,
maxHtmlBytes: Number(process.env.MAX_HTML_BYTES || 512 * 1024),
// Directory for uploaded HTML. In Docker this is /data/html and should be a
// mounted volume so drafts survive container recreation.
htmlDir: process.env.POSTPLAN_HTML_DIR || "./data/html",
// Web sign-in (dashboard). Absent POSTPLAN_SESSION_SECRET, all web-auth
// routes respond 503 and the API/serving paths are unaffected.
sessionSecret: process.env.POSTPLAN_SESSION_SECRET,
shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, ""),
s3: {
endpoint: process.env.AWS_ENDPOINT_URL || process.env.S3_ENDPOINT,
accessKeyId: process.env.AWS_ACCESS_KEY_ID || process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || process.env.S3_SECRET_ACCESS_KEY,
bucketName: process.env.AWS_S3_BUCKET_NAME || process.env.S3_BUCKET_NAME,
region: process.env.AWS_DEFAULT_REGION || process.env.AWS_REGION || "auto",
forcePathStyle: (process.env.AWS_S3_FORCE_PATH_STYLE || "true") !== "false"
}
shooBaseUrl: (process.env.SHOO_BASE_URL || "https://shoo.dev").replace(/\/+$/, "")
};
export function requireEnv(name, value) {
+1 -1
View File
@@ -4,7 +4,7 @@ import { ensureBootstrapApiKey, initDb } from "./db.js";
import { assertStorageConfigured } from "./storage.js";
async function main() {
assertStorageConfigured();
await assertStorageConfigured();
await initDb();
await ensureBootstrapApiKey();
+41 -48
View File
@@ -1,59 +1,52 @@
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { config, requireEnv } from "./config.js";
import fs from "node:fs/promises";
import path from "node:path";
import { config } from "./config.js";
let client;
function getClient() {
if (client) return client;
client = new S3Client({
endpoint: requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint),
region: requireEnv("AWS_DEFAULT_REGION", config.s3.region),
forcePathStyle: config.s3.forcePathStyle,
credentials: {
accessKeyId: requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId),
secretAccessKey: requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey)
}
});
return client;
}
export function assertStorageConfigured() {
requireEnv("AWS_ENDPOINT_URL", config.s3.endpoint);
requireEnv("AWS_ACCESS_KEY_ID", config.s3.accessKeyId);
requireEnv("AWS_SECRET_ACCESS_KEY", config.s3.secretAccessKey);
requireEnv("AWS_S3_BUCKET_NAME", config.s3.bucketName);
export async function assertStorageConfigured() {
const root = htmlRoot();
await fs.mkdir(root, { recursive: true });
await fs.access(root, fs.constants.W_OK);
}
export async function putHtmlObject(key, html) {
assertStorageConfigured();
await getClient().send(
new PutObjectCommand({
Bucket: config.s3.bucketName,
Key: key,
Body: html,
ContentType: "text/html; charset=utf-8",
CacheControl: "no-store"
})
);
const filePath = resolveObjectPath(key);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, html, "utf8");
}
export async function getHtmlObject(key) {
assertStorageConfigured();
const result = await getClient().send(
new GetObjectCommand({
Bucket: config.s3.bucketName,
Key: key
})
);
return streamToString(result.Body);
const filePath = resolveObjectPath(key);
try {
return await fs.readFile(filePath, "utf8");
} catch (error) {
if (error.code === "ENOENT") {
const missing = new Error("HTML object not found.");
missing.statusCode = 404;
throw missing;
}
throw error;
}
}
async function streamToString(stream) {
const chunks = [];
for await (const chunk of stream) {
chunks.push(Buffer.from(chunk));
function htmlRoot() {
return path.resolve(config.htmlDir);
}
function resolveObjectPath(key) {
const root = htmlRoot();
const normalizedKey = String(key || "").replace(/\\/g, "/");
if (!normalizedKey || path.isAbsolute(normalizedKey) || normalizedKey.includes("\0")) {
throw new Error("Invalid object key.");
}
return Buffer.concat(chunks).toString("utf8");
const parts = normalizedKey.split("/").filter((part) => part && part !== ".");
if (parts.length === 0 || parts.some((part) => part === "..")) {
throw new Error("Invalid object key.");
}
const resolved = path.resolve(root, ...parts);
const relative = path.relative(root, resolved);
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error("Invalid object key.");
}
return resolved;
}