From db397a14bcf312b9c5ab445e1a1f8718b3ed8504 Mon Sep 17 00:00:00 2001 From: opencode Date: Sat, 1 Aug 2026 19:11:00 +0200 Subject: [PATCH] fix: security hardening, validation, cache and analytics fixes Backend security: - Admin-gate /admin/redundancy (GET+POST) with zod validation and tool existence checks - Restrict CORS to same-origin (plus CORS_ORIGIN allowlist) and SameSite=Lax cookie - Validate returnTo to prevent open redirect in the OIDC flow - Validate/coerce relations body, reject self-relations and non-admin 'recommended' - Add central JSON error middleware (no more Express HTML 500s) - Fail fast at startup when SESSION_SECRET/VOTER_SECRET missing in production Backend correctness: - Stop leaking voterToken in the create-rating response - Allow clearing websiteUrl/iconUrl (nullable in UpdateToolBody, frontend sends null) - Regenerate session after login/callback (session fixation) and add OIDC state check - Block self-demotion and last-admin demotion in user PATCH - Set created_by to NULL on user delete (FK-safe) - Validate cost create/update bodies with zod - Unique index (tool_id, voter_token) + 409 on race duplicate ratings - Clamp audit limit, escape ilike wildcards in search, O(N) analytics queries Frontend: - tools-browse reads and syncs URL query params (fixes home 'View all' links) - Invalidate analytics/top-tools/categories/features caches after mutations - Sync category combobox input when the value changes externally - Hide Write a Review for anonymous users, drop unreachable rating guard --- artifacts/api-server/src/app.ts | 57 ++++++++- artifacts/api-server/src/routes/admin.ts | 37 +++++- artifacts/api-server/src/routes/analytics.ts | 108 ++++++++---------- artifacts/api-server/src/routes/audit.ts | 3 +- artifacts/api-server/src/routes/auth.ts | 34 +++++- artifacts/api-server/src/routes/costs.ts | 44 ++++++- artifacts/api-server/src/routes/ratings.ts | 41 +++++-- artifacts/api-server/src/routes/tools.ts | 41 ++++++- artifacts/api-server/src/routes/users.ts | 33 +++++- artifacts/api-server/src/types/session.d.ts | 1 + .../src/components/category-combobox.tsx | 7 +- artifacts/toolrate/src/pages/tool-detail.tsx | 23 ++-- artifacts/toolrate/src/pages/tool-edit.tsx | 8 +- artifacts/toolrate/src/pages/tool-new.tsx | 4 +- artifacts/toolrate/src/pages/tools-browse.tsx | 34 +++++- docker-compose.yml | 1 + .../src/generated/api.schemas.ts | 4 +- lib/api-zod/src/generated/api.ts | 4 +- lib/db/src/schema/ratings.ts | 28 +++-- lib/db/src/schema/tool-costs.ts | 2 +- lib/db/src/schema/tool-relations.ts | 2 +- 21 files changed, 378 insertions(+), 138 deletions(-) diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index 4c79678..39adc24 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -1,5 +1,4 @@ import express, { type Express } from "express"; -import cors from "cors"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import pinoHttp from "pino-http"; @@ -11,6 +10,15 @@ import "./types/session.d.ts"; const PgStore = ConnectPgSimple(session); +const isProd = process.env.NODE_ENV === "production"; + +if (isProd && !process.env.SESSION_SECRET) { + throw new Error("SESSION_SECRET must be set in production"); +} +if (isProd && !process.env.VOTER_SECRET) { + throw new Error("VOTER_SECRET must be set in production"); +} + const app: Express = express(); app.set("trust proxy", 1); @@ -35,7 +43,36 @@ app.use( }), ); -app.use(cors({ origin: true, credentials: true })); +const allowedOrigins = (process.env.CORS_ORIGIN ?? "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + +app.use((req, res, next) => { + const origin = req.headers.origin; + const host = req.headers.host; + let sameOrigin = false; + if (origin && host) { + try { + sameOrigin = new URL(origin).host === host; + } catch { + sameOrigin = false; + } + } + const allow = !origin || sameOrigin || allowedOrigins.includes(origin); + if (allow) { + if (origin) res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); + if (req.method === "OPTIONS") { + res.sendStatus(204); + return; + } + } + next(); +}); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -50,10 +87,10 @@ app.use( resave: false, saveUninitialized: false, cookie: { - secure: process.env.NODE_ENV === "production", + secure: isProd, httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days - sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", + sameSite: "lax", }, }), ); @@ -66,6 +103,18 @@ app.use("/api", (_req, res) => { res.status(404).json({ error: "Not found" }); }); +// Central JSON error handler (Express 5 forwards rejected async handlers here). +app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + const statusCode = (err as { status?: unknown })?.status ?? (err as { statusCode?: unknown })?.statusCode; + const status = typeof statusCode === "number" && statusCode >= 400 && statusCode < 600 ? statusCode : 500; + if (isProd) { + logger.error({ err: (err as Error)?.message }, "Unhandled error"); + } else { + logger.error({ err }, "Unhandled error"); + } + res.status(status).json({ error: isProd ? "Internal server error" : (err as Error)?.message ?? "Internal server error" }); +}); + const staticDir = process.env.STATIC_DIR; if (staticDir && existsSync(staticDir)) { app.use(express.static(staticDir)); diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts index 99ff6e1..7e6c0ac 100644 --- a/artifacts/api-server/src/routes/admin.ts +++ b/artifacts/api-server/src/routes/admin.ts @@ -1,9 +1,17 @@ import { Router, type IRouter } from "express"; import { eq, and, sql } from "drizzle-orm"; +import { z } from "zod"; import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db"; -import { requireAuth } from "../middleware/auth"; +import { requireAdmin } from "../middleware/auth"; import { writeAuditLog } from "../lib/audit"; +const EvaluateBody = z.object({ + toolId: z.coerce.number().int().positive(), + relatedToolId: z.coerce.number().int().positive(), + betterToolId: z.coerce.number().int().positive(), + notes: z.string().optional(), +}); + const router: IRouter = Router(); function buildRecommendation(a: any, b: any): { betterToolId: number; betterName: string; reason: string; certainty: "high" | "medium" | "low"; reasons: string[] } { @@ -62,7 +70,7 @@ function buildRecommendation(a: any, b: any): { betterToolId: number; betterName }; } -router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => { +router.get("/admin/redundancy", requireAdmin, async (_req, res): Promise => { const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name); const allRatings = await db @@ -158,10 +166,27 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => res.json(result); }); -router.post("/admin/redundancy/evaluate", requireAuth, async (req, res): Promise => { - const { toolId, relatedToolId, betterToolId, notes } = req.body; - if (!toolId || !relatedToolId || !betterToolId) { - res.status(400).json({ error: "toolId, relatedToolId, and betterToolId are required" }); +router.post("/admin/redundancy/evaluate", requireAdmin, async (req, res): Promise => { + const parsed = EvaluateBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const { toolId, relatedToolId, betterToolId, notes } = parsed.data; + if (toolId === relatedToolId) { + res.status(400).json({ error: "toolId and relatedToolId must differ" }); + return; + } + + const [toolA] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, toolId)).limit(1); + const [toolB] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, relatedToolId)).limit(1); + if (!toolA || !toolB) { + res.status(404).json({ error: "One or both tools not found" }); + return; + } + const [better] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, betterToolId)).limit(1); + if (!better) { + res.status(404).json({ error: "betterToolId not found" }); return; } diff --git a/artifacts/api-server/src/routes/analytics.ts b/artifacts/api-server/src/routes/analytics.ts index 447c149..686c702 100644 --- a/artifacts/api-server/src/routes/analytics.ts +++ b/artifacts/api-server/src/routes/analytics.ts @@ -73,78 +73,64 @@ router.get("/analytics/top-tools", async (req, res): Promise => { res.status(400).json({ error: parsed.error.message }); return; } - const limit = parsed.data.limit ?? 10; + const limit = Math.min(Math.max(parsed.data.limit ?? 10, 1), 50); const metric = parsed.data.metric ?? "combined"; - const tools = await db.select().from(toolsTable); - const allRatings = await db - .select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) - .from(ratingsTable); + const scoreExpr = metric === "usefulness" + ? sql`avg(${ratingsTable.usefulness})` + : metric === "usability" + ? sql`avg(${ratingsTable.usability})` + : sql`(avg(${ratingsTable.usefulness}) + avg(${ratingsTable.usability})) / 2`; - const ratingsByTool = new Map(); - for (const r of allRatings) { - const arr = ratingsByTool.get(r.toolId) ?? []; - arr.push({ usefulness: r.usefulness, usability: r.usability }); - ratingsByTool.set(r.toolId, arr); - } - - const toolsWithStats = tools.map((t) => { - const ratings = ratingsByTool.get(t.id) ?? []; - const rc = ratings.length; - const au = rc > 0 ? ratings.reduce((s, r) => s + r.usefulness, 0) / rc : null; - const aus = rc > 0 ? ratings.reduce((s, r) => s + r.usability, 0) / rc : null; - const ac = au != null && aus != null ? (au + aus) / 2 : null; - return { ...t, ratingCount: rc, avgUsefulness: au, avgUsability: aus, avgCombined: ac }; - }); - - const scored = toolsWithStats - .filter((t) => t.ratingCount > 0) - .map((t) => { - let score = 0; - if (metric === "usefulness") score = t.avgUsefulness ?? 0; - else if (metric === "usability") score = t.avgUsability ?? 0; - else score = t.avgCombined ?? 0; - return { tool: t, score, ratingCount: t.ratingCount }; + const rows = await db + .select({ + tool: toolsTable, + ratingCount: sql`count(${ratingsTable.id})::int`, + avgUsefulness: sql`avg(${ratingsTable.usefulness})`, + avgUsability: sql`avg(${ratingsTable.usability})`, + score: sql`${scoreExpr}`, }) - .sort((a, b) => b.score - a.score) - .slice(0, limit); + .from(toolsTable) + .innerJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id)) + .groupBy(toolsTable.id) + .orderBy(desc(scoreExpr)) + .limit(limit); + + const scored = rows.map(({ tool, ratingCount, avgUsefulness, avgUsability, score }) => { + const avgCombined = avgUsefulness != null && avgUsability != null + ? (Number(avgUsefulness) + Number(avgUsability)) / 2 + : null; + return { + tool: { ...tool, ratingCount, avgUsefulness, avgUsability, avgCombined }, + score: Number(score), + ratingCount, + }; + }); res.json(scored); }); router.get("/analytics/by-category", async (_req, res): Promise => { - const categories = await db - .selectDistinct({ category: toolsTable.category }) - .from(toolsTable); - - const result = await Promise.all( - categories.map(async ({ category }) => { - const [toolCount] = await db - .select({ count: sql`count(*)::int` }) - .from(toolsTable) - .where(eq(toolsTable.category, category)); - - const [ratingStats] = await db - .select({ - totalRatings: sql`count(*)::int`, - avgUsefulness: sql`avg(${ratingsTable.usefulness})`, - avgUsability: sql`avg(${ratingsTable.usability})`, - }) - .from(ratingsTable) - .innerJoin(toolsTable, eq(ratingsTable.toolId, toolsTable.id)) - .where(eq(toolsTable.category, category)); - - return { - category, - toolCount: toolCount?.count ?? 0, - totalRatings: ratingStats?.totalRatings ?? 0, - avgUsefulness: ratingStats?.avgUsefulness != null ? Number(ratingStats.avgUsefulness) : null, - avgUsability: ratingStats?.avgUsability != null ? Number(ratingStats.avgUsability) : null, - }; + const rows = await db + .select({ + category: toolsTable.category, + toolCount: sql`count(distinct ${toolsTable.id})::int`, + totalRatings: sql`count(${ratingsTable.id})::int`, + avgUsefulness: sql`avg(${ratingsTable.usefulness})`, + avgUsability: sql`avg(${ratingsTable.usability})`, }) - ); + .from(toolsTable) + .leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id)) + .groupBy(toolsTable.category) + .orderBy(toolsTable.category); - res.json(result); + res.json(rows.map((r) => ({ + category: r.category, + toolCount: r.toolCount ?? 0, + totalRatings: r.totalRatings ?? 0, + avgUsefulness: r.avgUsefulness != null ? Number(r.avgUsefulness) : null, + avgUsability: r.avgUsability != null ? Number(r.avgUsability) : null, + }))); }); router.get("/analytics/rating-distribution", async (req, res): Promise => { diff --git a/artifacts/api-server/src/routes/audit.ts b/artifacts/api-server/src/routes/audit.ts index 6791f41..138b57a 100644 --- a/artifacts/api-server/src/routes/audit.ts +++ b/artifacts/api-server/src/routes/audit.ts @@ -7,7 +7,8 @@ const router: IRouter = Router(); router.get("/audit-logs", requireAdmin, async (req, res): Promise => { const { entityType, entityId, limit } = req.query; - const maxLimit = Math.min(parseInt(limit as string) || 100, 500); + const parsedLimit = parseInt(limit as string, 10); + const maxLimit = Number.isFinite(parsedLimit) ? Math.min(Math.max(parsedLimit, 1), 500) : 100; const conditions: ReturnType[] = []; if (entityType && typeof entityType === "string") { diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index c22bb04..68f9b22 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -25,6 +25,15 @@ function getBaseUrl(req: Request): string { return `${proto}://${host}`; } +function isSafeReturnTo(value: string): boolean { + if (!value.startsWith("/") || value.startsWith("//")) return false; + try { + return new URL(value, "http://localhost").origin === "http://localhost"; + } catch { + return false; + } +} + async function getClient(): Promise { if (cachedClient) return cachedClient; @@ -125,6 +134,10 @@ router.post("/auth/login", async (req, res): Promise => { return; } + await new Promise((resolve, reject) => { + req.session.regenerate((err) => (err ? reject(err) : resolve())); + }); + req.session.user = { sub: String(user.id), name: user.username, @@ -155,9 +168,11 @@ router.get("/auth/login", async (req, res): Promise => { const codeVerifier = generators.codeVerifier(); const codeChallenge = generators.codeChallenge(codeVerifier); + const state = generators.state(); req.session.codeVerifier = codeVerifier; - if (req.query.returnTo && typeof req.query.returnTo === "string") { + req.session.oidcState = state; + if (req.query.returnTo && typeof req.query.returnTo === "string" && isSafeReturnTo(req.query.returnTo)) { req.session.returnTo = req.query.returnTo; } @@ -167,6 +182,7 @@ router.get("/auth/login", async (req, res): Promise => { code_challenge: codeChallenge, code_challenge_method: "S256", redirect_uri: redirectUri, + state, }); res.redirect(url); @@ -185,17 +201,29 @@ router.get("/auth/callback", async (req, res): Promise => { return; } + const state = typeof req.query.state === "string" ? req.query.state : ""; + if (!state || state !== req.session.oidcState) { + res.status(400).json({ error: "Invalid OAuth state." }); + return; + } + delete req.session.oidcState; + const redirectUri = `${getBaseUrl(req)}/api/auth/callback`; try { const params = client.callbackParams(req); const tokenSet = await client.callback(redirectUri, params, { code_verifier: codeVerifier, + state, }); const userinfo = await client.userinfo(tokenSet.access_token!); const dbUser = await upsertUserFromOidc(userinfo); + await new Promise((resolve, reject) => { + req.session.regenerate((err) => (err ? reject(err) : resolve())); + }); + req.session.user = { sub: dbUser.id.toString(), email: typeof userinfo.email === "string" ? userinfo.email : undefined, @@ -207,10 +235,10 @@ router.get("/auth/callback", async (req, res): Promise => { }; delete req.session.codeVerifier; - const returnTo = req.session.returnTo || "/"; + const returnTo = req.session.returnTo ?? "/"; delete req.session.returnTo; - res.redirect(returnTo); + res.redirect(isSafeReturnTo(returnTo) ? returnTo : "/"); } catch (err) { logger.error({ err }, "Keycloak callback failed"); res.status(500).json({ error: "Authentication failed." }); diff --git a/artifacts/api-server/src/routes/costs.ts b/artifacts/api-server/src/routes/costs.ts index b01d813..d1ab441 100644 --- a/artifacts/api-server/src/routes/costs.ts +++ b/artifacts/api-server/src/routes/costs.ts @@ -1,5 +1,6 @@ import { Router, type IRouter } from "express"; import { eq, and } from "drizzle-orm"; +import { z } from "zod"; import { db, toolsTable, toolCostsTable } from "@workspace/db"; import { requireAuth, requireAdmin } from "../middleware/auth"; import { requireFeature } from "../middleware/feature"; @@ -7,6 +8,27 @@ import { writeAuditLog } from "../lib/audit"; const router: IRouter = Router(); +const LicenseType = z.enum(["free", "subscription", "one_time", "usage_based"]); +const BillingPeriod = z.enum(["monthly", "quarterly", "yearly"]); + +const CostCreateBody = z.object({ + licenseType: LicenseType.optional(), + billingPeriod: BillingPeriod.nullable().optional(), + cost: z.coerce.number().finite().nonnegative().nullable().optional(), + currency: z.string().min(1).max(10).optional(), + renewalDate: z.coerce.date().optional(), + notes: z.string().optional(), +}); + +const CostUpdateBody = CostCreateBody.partial().extend({ + licenseType: LicenseType.optional(), + billingPeriod: BillingPeriod.nullable().optional(), + cost: z.coerce.number().finite().nonnegative().nullable().optional(), + currency: z.string().min(1).max(10).optional(), + renewalDate: z.coerce.date().nullable().optional(), + notes: z.string().nullable().optional(), +}); + router.get("/tools/:id/costs", async (req, res): Promise => { const toolId = Number(req.params.id); if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; } @@ -30,15 +52,20 @@ router.post("/tools/:id/costs", requireAuth, requireFeature("costs"), async (req const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId)); if (!tool) { res.status(404).json({ error: "Tool not found" }); return; } - const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body; + const parsed = CostCreateBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = parsed.data; const [entry] = await db.insert(toolCostsTable).values({ toolId, licenseType: licenseType ?? "free", billingPeriod: billingPeriod ?? null, - cost: cost ?? null, + cost: cost != null ? String(cost) : null, currency: currency ?? "EUR", - renewalDate: renewalDate ? new Date(renewalDate) : null, + renewalDate: renewalDate ?? null, notes: notes ?? null, createdBy: Number(req.session.user!.sub), }).returning(); @@ -54,13 +81,18 @@ router.patch("/costs/:id", requireAdmin, async (req, res): Promise => { const [existing] = await db.select().from(toolCostsTable).where(eq(toolCostsTable.id, id)); if (!existing) { res.status(404).json({ error: "Cost entry not found" }); return; } - const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = req.body; + const parsed = CostUpdateBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const { licenseType, billingPeriod, cost, currency, renewalDate, notes } = parsed.data; const updateData: Record = {}; if (licenseType !== undefined) updateData.licenseType = licenseType; if (billingPeriod !== undefined) updateData.billingPeriod = billingPeriod; - if (cost !== undefined) updateData.cost = cost; + if (cost !== undefined) updateData.cost = cost != null ? String(cost) : null; if (currency !== undefined) updateData.currency = currency; - if (renewalDate !== undefined) updateData.renewalDate = renewalDate ? new Date(renewalDate) : null; + if (renewalDate !== undefined) updateData.renewalDate = renewalDate; if (notes !== undefined) updateData.notes = notes; const [updated] = await db.update(toolCostsTable).set(updateData).where(eq(toolCostsTable.id, id)).returning(); diff --git a/artifacts/api-server/src/routes/ratings.ts b/artifacts/api-server/src/routes/ratings.ts index ae36c9f..64ba218 100644 --- a/artifacts/api-server/src/routes/ratings.ts +++ b/artifacts/api-server/src/routes/ratings.ts @@ -79,14 +79,39 @@ router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise = return; } - const [rating] = await db.insert(ratingsTable).values({ - toolId: params.data.id, - usefulness: parsed.data.usefulness, - usability: parsed.data.usability, - comment: parsed.data.comment ?? null, - reviewerName: parsed.data.reviewerName ?? null, - voterToken: token, - }).returning(); + let rating: { + id: number; + toolId: number; + usefulness: number; + usability: number; + comment: string | null; + reviewerName: string | null; + createdAt: Date; + }; + try { + [rating] = await db.insert(ratingsTable).values({ + toolId: params.data.id, + usefulness: parsed.data.usefulness, + usability: parsed.data.usability, + comment: parsed.data.comment ?? null, + reviewerName: parsed.data.reviewerName ?? null, + voterToken: token, + }).returning({ + id: ratingsTable.id, + toolId: ratingsTable.toolId, + usefulness: ratingsTable.usefulness, + usability: ratingsTable.usability, + comment: ratingsTable.comment, + reviewerName: ratingsTable.reviewerName, + createdAt: ratingsTable.createdAt, + }); + } catch (err) { + if ((err as { code?: string })?.code === "23505") { + res.status(409).json({ error: "You have already reviewed this tool." }); + return; + } + throw err; + } res.status(201).json(rating); }); diff --git a/artifacts/api-server/src/routes/tools.ts b/artifacts/api-server/src/routes/tools.ts index b82dba6..78fc9de 100644 --- a/artifacts/api-server/src/routes/tools.ts +++ b/artifacts/api-server/src/routes/tools.ts @@ -1,5 +1,6 @@ import { Router, type IRouter } from "express"; -import { eq, ilike, desc, sql, and, not } from "drizzle-orm"; +import { eq, desc, sql, and, not } from "drizzle-orm"; +import { z } from "zod"; import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db"; import { ListToolsQueryParams, @@ -15,6 +16,14 @@ import { writeAuditLog } from "../lib/audit"; const router: IRouter = Router(); +const PUBLIC_RELATION_TYPES = ["similar", "replaces", "superseded_by"] as const; +const RELATION_TYPES = [...PUBLIC_RELATION_TYPES, "recommended"] as const; +const RelationBody = z.object({ + relatedToolId: z.coerce.number().int().positive(), + relationType: z.enum(RELATION_TYPES).optional(), + notes: z.string().optional(), +}); + function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { usefulness: number; usability: number }[]) { const ratingCount = ratings.length; const avgUsefulness = ratingCount > 0 @@ -49,7 +58,8 @@ router.get("/tools", async (req, res): Promise => { query = query.where(eq(toolsTable.category, category)); } if (search) { - query = query.where(ilike(toolsTable.name, `%${search}%`)); + const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`); + query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`); } const tools = await query.orderBy(desc(toolsTable.createdAt)); @@ -326,8 +336,29 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools") const toolId = Number(req.params.id); if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; } - const { relatedToolId, relationType, notes } = req.body; - if (!relatedToolId) { res.status(400).json({ error: "relatedToolId is required" }); return; } + const parsed = RelationBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + const { relatedToolId, notes } = parsed.data; + const relationType = parsed.data.relationType ?? "similar"; + + if (relationType === "recommended" && req.session.user?.role !== "admin") { + res.status(403).json({ error: "Only admins can create recommended relations" }); + return; + } + if (toolId === relatedToolId) { + res.status(400).json({ error: "A tool cannot be related to itself" }); + return; + } + + const [toolA] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, toolId)).limit(1); + const [toolB] = await db.select({ id: toolsTable.id }).from(toolsTable).where(eq(toolsTable.id, relatedToolId)).limit(1); + if (!toolA || !toolB) { + res.status(404).json({ error: "One or both tools not found" }); + return; + } const [existing] = await db .select() @@ -340,7 +371,7 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools") const [relation] = await db.insert(toolRelationsTable).values({ toolId, relatedToolId, - relationType: relationType ?? "similar", + relationType, notes: notes ?? null, createdBy: Number(req.session.user!.sub), }).returning(); diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts index 9c8a5fd..8176c1f 100644 --- a/artifacts/api-server/src/routes/users.ts +++ b/artifacts/api-server/src/routes/users.ts @@ -1,5 +1,5 @@ import { Router, type IRouter } from "express"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import bcrypt from "bcryptjs"; import { db, usersTable } from "@workspace/db"; import { requireAdmin } from "../middleware/auth"; @@ -85,6 +85,32 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise => { return; } + const [target] = await db + .select({ id: usersTable.id, role: usersTable.role }) + .from(usersTable) + .where(eq(usersTable.id, id)) + .limit(1); + + if (!target) { + res.status(404).json({ error: "User not found" }); + return; + } + + if (target.role === "admin" && parsed.data.role === "user") { + if (req.session.user?.sub === String(id)) { + res.status(400).json({ error: "Cannot demote your own account" }); + return; + } + const [{ count }] = await db + .select({ count: sql`count(*)::int` }) + .from(usersTable) + .where(eq(usersTable.role, "admin")); + if (count <= 1) { + res.status(400).json({ error: "Cannot demote the last admin" }); + return; + } + } + const [user] = await db .update(usersTable) .set({ role: parsed.data.role }) @@ -97,11 +123,6 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise => { createdAt: usersTable.createdAt, }); - if (!user) { - res.status(404).json({ error: "User not found" }); - return; - } - await writeAuditLog(req, "user", id, "update", { role: parsed.data.role }); res.json(user); }); diff --git a/artifacts/api-server/src/types/session.d.ts b/artifacts/api-server/src/types/session.d.ts index c0ef2f7..fe0f5cb 100644 --- a/artifacts/api-server/src/types/session.d.ts +++ b/artifacts/api-server/src/types/session.d.ts @@ -13,5 +13,6 @@ declare module "express-session" { }; codeVerifier?: string; returnTo?: string; + oidcState?: string; } } diff --git a/artifacts/toolrate/src/components/category-combobox.tsx b/artifacts/toolrate/src/components/category-combobox.tsx index 6c581ff..c6ca0ba 100644 --- a/artifacts/toolrate/src/components/category-combobox.tsx +++ b/artifacts/toolrate/src/components/category-combobox.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { Check, ChevronsUpDown } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -26,6 +26,11 @@ interface CategoryComboboxProps { export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) { const [open, setOpen] = useState(false); const [inputValue, setInputValue] = useState(value); + + useEffect(() => { + setInputValue(value); + }, [value]); + const categories = useListCategories({ query: { queryKey: getListCategoriesQueryKey(), diff --git a/artifacts/toolrate/src/pages/tool-detail.tsx b/artifacts/toolrate/src/pages/tool-detail.tsx index 4a7eb5a..d7c4a42 100644 --- a/artifacts/toolrate/src/pages/tool-detail.tsx +++ b/artifacts/toolrate/src/pages/tool-detail.tsx @@ -7,7 +7,11 @@ import { getListToolRatingsQueryKey, useGetRatingDistribution, getGetRatingDistributionQueryKey, - useCreateRating + useCreateRating, + getGetTopToolsQueryKey, + getGetAnalyticsSummaryQueryKey, + getListCategoriesQueryKey, + getListAllFeaturesQueryKey } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { useForm } from "react-hook-form"; @@ -214,15 +218,6 @@ export default function ToolDetail() { }); const onSubmit = (data: RatingFormValues) => { - if (data.usefulness === 0 || data.usability === 0) { - toast({ - title: "Missing ratings", - description: "Please rate both usefulness and usability.", - variant: "destructive" - }); - return; - } - createRating.mutate({ id, data }, { onSuccess: () => { toast({ @@ -236,6 +231,8 @@ export default function ToolDetail() { queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) }); queryClient.invalidateQueries({ queryKey: getListToolRatingsQueryKey(id) }); queryClient.invalidateQueries({ queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }); + queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() }); }, onError: (error) => { toast({ @@ -277,6 +274,10 @@ export default function ToolDetail() { onSuccess: () => { toast({ title: "Tool deleted" }); queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); + queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() }); + queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() }); setLocation("/tools"); }, onError: (err) => { @@ -711,7 +712,7 @@ export default function ToolDetail() {

Reviews

- {!isReviewFormOpen && ( + {!isReviewFormOpen && user && ( )}
diff --git a/artifacts/toolrate/src/pages/tool-edit.tsx b/artifacts/toolrate/src/pages/tool-edit.tsx index d3936e5..66a5f99 100644 --- a/artifacts/toolrate/src/pages/tool-edit.tsx +++ b/artifacts/toolrate/src/pages/tool-edit.tsx @@ -10,6 +10,8 @@ import { getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, + getGetTopToolsQueryKey, + getGetAnalyticsSummaryQueryKey, } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; @@ -92,8 +94,8 @@ export default function ToolEdit() { const onSubmit = (data: ToolFormValues) => { const payload = { ...data, - websiteUrl: data.websiteUrl || undefined, - iconUrl: data.iconUrl || undefined, + websiteUrl: data.websiteUrl?.trim() ? data.websiteUrl : null, + iconUrl: data.iconUrl?.trim() ? data.iconUrl : null, features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""), tags: data.tags?.map((t) => t.value).filter((v) => v.trim() !== ""), }; @@ -107,6 +109,8 @@ export default function ToolEdit() { queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() }); queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() }); setLocation(`/tools/${id}`); }, onError: (err) => { diff --git a/artifacts/toolrate/src/pages/tool-new.tsx b/artifacts/toolrate/src/pages/tool-new.tsx index 34f0afc..cf60048 100644 --- a/artifacts/toolrate/src/pages/tool-new.tsx +++ b/artifacts/toolrate/src/pages/tool-new.tsx @@ -2,7 +2,7 @@ import { useLocation } from "wouter"; import { useForm, useFieldArray } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; -import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey } from "@workspace/api-client-react"; +import { useCreateTool, getListToolsQueryKey, getListCategoriesQueryKey, getListAllFeaturesQueryKey, getGetTopToolsQueryKey, getGetAnalyticsSummaryQueryKey } from "@workspace/api-client-react"; import { useQueryClient } from "@tanstack/react-query"; import { Layout } from "@/components/layout"; @@ -77,6 +77,8 @@ export default function ToolNew() { queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() }); queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() }); + queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() }); setLocation(`/tools/${newTool.id}`); }, onError: () => { diff --git a/artifacts/toolrate/src/pages/tools-browse.tsx b/artifacts/toolrate/src/pages/tools-browse.tsx index 8e23eb4..82c24a2 100644 --- a/artifacts/toolrate/src/pages/tools-browse.tsx +++ b/artifacts/toolrate/src/pages/tools-browse.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useListTools, useListCategories, @@ -11,13 +11,35 @@ import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Search, Wrench, SlidersHorizontal, X } from "lucide-react"; -import { Link } from "wouter"; +import { Link, useLocation, useSearch } from "wouter"; + +const SORT_VALUES = new Set([ListToolsSort.newest, ListToolsSort.top_rated, ListToolsSort.most_reviewed]); export default function ToolsBrowse() { - const [search, setSearch] = useState(""); - const [searchInput, setSearchInput] = useState(""); - const [category, setCategory] = useState("all"); - const [sort, setSort] = useState(ListToolsSort.newest); + const [, navigate] = useLocation(); + const urlSearch = useSearch(); + + const initialParams = new URLSearchParams(urlSearch); + const initialSearch = initialParams.get("search") ?? ""; + const initialCategory = initialParams.get("category") ?? "all"; + const initialSortParam = initialParams.get("sort") ?? ""; + const initialSort = SORT_VALUES.has(initialSortParam) + ? (initialSortParam as ListToolsSort) + : ListToolsSort.newest; + + const [search, setSearch] = useState(initialSearch); + const [searchInput, setSearchInput] = useState(initialSearch); + const [category, setCategory] = useState(initialCategory); + const [sort, setSort] = useState(initialSort); + + useEffect(() => { + const p = new URLSearchParams(); + if (search) p.set("search", search); + if (category && category !== "all") p.set("category", category); + if (sort && sort !== ListToolsSort.newest) p.set("sort", sort); + const qs = p.toString(); + navigate(qs ? `/tools?${qs}` : "/tools", { replace: true }); + }, [search, category, sort]); const { data: categories, isLoading: loadingCategories } = useListCategories(); diff --git a/docker-compose.yml b/docker-compose.yml index 73111e9..3903b34 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: BASE_PATH: "/" DATABASE_URL: postgres://toolrate:toolrate@db:5432/toolrate SESSION_SECRET: change-this-to-a-random-secret + VOTER_SECRET: change-this-to-a-random-voter-secret NODE_ENV: production LOCAL_ADMIN_USERNAME: admin LOCAL_ADMIN_PASSWORD: pssw0rd diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index b245d0b..bd0c319 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -144,8 +144,8 @@ export interface ToolUpdate { name?: string; description?: string; category?: string; - websiteUrl?: string; - iconUrl?: string; + websiteUrl?: string | null; + iconUrl?: string | null; features?: string[]; tags?: string[]; } diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index 053f88c..0ea7b04 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -105,8 +105,8 @@ export const UpdateToolBody = zod.object({ "name": zod.string().min(1).optional(), "description": zod.string().optional(), "category": zod.string().optional(), - "websiteUrl": zod.string().optional(), - "iconUrl": zod.string().optional(), + "websiteUrl": zod.string().nullable().optional(), + "iconUrl": zod.string().nullable().optional(), "features": zod.array(zod.string()).optional(), "tags": zod.array(zod.string()).optional() }) diff --git a/lib/db/src/schema/ratings.ts b/lib/db/src/schema/ratings.ts index d8f4b41..fa25520 100644 --- a/lib/db/src/schema/ratings.ts +++ b/lib/db/src/schema/ratings.ts @@ -1,18 +1,24 @@ -import { pgTable, text, serial, integer, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, text, serial, integer, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; import { createInsertSchema } from "drizzle-zod"; import { z } from "zod/v4"; import { toolsTable } from "./tools"; -export const ratingsTable = pgTable("ratings", { - id: serial("id").primaryKey(), - toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), - usefulness: integer("usefulness").notNull(), - usability: integer("usability").notNull(), - comment: text("comment"), - reviewerName: text("reviewer_name"), - voterToken: text("voter_token"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), -}); +export const ratingsTable = pgTable( + "ratings", + { + id: serial("id").primaryKey(), + toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), + usefulness: integer("usefulness").notNull(), + usability: integer("usability").notNull(), + comment: text("comment"), + reviewerName: text("reviewer_name"), + voterToken: text("voter_token"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => [ + uniqueIndex("ratings_tool_voter_uniq").on(t.toolId, t.voterToken), + ], +); export const insertRatingSchema = createInsertSchema(ratingsTable).omit({ id: true, createdAt: true }); export type InsertRating = z.infer; diff --git a/lib/db/src/schema/tool-costs.ts b/lib/db/src/schema/tool-costs.ts index 2a8cde4..5771594 100644 --- a/lib/db/src/schema/tool-costs.ts +++ b/lib/db/src/schema/tool-costs.ts @@ -11,6 +11,6 @@ export const toolCostsTable = pgTable("tool_costs", { currency: text("currency").default("EUR"), renewalDate: timestamp("renewal_date", { withTimezone: true }), notes: text("notes"), - createdBy: integer("created_by").references(() => usersTable.id), + createdBy: integer("created_by").references(() => usersTable.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); diff --git a/lib/db/src/schema/tool-relations.ts b/lib/db/src/schema/tool-relations.ts index 9bc37d8..079cac1 100644 --- a/lib/db/src/schema/tool-relations.ts +++ b/lib/db/src/schema/tool-relations.ts @@ -8,6 +8,6 @@ export const toolRelationsTable = pgTable("tool_relations", { relatedToolId: integer("related_tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), relationType: text("relation_type", { enum: ["similar", "replaces", "superseded_by", "recommended"] }).notNull().default("similar"), notes: text("notes"), - createdBy: integer("created_by").references(() => usersTable.id), + createdBy: integer("created_by").references(() => usersTable.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), });