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:
+4
-9
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user