chore(18-03): apply 18-01 schema foundation as dependency baseline
This commit is contained in:
@@ -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" }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
|
||||
83
src/server/services/storage.service.ts
Normal file
83
src/server/services/storage.service.ts
Normal 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user