1
26
27import { readFileSync, existsSync } from "node:fs";
28import { dirname, join } from "node:path";
29import { fileURLToPath } from "node:url";
30
31import { signS3Request, type S3Config } from "@factory/storage";
32
33
34
35const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
36
37
38function loadDotEnv(): Record<string, string> {
39 const out: Record<string, string> = {};
40 const path = join(REPO_ROOT, ".env");
41 if (!existsSync(path)) return out;
42 for (const line of readFileSync(path, "utf8").split("\n")) {
43 const t = line.trim();
44 if (t === "" || t.startsWith("#")) continue;
45 const eq = t.indexOf("=");
46 if (eq <= 0) continue;
47 out[t.slice(0, eq).trim()] = t.slice(eq + 1).trim();
48 }
49 return out;
50}
51
52const fileEnv = loadDotEnv();
53const env = (key: string, fallback: string): string =>
54 process.env[key] ?? fileEnv[key] ?? fallback;
55
56const config: S3Config = {
57 endpoint: env("S3_ENDPOINT", "http://localhost:9000").replace(/\/+$/, ""),
58 region: env("S3_REGION", "us-east-1"),
59 bucket: "",
60 accessKeyId: env("S3_ACCESS_KEY", "minioadmin"),
61 secretAccessKey: env("S3_SECRET_KEY", "minioadmin"),
62 forcePathStyle: true,
63};
64const FILES_BUCKET = env("S3_BUCKET_FILES", "factory-files");
65const THUMBS_BUCKET = env("S3_BUCKET_THUMBS", "factory-thumbs");
66
67
68
69const EMPTY_SHA =
70 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
71
72function sign(bucket: string, method: string, path: string): Record<string, string> {
73 const signed = signS3Request({
74 method,
75 path,
76 query: {},
77 headers: {},
78 payloadHash: EMPTY_SHA,
79 config: { ...config, bucket },
80 now: new Date(),
81 alwaysSignContentSha: true,
82 });
83 return signed.headers;
84}
85
86async function rawReq(
87 method: string,
88 path: string,
89 bucket: string,
90 signedHeaders?: Record<string, string>,
91): Promise<{ status: number; body: string }> {
92 const headers = signedHeaders ?? sign(bucket, method, path);
93 const res = await fetch(`${config.endpoint}${path}`, { method, headers });
94 const text = await res.text();
95 return { status: res.status, body: text };
96}
97
98
99
100async function bucketState(bucket: string): Promise<"exists" | "missing" | "error"> {
101
102
103
104
105
106 const r = await rawReq("GET", "/", bucket);
107 if (r.status !== 200) {
108 console.error(` ! unexpected ListAllMyBuckets status ${r.status}: ${r.body.slice(0, 200)}`);
109 return "error";
110 }
111 const exists = new RegExp(`<Name>${bucket}</Name>`).test(r.body);
112 return exists ? "exists" : "missing";
113}
114
115async function ensureBucket(bucket: string): Promise<void> {
116 const state = await bucketState(bucket);
117 if (state === "exists") {
118 console.log(` ✓ ${bucket} exists`);
119 } else if (state === "missing") {
120 const r = await rawReq("PUT", `/${bucket}`, bucket);
121 if (r.status === 200) {
122 console.log(` + ${bucket} created`);
123 } else {
124 throw new Error(`PUT /${bucket} failed: ${r.status} ${r.body.slice(0, 300)}`);
125 }
126 } else {
127 throw new Error(`could not determine state of ${bucket}`);
128 }
129
130
131 const anon = await fetch(`${config.endpoint}/${bucket}`, { method: "GET" });
132 if (anon.status !== 403) {
133 throw new Error(
134 `bucket ${bucket} is NOT private: anonymous GET returned ${anon.status} (expected 403)`,
135 );
136 }
137 console.log(` ✓ ${bucket} verified private (anonymous GET → 403)`);
138}
139
140
141
142async function main(): Promise<void> {
143 console.log(`minio-init: endpoint ${config.endpoint} (region ${config.region})`);
144 for (const bucket of [FILES_BUCKET, THUMBS_BUCKET]) {
145 await ensureBucket(bucket);
146 }
147 console.log(`minio-init: buckets ${FILES_BUCKET} + ${THUMBS_BUCKET} ready (private)`);
148}
149
150main().catch((err) => {
151 console.error(`minio-init failed: ${(err as Error).message}`);
152 process.exit(1);
153});
154