chore(18-03): apply 18-01 schema foundation as dependency baseline

This commit is contained in:
2026-04-05 13:04:09 +02:00
parent f7c9f3dc94
commit 89b0496845
20 changed files with 3022 additions and 473 deletions

View File

@@ -1,6 +1,11 @@
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import {
oidcAuthMiddleware,
processOAuthCallback,
revokeSession,
} from "@hono/oidc-auth";
import { db as prodDb } from "../db/index.ts";
import { seedDefaults } from "../db/seed.ts";
import { mcpRoutes } from "./mcp/index.ts";
@@ -16,7 +21,7 @@ import { threadRoutes } from "./routes/threads.ts";
import { totalRoutes } from "./routes/totals.ts";
// Seed default data on startup
seedDefaults();
await seedDefaults();
const app = new Hono();
@@ -35,6 +40,14 @@ app.get("/api/health", (c) => {
return c.json({ status: "ok" });
});
// ── OIDC Browser Auth (top-level, before /api/* middleware) ───────────
app.get("/login", oidcAuthMiddleware(), async (c) => c.redirect("/"));
app.get("/callback", async (c) => processOAuthCallback(c));
app.get("/logout", async (c) => {
await revokeSession(c);
return c.redirect("/login");
});
// CORS for OAuth and MCP endpoints (required for claude.ai browser-based flows)
app.use("/.well-known/*", cors());
app.use("/oauth/*", cors());
@@ -54,13 +67,13 @@ app.use("/api/*", async (c, next) => {
return next();
});
// Auth middleware for write operations (POST/PUT/PATCH/DELETE) on non-auth routes
// Auth middleware for all data routes (userId must be available for per-user scoping)
app.use("/api/*", async (c, next) => {
// Skip auth routes — they handle their own auth
if (c.req.path.startsWith("/api/auth")) return next();
// Skip GET requests — read is public
if (c.req.method === "GET") return next();
// All other methods require auth
// Skip health check
if (c.req.path === "/api/health") return next();
// All methods require auth for userId resolution
return requireAuth(c, next);
});
@@ -79,9 +92,6 @@ if (process.env.GEARBOX_MCP !== "false") {
app.route("/mcp", mcpRoutes);
}
// Serve uploaded images
app.use("/uploads/*", serveStatic({ root: "./" }));
// Serve Vite-built SPA in production
if (process.env.NODE_ENV === "production") {
app.use("/*", serveStatic({ root: "./dist/client" }));

View File

@@ -1,37 +1,46 @@
import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
import {
getSession,
getUserCount,
refreshSession,
verifyApiKey,
} from "../services/auth.service";
import { getOrCreateUser, verifyApiKey } from "../services/auth.service";
import { getOrCreateUncategorized } from "../services/category.service";
import { verifyAccessToken } from "../services/oauth.service";
export async function requireAuth(c: Context, next: Next) {
const db = c.get("db");
// Check if any users exist at all
if (getUserCount(db) === 0) {
return c.json({ error: "setup_required" }, 403);
}
// Check API key first
const apiKey = c.req.header("X-API-Key");
if (apiKey) {
const valid = await verifyApiKey(db, apiKey);
if (valid) return next();
const result = await verifyApiKey(db, apiKey);
if (result) {
c.set("userId", result.userId);
return next();
}
return c.json({ error: "Invalid API key" }, 401);
}
// Check session cookie
const sessionId = getCookie(c, "gearbox_session");
if (sessionId) {
const session = getSession(db, sessionId);
if (session) {
// Refresh session expiry on use
refreshSession(db, sessionId);
// Check OAuth Bearer token
const authHeader = c.req.header("Authorization");
if (authHeader?.startsWith("Bearer ")) {
const token = authHeader.slice(7);
const result = await verifyAccessToken(db, token);
if (result) {
c.set("userId", result.userId);
return next();
}
return c.json({ error: "Invalid or expired token" }, 401);
}
// Check OIDC session (browser users via Logto)
try {
const { getAuth } = await import("@hono/oidc-auth");
const auth = await getAuth(c);
if (auth?.sub) {
const user = await getOrCreateUser(db, auth.sub);
await getOrCreateUncategorized(db, user.id);
c.set("userId", user.id);
return next();
}
} catch {
// OIDC not configured or session invalid — fall through
}
return c.json({ error: "Authentication required" }, 401);

View File

@@ -1,167 +1,40 @@
import { zValidator } from "@hono/zod-validator";
import { eq } from "drizzle-orm";
import { getAuth } from "@hono/oidc-auth";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { z } from "zod";
import { users } from "../../db/schema.ts";
import { parseId } from "../lib/params.ts";
import { requireAuth } from "../middleware/auth.ts";
import { rateLimit } from "../middleware/rateLimit.ts";
import {
changePassword,
createApiKey,
createSession,
createUser,
deleteApiKey,
deleteSession,
getSession,
getUserCount,
listApiKeys,
verifyPassword,
} from "../services/auth.service.ts";
type Env = { Variables: { db?: any } };
type Env = { Variables: { db?: any; userId?: number } };
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
const setupSchema = z.object({
username: z.string().min(1),
password: z.string().min(6),
});
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6),
});
const createKeySchema = z.object({ name: z.string().min(1) });
const COOKIE_NAME = "gearbox_session";
const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 days in seconds
const app = new Hono<Env>();
// ── Public routes ───────────────────────────────────────────────────
// ── Auth Status ──────────────────────────────────────────────────────
app.get("/me", (c) => {
const db = c.get("db");
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
const session = getSession(db, sessionId);
if (session) {
return c.json({
user: { id: session.userId },
setupRequired: false,
});
}
app.get("/me", async (c) => {
const auth = await getAuth(c);
if (auth) {
return c.json({
user: { id: auth.sub, email: auth.email },
authenticated: true,
});
}
const setupRequired = getUserCount(db) === 0;
return c.json({ user: null, setupRequired });
return c.json({ user: null, authenticated: false });
});
app.post("/setup", rateLimit, zValidator("json", setupSchema), async (c) => {
// ── API Key Management (protected) ───────────────────────────────────
app.get("/keys", requireAuth, async (c) => {
const db = c.get("db");
if (getUserCount(db) > 0) {
return c.json({ error: "Setup already completed" }, 403);
}
const { username, password } = c.req.valid("json");
const user = await createUser(db, username, password);
const session = createSession(db, user.id);
setCookie(c, COOKIE_NAME, session.id, {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: COOKIE_MAX_AGE,
});
return c.json({ username: user.username }, 201);
});
app.post("/login", rateLimit, zValidator("json", loginSchema), async (c) => {
const db = c.get("db");
const { username, password } = c.req.valid("json");
const user = await verifyPassword(db, username, password);
if (!user) {
return c.json({ error: "Invalid credentials" }, 401);
}
const session = createSession(db, user.id);
setCookie(c, COOKIE_NAME, session.id, {
httpOnly: true,
sameSite: "Lax",
path: "/",
maxAge: COOKIE_MAX_AGE,
});
return c.json({ username: user.username });
});
app.post("/logout", (c) => {
const db = c.get("db");
const sessionId = getCookie(c, COOKIE_NAME);
if (sessionId) {
deleteSession(db, sessionId);
}
deleteCookie(c, COOKIE_NAME, { path: "/" });
return c.json({ ok: true });
});
// ── Protected routes ────────────────────────────────────────────────
app.put(
"/password",
requireAuth,
zValidator("json", changePasswordSchema),
async (c) => {
const db = c.get("db");
const sessionId = getCookie(c, COOKIE_NAME);
if (!sessionId) {
return c.json({ error: "Session required for password change" }, 401);
}
const session = getSession(db, sessionId);
if (!session) {
return c.json({ error: "Session required for password change" }, 401);
}
const userRecord = db
.select()
.from(users)
.where(eq(users.id, session.userId))
.get();
if (!userRecord) {
return c.json({ error: "User not found" }, 404);
}
const { currentPassword, newPassword } = c.req.valid("json");
const changed = await changePassword(
db,
userRecord.username,
currentPassword,
newPassword,
);
if (!changed) {
return c.json({ error: "Invalid current password" }, 401);
}
return c.json({ ok: true });
},
);
app.get("/keys", requireAuth, (c) => {
const db = c.get("db");
const keys = listApiKeys(db);
const userId = c.get("userId")!;
const keys = await listApiKeys(db, userId);
return c.json(keys);
});
@@ -171,8 +44,9 @@ app.post(
zValidator("json", createKeySchema),
async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const { name } = c.req.valid("json");
const result = await createApiKey(db, name);
const result = await createApiKey(db, userId, name);
return c.json(
{
@@ -186,11 +60,12 @@ app.post(
},
);
app.delete("/keys/:id", requireAuth, (c) => {
app.delete("/keys/:id", requireAuth, async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid key ID" }, 400);
deleteApiKey(db, id);
await deleteApiKey(db, userId, id);
return c.json({ ok: true });
});

View File

@@ -7,6 +7,7 @@ import {
updateSetupSchema,
} from "../../shared/schemas.ts";
import { parseId } from "../lib/params.ts";
import { withImageUrls } from "../services/storage.service.ts";
import {
createSetup,
deleteSetup,
@@ -18,88 +19,97 @@ import {
updateSetup,
} from "../services/setup.service.ts";
type Env = { Variables: { db?: any } };
type Env = { Variables: { db?: any; userId?: number } };
const app = new Hono<Env>();
// Setup CRUD
app.get("/", (c) => {
app.get("/", async (c) => {
const db = c.get("db");
const setups = getAllSetups(db);
const userId = c.get("userId")!;
const setups = await getAllSetups(db, userId);
return c.json(setups);
});
app.post("/", zValidator("json", createSetupSchema), (c) => {
app.post("/", zValidator("json", createSetupSchema), async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const data = c.req.valid("json");
const setup = createSetup(db, data);
const setup = await createSetup(db, userId, data);
return c.json(setup, 201);
});
app.get("/:id", (c) => {
app.get("/:id", async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const setup = getSetupWithItems(db, id);
const setup = await getSetupWithItems(db, userId, id);
if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup);
const enrichedItems = await withImageUrls(setup.items);
return c.json({ ...setup, items: enrichedItems });
});
app.put("/:id", zValidator("json", updateSetupSchema), (c) => {
app.put("/:id", zValidator("json", updateSetupSchema), async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const data = c.req.valid("json");
const setup = updateSetup(db, id, data);
const setup = await updateSetup(db, userId, id, data);
if (!setup) return c.json({ error: "Setup not found" }, 404);
return c.json(setup);
});
app.delete("/:id", (c) => {
app.delete("/:id", async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const deleted = deleteSetup(db, id);
const deleted = await deleteSetup(db, userId, id);
if (!deleted) return c.json({ error: "Setup not found" }, 404);
return c.json({ success: true });
});
// Setup Items
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => {
app.put("/:id/items", zValidator("json", syncSetupItemsSchema), async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const id = parseId(c.req.param("id"));
if (!id) return c.json({ error: "Invalid setup ID" }, 400);
const { itemIds } = c.req.valid("json");
const setup = getSetupWithItems(db, id);
const setup = await getSetupWithItems(db, userId, id);
if (!setup) return c.json({ error: "Setup not found" }, 404);
syncSetupItems(db, id, itemIds);
await syncSetupItems(db, userId, id, itemIds);
return c.json({ success: true });
});
app.patch(
"/:id/items/:itemId/classification",
zValidator("json", updateClassificationSchema),
(c) => {
async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const setupId = parseId(c.req.param("id"));
const itemId = parseId(c.req.param("itemId"));
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
const { classification } = c.req.valid("json");
updateItemClassification(db, setupId, itemId, classification);
await updateItemClassification(db, userId, setupId, itemId, classification);
return c.json({ success: true });
},
);
app.delete("/:id/items/:itemId", (c) => {
app.delete("/:id/items/:itemId", async (c) => {
const db = c.get("db");
const userId = c.get("userId")!;
const setupId = parseId(c.req.param("id"));
const itemId = parseId(c.req.param("itemId"));
if (!setupId || !itemId) return c.json({ error: "Invalid ID" }, 400);
removeSetupItem(db, setupId, itemId);
await removeSetupItem(db, userId, setupId, itemId);
return c.json({ success: true });
});

View File

@@ -1,147 +1,65 @@
import { randomBytes } from "node:crypto";
import { count, eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { apiKeys, sessions, users } from "../../db/schema.ts";
import { apiKeys, users } from "../../db/schema.ts";
type Db = typeof prodDb;
// ── User Management ──────────────────────────────────────────────────
export async function createUser(
db: Db = prodDb,
username: string,
password: string,
) {
const passwordHash = await Bun.password.hash(password);
return db.insert(users).values({ username, passwordHash }).returning().get();
}
export async function verifyPassword(
db: Db = prodDb,
username: string,
password: string,
) {
const user = db
.select()
.from(users)
.where(eq(users.username, username))
.get();
if (!user) return null;
const valid = await Bun.password.verify(password, user.passwordHash);
return valid ? user : null;
}
export function getUserCount(db: Db = prodDb): number {
const result = db.select({ value: count() }).from(users).get();
return result?.value ?? 0;
}
export async function changePassword(
db: Db = prodDb,
username: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const user = await verifyPassword(db, username, currentPassword);
if (!user) return false;
const newHash = await Bun.password.hash(newPassword);
db.update(users)
.set({ passwordHash: newHash })
.where(eq(users.id, user.id))
.run();
return true;
}
// ── Session Management ───────────────────────────────────────────────
export function createSession(
db: Db = prodDb,
userId: number,
expiryDays = 30,
) {
const id = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
return db
.insert(sessions)
.values({ id, userId, expiresAt })
.returning()
.get();
}
export function getSession(db: Db = prodDb, sessionId: string) {
const session = db
.select()
.from(sessions)
.where(eq(sessions.id, sessionId))
.get();
if (!session) return null;
if (session.expiresAt < new Date()) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
return null;
}
return session;
}
export function deleteSession(db: Db = prodDb, sessionId: string) {
db.delete(sessions).where(eq(sessions.id, sessionId)).run();
}
export function refreshSession(
db: Db = prodDb,
sessionId: string,
expiryDays = 30,
) {
const expiresAt = new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
db.update(sessions)
.set({ expiresAt })
.where(eq(sessions.id, sessionId))
.run();
export async function getOrCreateUser(
db: Db,
logtoSub: string,
): Promise<{ id: number }> {
const [user] = await db
.insert(users)
.values({ logtoSub })
.onConflictDoUpdate({
target: users.logtoSub,
set: { logtoSub },
})
.returning({ id: users.id });
return user;
}
// ── API Key Management ───────────────────────────────────────────────
export async function createApiKey(db: Db = prodDb, name: string) {
export async function createApiKey(
db: Db,
userId: number,
name: string,
) {
const rawKey = randomBytes(32).toString("hex");
const keyHash = await Bun.password.hash(rawKey);
const keyPrefix = rawKey.slice(0, 8);
const record = db
const [record] = await db
.insert(apiKeys)
.values({ name, keyHash, keyPrefix })
.returning()
.get();
.values({ name, keyHash, keyPrefix, userId })
.returning();
return { ...record, rawKey };
}
export async function verifyApiKey(
db: Db = prodDb,
db: Db,
rawKey: string,
): Promise<boolean> {
): Promise<{ userId: number } | null> {
const prefix = rawKey.slice(0, 8);
const candidates = db
const candidates = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.keyPrefix, prefix))
.all();
.where(eq(apiKeys.keyPrefix, prefix));
for (const candidate of candidates) {
const valid = await Bun.password.verify(rawKey, candidate.keyHash);
if (valid) return true;
if (valid) return { userId: candidate.userId };
}
return false;
return null;
}
export function listApiKeys(db: Db = prodDb) {
export async function listApiKeys(db: Db, userId: number) {
return db
.select({
id: apiKeys.id,
@@ -150,9 +68,11 @@ export function listApiKeys(db: Db = prodDb) {
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.all();
.where(eq(apiKeys.userId, userId));
}
export function deleteApiKey(db: Db = prodDb, id: number) {
db.delete(apiKeys).where(eq(apiKeys.id, id)).run();
export async function deleteApiKey(db: Db, userId: number, id: number) {
await db
.delete(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, userId)));
}

View File

@@ -1,15 +1,24 @@
import { eq, sql } from "drizzle-orm";
import { and, eq, inArray, sql } from "drizzle-orm";
import { db as prodDb } from "../../db/index.ts";
import { categories, items, setupItems, setups } from "../../db/schema.ts";
import type { CreateSetup, UpdateSetup } from "../../shared/types.ts";
type Db = typeof prodDb;
export function createSetup(db: Db = prodDb, data: CreateSetup) {
return db.insert(setups).values({ name: data.name }).returning().get();
export async function createSetup(
db: Db,
userId: number,
data: CreateSetup,
) {
const [row] = await db
.insert(setups)
.values({ name: data.name, userId })
.returning();
return row;
}
export function getAllSetups(db: Db = prodDb) {
export async function getAllSetups(db: Db, userId: number) {
return db
.select({
id: setups.id,
@@ -32,14 +41,21 @@ export function getAllSetups(db: Db = prodDb) {
), 0)`.as("total_cost"),
})
.from(setups)
.all();
.where(eq(setups.userId, userId));
}
export function getSetupWithItems(db: Db = prodDb, setupId: number) {
const setup = db.select().from(setups).where(eq(setups.id, setupId)).get();
export async function getSetupWithItems(
db: Db,
userId: number,
setupId: number,
) {
const [setup] = await db
.select()
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
const itemList = db
const itemList = await db
.select({
id: items.id,
name: items.name,
@@ -59,59 +75,82 @@ export function getSetupWithItems(db: Db = prodDb, setupId: number) {
.from(setupItems)
.innerJoin(items, eq(setupItems.itemId, items.id))
.innerJoin(categories, eq(items.categoryId, categories.id))
.where(eq(setupItems.setupId, setupId))
.all();
.where(eq(setupItems.setupId, setupId));
return { ...setup, items: itemList };
}
export function updateSetup(
db: Db = prodDb,
export async function updateSetup(
db: Db,
userId: number,
setupId: number,
data: UpdateSetup,
) {
const existing = db
const [existing] = await db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!existing) return null;
return db
const [row] = await db
.update(setups)
.set({ name: data.name, updatedAt: new Date() })
.where(eq(setups.id, setupId))
.returning()
.get();
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)))
.returning();
return row;
}
export function deleteSetup(db: Db = prodDb, setupId: number) {
const existing = db
export async function deleteSetup(
db: Db,
userId: number,
setupId: number,
) {
const [existing] = await db
.select({ id: setups.id })
.from(setups)
.where(eq(setups.id, setupId))
.get();
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!existing) return false;
db.delete(setups).where(eq(setups.id, setupId)).run();
await db
.delete(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
return true;
}
export function syncSetupItems(
db: Db = prodDb,
export async function syncSetupItems(
db: Db,
userId: number,
setupId: number,
itemIds: number[],
) {
return db.transaction((tx) => {
return await db.transaction(async (tx) => {
// Verify the setup belongs to this user
const [setup] = await tx
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
// Verify all itemIds belong to this user
const validItems =
itemIds.length > 0
? await tx
.select({ id: items.id })
.from(items)
.where(and(eq(items.userId, userId), inArray(items.id, itemIds)))
: [];
const validItemIds = new Set(validItems.map((i) => i.id));
const filteredItemIds = itemIds.filter((id) => validItemIds.has(id));
// Save existing classifications before deleting
const existing = tx
const existing = await tx
.select({
itemId: setupItems.itemId,
classification: setupItems.classification,
})
.from(setupItems)
.where(eq(setupItems.setupId, setupId))
.all();
.where(eq(setupItems.setupId, setupId));
const classificationMap = new Map<number, string>();
for (const row of existing) {
@@ -119,43 +158,57 @@ export function syncSetupItems(
}
// Delete all existing items for this setup
tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();
await tx.delete(setupItems).where(eq(setupItems.setupId, setupId));
// Re-insert new items, preserving classifications for retained items
for (const itemId of itemIds) {
tx.insert(setupItems)
.values({
setupId,
itemId,
classification: classificationMap.get(itemId) ?? "base",
})
.run();
// Re-insert only user-owned items, preserving classifications
for (const itemId of filteredItemIds) {
await tx.insert(setupItems).values({
setupId,
itemId,
classification: classificationMap.get(itemId) ?? "base",
});
}
});
}
export function updateItemClassification(
db: Db = prodDb,
export async function updateItemClassification(
db: Db,
userId: number,
setupId: number,
itemId: number,
classification: string,
) {
db.update(setupItems)
// Verify setup belongs to user
const [setup] = await db
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
await db
.update(setupItems)
.set({ classification })
.where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
)
.run();
and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)),
);
}
export function removeSetupItem(
db: Db = prodDb,
export async function removeSetupItem(
db: Db,
userId: number,
setupId: number,
itemId: number,
) {
db.delete(setupItems)
// Verify setup belongs to user
const [setup] = await db
.select({ id: setups.id })
.from(setups)
.where(and(eq(setups.id, setupId), eq(setups.userId, userId)));
if (!setup) return null;
await db
.delete(setupItems)
.where(
sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
)
.run();
and(eq(setupItems.setupId, setupId), eq(setupItems.itemId, itemId)),
);
}

View File

@@ -0,0 +1,83 @@
import {
DeleteObjectCommand,
GetObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
// MinIO GitHub repository was archived Feb 2026. The S3 API abstraction
// makes the underlying provider swappable (SeaweedFS, Garage, AWS S3, etc.)
// without code changes.
const s3 = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION ?? "us-east-1",
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY!,
secretAccessKey: process.env.S3_SECRET_KEY!,
},
forcePathStyle: true, // REQUIRED for MinIO and most S3-compatible services
});
const bucket = process.env.S3_BUCKET ?? "gearbox-images";
const presignExpiry = Number.parseInt(
process.env.S3_PRESIGN_EXPIRY ?? "3600",
10,
);
export async function uploadImage(
buffer: Buffer | ArrayBuffer,
filename: string,
contentType: string,
): Promise<void> {
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: filename,
Body: Buffer.from(buffer),
ContentType: contentType,
}),
);
}
export async function deleteImage(filename: string): Promise<void> {
await s3.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: filename,
}),
);
}
export async function getImageUrl(filename: string): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: filename,
});
return getSignedUrl(s3, command, { expiresIn: presignExpiry });
}
/**
* Enrich a record that has an imageFilename with a presigned imageUrl.
* Returns null imageUrl when imageFilename is null.
*/
export async function withImageUrl<
T extends { imageFilename: string | null },
>(record: T): Promise<T & { imageUrl: string | null }> {
return {
...record,
imageUrl: record.imageFilename
? await getImageUrl(record.imageFilename)
: null,
};
}
/**
* Batch version of withImageUrl. Uses Promise.all for parallelism.
*/
export async function withImageUrls<
T extends { imageFilename: string | null },
>(records: T[]): Promise<(T & { imageUrl: string | null })[]> {
return Promise.all(records.map((record) => withImageUrl(record)));
}