fix: security hardening, validation, cache and analytics fixes
Build & Push Docker Image / build (push) Successful in 4m32s

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
This commit is contained in:
opencode
2026-08-01 19:11:00 +02:00
parent 0c6a35e841
commit db397a14bc
21 changed files with 378 additions and 138 deletions
+53 -4
View File
@@ -1,5 +1,4 @@
import express, { type Express } from "express"; import express, { type Express } from "express";
import cors from "cors";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import pinoHttp from "pino-http"; import pinoHttp from "pino-http";
@@ -11,6 +10,15 @@ import "./types/session.d.ts";
const PgStore = ConnectPgSimple(session); 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(); const app: Express = express();
app.set("trust proxy", 1); 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.json());
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
@@ -50,10 +87,10 @@ app.use(
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: process.env.NODE_ENV === "production", secure: isProd,
httpOnly: true, httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days 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" }); 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; const staticDir = process.env.STATIC_DIR;
if (staticDir && existsSync(staticDir)) { if (staticDir && existsSync(staticDir)) {
app.use(express.static(staticDir)); app.use(express.static(staticDir));
+31 -6
View File
@@ -1,9 +1,17 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import { eq, and, sql } from "drizzle-orm"; import { eq, and, sql } from "drizzle-orm";
import { z } from "zod";
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db"; import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
import { requireAuth } from "../middleware/auth"; import { requireAdmin } from "../middleware/auth";
import { writeAuditLog } from "../lib/audit"; 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(); const router: IRouter = Router();
function buildRecommendation(a: any, b: any): { betterToolId: number; betterName: string; reason: string; certainty: "high" | "medium" | "low"; reasons: string[] } { 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<void> => { router.get("/admin/redundancy", requireAdmin, async (_req, res): Promise<void> => {
const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name); const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name);
const allRatings = await db const allRatings = await db
@@ -158,10 +166,27 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
res.json(result); res.json(result);
}); });
router.post("/admin/redundancy/evaluate", requireAuth, async (req, res): Promise<void> => { router.post("/admin/redundancy/evaluate", requireAdmin, async (req, res): Promise<void> => {
const { toolId, relatedToolId, betterToolId, notes } = req.body; const parsed = EvaluateBody.safeParse(req.body);
if (!toolId || !relatedToolId || !betterToolId) { if (!parsed.success) {
res.status(400).json({ error: "toolId, relatedToolId, and betterToolId are required" }); 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; return;
} }
+44 -58
View File
@@ -73,78 +73,64 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
res.status(400).json({ error: parsed.error.message }); res.status(400).json({ error: parsed.error.message });
return; 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 metric = parsed.data.metric ?? "combined";
const tools = await db.select().from(toolsTable); const scoreExpr = metric === "usefulness"
const allRatings = await db ? sql`avg(${ratingsTable.usefulness})`
.select({ toolId: ratingsTable.toolId, usefulness: ratingsTable.usefulness, usability: ratingsTable.usability }) : metric === "usability"
.from(ratingsTable); ? sql`avg(${ratingsTable.usability})`
: sql`(avg(${ratingsTable.usefulness}) + avg(${ratingsTable.usability})) / 2`;
const ratingsByTool = new Map<number, { usefulness: number; usability: number }[]>(); const rows = await db
for (const r of allRatings) { .select({
const arr = ratingsByTool.get(r.toolId) ?? []; tool: toolsTable,
arr.push({ usefulness: r.usefulness, usability: r.usability }); ratingCount: sql<number>`count(${ratingsTable.id})::int`,
ratingsByTool.set(r.toolId, arr); avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
} avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
score: sql<number>`${scoreExpr}`,
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 };
}) })
.sort((a, b) => b.score - a.score) .from(toolsTable)
.slice(0, limit); .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); res.json(scored);
}); });
router.get("/analytics/by-category", async (_req, res): Promise<void> => { router.get("/analytics/by-category", async (_req, res): Promise<void> => {
const categories = await db const rows = await db
.selectDistinct({ category: toolsTable.category })
.from(toolsTable);
const result = await Promise.all(
categories.map(async ({ category }) => {
const [toolCount] = await db
.select({ count: sql<number>`count(*)::int` })
.from(toolsTable)
.where(eq(toolsTable.category, category));
const [ratingStats] = await db
.select({ .select({
totalRatings: sql<number>`count(*)::int`, category: toolsTable.category,
toolCount: sql<number>`count(distinct ${toolsTable.id})::int`,
totalRatings: sql<number>`count(${ratingsTable.id})::int`,
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`, avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
avgUsability: sql<number | null>`avg(${ratingsTable.usability})`, avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
}) })
.from(ratingsTable) .from(toolsTable)
.innerJoin(toolsTable, eq(ratingsTable.toolId, toolsTable.id)) .leftJoin(ratingsTable, eq(ratingsTable.toolId, toolsTable.id))
.where(eq(toolsTable.category, category)); .groupBy(toolsTable.category)
.orderBy(toolsTable.category);
return { res.json(rows.map((r) => ({
category, category: r.category,
toolCount: toolCount?.count ?? 0, toolCount: r.toolCount ?? 0,
totalRatings: ratingStats?.totalRatings ?? 0, totalRatings: r.totalRatings ?? 0,
avgUsefulness: ratingStats?.avgUsefulness != null ? Number(ratingStats.avgUsefulness) : null, avgUsefulness: r.avgUsefulness != null ? Number(r.avgUsefulness) : null,
avgUsability: ratingStats?.avgUsability != null ? Number(ratingStats.avgUsability) : null, avgUsability: r.avgUsability != null ? Number(r.avgUsability) : null,
}; })));
})
);
res.json(result);
}); });
router.get("/analytics/rating-distribution", async (req, res): Promise<void> => { router.get("/analytics/rating-distribution", async (req, res): Promise<void> => {
+2 -1
View File
@@ -7,7 +7,8 @@ const router: IRouter = Router();
router.get("/audit-logs", requireAdmin, async (req, res): Promise<void> => { router.get("/audit-logs", requireAdmin, async (req, res): Promise<void> => {
const { entityType, entityId, limit } = req.query; 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<typeof eq>[] = []; const conditions: ReturnType<typeof eq>[] = [];
if (entityType && typeof entityType === "string") { if (entityType && typeof entityType === "string") {
+31 -3
View File
@@ -25,6 +25,15 @@ function getBaseUrl(req: Request): string {
return `${proto}://${host}`; 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<Client | null> { async function getClient(): Promise<Client | null> {
if (cachedClient) return cachedClient; if (cachedClient) return cachedClient;
@@ -125,6 +134,10 @@ router.post("/auth/login", async (req, res): Promise<void> => {
return; return;
} }
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
req.session.user = { req.session.user = {
sub: String(user.id), sub: String(user.id),
name: user.username, name: user.username,
@@ -155,9 +168,11 @@ router.get("/auth/login", async (req, res): Promise<void> => {
const codeVerifier = generators.codeVerifier(); const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier); const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
req.session.codeVerifier = codeVerifier; 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; req.session.returnTo = req.query.returnTo;
} }
@@ -167,6 +182,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
code_challenge: codeChallenge, code_challenge: codeChallenge,
code_challenge_method: "S256", code_challenge_method: "S256",
redirect_uri: redirectUri, redirect_uri: redirectUri,
state,
}); });
res.redirect(url); res.redirect(url);
@@ -185,17 +201,29 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
return; 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`; const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
try { try {
const params = client.callbackParams(req); const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, { const tokenSet = await client.callback(redirectUri, params, {
code_verifier: codeVerifier, code_verifier: codeVerifier,
state,
}); });
const userinfo = await client.userinfo(tokenSet.access_token!); const userinfo = await client.userinfo(tokenSet.access_token!);
const dbUser = await upsertUserFromOidc(userinfo); const dbUser = await upsertUserFromOidc(userinfo);
await new Promise<void>((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
req.session.user = { req.session.user = {
sub: dbUser.id.toString(), sub: dbUser.id.toString(),
email: typeof userinfo.email === "string" ? userinfo.email : undefined, email: typeof userinfo.email === "string" ? userinfo.email : undefined,
@@ -207,10 +235,10 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
}; };
delete req.session.codeVerifier; delete req.session.codeVerifier;
const returnTo = req.session.returnTo || "/"; const returnTo = req.session.returnTo ?? "/";
delete req.session.returnTo; delete req.session.returnTo;
res.redirect(returnTo); res.redirect(isSafeReturnTo(returnTo) ? returnTo : "/");
} catch (err) { } catch (err) {
logger.error({ err }, "Keycloak callback failed"); logger.error({ err }, "Keycloak callback failed");
res.status(500).json({ error: "Authentication failed." }); res.status(500).json({ error: "Authentication failed." });
+38 -6
View File
@@ -1,5 +1,6 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { db, toolsTable, toolCostsTable } from "@workspace/db"; import { db, toolsTable, toolCostsTable } from "@workspace/db";
import { requireAuth, requireAdmin } from "../middleware/auth"; import { requireAuth, requireAdmin } from "../middleware/auth";
import { requireFeature } from "../middleware/feature"; import { requireFeature } from "../middleware/feature";
@@ -7,6 +8,27 @@ import { writeAuditLog } from "../lib/audit";
const router: IRouter = Router(); 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<void> => { router.get("/tools/:id/costs", async (req, res): Promise<void> => {
const toolId = Number(req.params.id); const toolId = Number(req.params.id);
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; } 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)); const [tool] = await db.select().from(toolsTable).where(eq(toolsTable.id, toolId));
if (!tool) { res.status(404).json({ error: "Tool not found" }); return; } 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({ const [entry] = await db.insert(toolCostsTable).values({
toolId, toolId,
licenseType: licenseType ?? "free", licenseType: licenseType ?? "free",
billingPeriod: billingPeriod ?? null, billingPeriod: billingPeriod ?? null,
cost: cost ?? null, cost: cost != null ? String(cost) : null,
currency: currency ?? "EUR", currency: currency ?? "EUR",
renewalDate: renewalDate ? new Date(renewalDate) : null, renewalDate: renewalDate ?? null,
notes: notes ?? null, notes: notes ?? null,
createdBy: Number(req.session.user!.sub), createdBy: Number(req.session.user!.sub),
}).returning(); }).returning();
@@ -54,13 +81,18 @@ router.patch("/costs/:id", requireAdmin, async (req, res): Promise<void> => {
const [existing] = await db.select().from(toolCostsTable).where(eq(toolCostsTable.id, id)); const [existing] = await db.select().from(toolCostsTable).where(eq(toolCostsTable.id, id));
if (!existing) { res.status(404).json({ error: "Cost entry not found" }); return; } 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<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (licenseType !== undefined) updateData.licenseType = licenseType; if (licenseType !== undefined) updateData.licenseType = licenseType;
if (billingPeriod !== undefined) updateData.billingPeriod = billingPeriod; 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 (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; if (notes !== undefined) updateData.notes = notes;
const [updated] = await db.update(toolCostsTable).set(updateData).where(eq(toolCostsTable.id, id)).returning(); const [updated] = await db.update(toolCostsTable).set(updateData).where(eq(toolCostsTable.id, id)).returning();
+27 -2
View File
@@ -79,14 +79,39 @@ router.post("/tools/:id/ratings", requireAuth, async (req, res): Promise<void> =
return; return;
} }
const [rating] = await db.insert(ratingsTable).values({ 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, toolId: params.data.id,
usefulness: parsed.data.usefulness, usefulness: parsed.data.usefulness,
usability: parsed.data.usability, usability: parsed.data.usability,
comment: parsed.data.comment ?? null, comment: parsed.data.comment ?? null,
reviewerName: parsed.data.reviewerName ?? null, reviewerName: parsed.data.reviewerName ?? null,
voterToken: token, voterToken: token,
}).returning(); }).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); res.status(201).json(rating);
}); });
+36 -5
View File
@@ -1,5 +1,6 @@
import { Router, type IRouter } from "express"; 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 { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import { import {
ListToolsQueryParams, ListToolsQueryParams,
@@ -15,6 +16,14 @@ import { writeAuditLog } from "../lib/audit";
const router: IRouter = Router(); 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 }[]) { function buildToolWithStats(tool: typeof toolsTable.$inferSelect, ratings: { usefulness: number; usability: number }[]) {
const ratingCount = ratings.length; const ratingCount = ratings.length;
const avgUsefulness = ratingCount > 0 const avgUsefulness = ratingCount > 0
@@ -49,7 +58,8 @@ router.get("/tools", async (req, res): Promise<void> => {
query = query.where(eq(toolsTable.category, category)); query = query.where(eq(toolsTable.category, category));
} }
if (search) { 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)); 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); const toolId = Number(req.params.id);
if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; } if (isNaN(toolId)) { res.status(400).json({ error: "Invalid id" }); return; }
const { relatedToolId, relationType, notes } = req.body; const parsed = RelationBody.safeParse(req.body);
if (!relatedToolId) { res.status(400).json({ error: "relatedToolId is required" }); return; } 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 const [existing] = await db
.select() .select()
@@ -340,7 +371,7 @@ router.post("/tools/:id/relations", requireAuth, requireFeature("similar-tools")
const [relation] = await db.insert(toolRelationsTable).values({ const [relation] = await db.insert(toolRelationsTable).values({
toolId, toolId,
relatedToolId, relatedToolId,
relationType: relationType ?? "similar", relationType,
notes: notes ?? null, notes: notes ?? null,
createdBy: Number(req.session.user!.sub), createdBy: Number(req.session.user!.sub),
}).returning(); }).returning();
+27 -6
View File
@@ -1,5 +1,5 @@
import { Router, type IRouter } from "express"; import { Router, type IRouter } from "express";
import { eq } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { db, usersTable } from "@workspace/db"; import { db, usersTable } from "@workspace/db";
import { requireAdmin } from "../middleware/auth"; import { requireAdmin } from "../middleware/auth";
@@ -85,6 +85,32 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
return; 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<number>`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 const [user] = await db
.update(usersTable) .update(usersTable)
.set({ role: parsed.data.role }) .set({ role: parsed.data.role })
@@ -97,11 +123,6 @@ router.patch("/users/:id", requireAdmin, async (req, res): Promise<void> => {
createdAt: usersTable.createdAt, createdAt: usersTable.createdAt,
}); });
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
await writeAuditLog(req, "user", id, "update", { role: parsed.data.role }); await writeAuditLog(req, "user", id, "update", { role: parsed.data.role });
res.json(user); res.json(user);
}); });
+1
View File
@@ -13,5 +13,6 @@ declare module "express-session" {
}; };
codeVerifier?: string; codeVerifier?: string;
returnTo?: string; returnTo?: string;
oidcState?: string;
} }
} }
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { Check, ChevronsUpDown } from "lucide-react"; import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -26,6 +26,11 @@ interface CategoryComboboxProps {
export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) { export function CategoryCombobox({ value, onChange, placeholder = "Select or type a category..." }: CategoryComboboxProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [inputValue, setInputValue] = useState(value); const [inputValue, setInputValue] = useState(value);
useEffect(() => {
setInputValue(value);
}, [value]);
const categories = useListCategories({ const categories = useListCategories({
query: { query: {
queryKey: getListCategoriesQueryKey(), queryKey: getListCategoriesQueryKey(),
+12 -11
View File
@@ -7,7 +7,11 @@ import {
getListToolRatingsQueryKey, getListToolRatingsQueryKey,
useGetRatingDistribution, useGetRatingDistribution,
getGetRatingDistributionQueryKey, getGetRatingDistributionQueryKey,
useCreateRating useCreateRating,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
getListCategoriesQueryKey,
getListAllFeaturesQueryKey
} from "@workspace/api-client-react"; } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -214,15 +218,6 @@ export default function ToolDetail() {
}); });
const onSubmit = (data: RatingFormValues) => { 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 }, { createRating.mutate({ id, data }, {
onSuccess: () => { onSuccess: () => {
toast({ toast({
@@ -236,6 +231,8 @@ export default function ToolDetail() {
queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) }); queryClient.invalidateQueries({ queryKey: getGetToolQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getListToolRatingsQueryKey(id) }); queryClient.invalidateQueries({ queryKey: getListToolRatingsQueryKey(id) });
queryClient.invalidateQueries({ queryKey: getGetRatingDistributionQueryKey({ toolId: id }) }); queryClient.invalidateQueries({ queryKey: getGetRatingDistributionQueryKey({ toolId: id }) });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
}, },
onError: (error) => { onError: (error) => {
toast({ toast({
@@ -277,6 +274,10 @@ export default function ToolDetail() {
onSuccess: () => { onSuccess: () => {
toast({ title: "Tool deleted" }); toast({ title: "Tool deleted" });
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
setLocation("/tools"); setLocation("/tools");
}, },
onError: (err) => { onError: (err) => {
@@ -711,7 +712,7 @@ export default function ToolDetail() {
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h3 className="text-2xl font-bold">Reviews</h3> <h3 className="text-2xl font-bold">Reviews</h3>
{!isReviewFormOpen && ( {!isReviewFormOpen && user && (
<Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button> <Button onClick={() => setIsReviewFormOpen(true)}>Write a Review</Button>
)} )}
</div> </div>
+6 -2
View File
@@ -10,6 +10,8 @@ import {
getListToolsQueryKey, getListToolsQueryKey,
getListCategoriesQueryKey, getListCategoriesQueryKey,
getListAllFeaturesQueryKey, getListAllFeaturesQueryKey,
getGetTopToolsQueryKey,
getGetAnalyticsSummaryQueryKey,
} from "@workspace/api-client-react"; } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
@@ -92,8 +94,8 @@ export default function ToolEdit() {
const onSubmit = (data: ToolFormValues) => { const onSubmit = (data: ToolFormValues) => {
const payload = { const payload = {
...data, ...data,
websiteUrl: data.websiteUrl || undefined, websiteUrl: data.websiteUrl?.trim() ? data.websiteUrl : null,
iconUrl: data.iconUrl || undefined, iconUrl: data.iconUrl?.trim() ? data.iconUrl : null,
features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""), features: data.features?.map((f) => f.value).filter((v) => v.trim() !== ""),
tags: data.tags?.map((t) => t.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: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() }); queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() }); queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
setLocation(`/tools/${id}`); setLocation(`/tools/${id}`);
}, },
onError: (err) => { onError: (err) => {
+3 -1
View File
@@ -2,7 +2,7 @@ import { useLocation } from "wouter";
import { useForm, useFieldArray } from "react-hook-form"; import { useForm, useFieldArray } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "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 { useQueryClient } from "@tanstack/react-query";
import { Layout } from "@/components/layout"; import { Layout } from "@/components/layout";
@@ -77,6 +77,8 @@ export default function ToolNew() {
queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() }); queryClient.invalidateQueries({ queryKey: getListToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() }); queryClient.invalidateQueries({ queryKey: getListCategoriesQueryKey() });
queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() }); queryClient.invalidateQueries({ queryKey: getListAllFeaturesQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetTopToolsQueryKey() });
queryClient.invalidateQueries({ queryKey: getGetAnalyticsSummaryQueryKey() });
setLocation(`/tools/${newTool.id}`); setLocation(`/tools/${newTool.id}`);
}, },
onError: () => { onError: () => {
+28 -6
View File
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { import {
useListTools, useListTools,
useListCategories, useListCategories,
@@ -11,13 +11,35 @@ import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { Search, Wrench, SlidersHorizontal, X } from "lucide-react"; import { Search, Wrench, SlidersHorizontal, X } from "lucide-react";
import { Link } from "wouter"; import { Link, useLocation, useSearch } from "wouter";
const SORT_VALUES = new Set<string>([ListToolsSort.newest, ListToolsSort.top_rated, ListToolsSort.most_reviewed]);
export default function ToolsBrowse() { export default function ToolsBrowse() {
const [search, setSearch] = useState(""); const [, navigate] = useLocation();
const [searchInput, setSearchInput] = useState(""); const urlSearch = useSearch();
const [category, setCategory] = useState<string>("all");
const [sort, setSort] = useState<ListToolsSort>(ListToolsSort.newest); 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<string>(initialCategory);
const [sort, setSort] = useState<ListToolsSort>(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(); const { data: categories, isLoading: loadingCategories } = useListCategories();
+1
View File
@@ -22,6 +22,7 @@ services:
BASE_PATH: "/" BASE_PATH: "/"
DATABASE_URL: postgres://toolrate:toolrate@db:5432/toolrate DATABASE_URL: postgres://toolrate:toolrate@db:5432/toolrate
SESSION_SECRET: change-this-to-a-random-secret SESSION_SECRET: change-this-to-a-random-secret
VOTER_SECRET: change-this-to-a-random-voter-secret
NODE_ENV: production NODE_ENV: production
LOCAL_ADMIN_USERNAME: admin LOCAL_ADMIN_USERNAME: admin
LOCAL_ADMIN_PASSWORD: pssw0rd LOCAL_ADMIN_PASSWORD: pssw0rd
@@ -144,8 +144,8 @@ export interface ToolUpdate {
name?: string; name?: string;
description?: string; description?: string;
category?: string; category?: string;
websiteUrl?: string; websiteUrl?: string | null;
iconUrl?: string; iconUrl?: string | null;
features?: string[]; features?: string[];
tags?: string[]; tags?: string[];
} }
+2 -2
View File
@@ -105,8 +105,8 @@ export const UpdateToolBody = zod.object({
"name": zod.string().min(1).optional(), "name": zod.string().min(1).optional(),
"description": zod.string().optional(), "description": zod.string().optional(),
"category": zod.string().optional(), "category": zod.string().optional(),
"websiteUrl": zod.string().optional(), "websiteUrl": zod.string().nullable().optional(),
"iconUrl": zod.string().optional(), "iconUrl": zod.string().nullable().optional(),
"features": zod.array(zod.string()).optional(), "features": zod.array(zod.string()).optional(),
"tags": zod.array(zod.string()).optional() "tags": zod.array(zod.string()).optional()
}) })
+9 -3
View File
@@ -1,9 +1,11 @@
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 { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4"; import { z } from "zod/v4";
import { toolsTable } from "./tools"; import { toolsTable } from "./tools";
export const ratingsTable = pgTable("ratings", { export const ratingsTable = pgTable(
"ratings",
{
id: serial("id").primaryKey(), id: serial("id").primaryKey(),
toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }),
usefulness: integer("usefulness").notNull(), usefulness: integer("usefulness").notNull(),
@@ -12,7 +14,11 @@ export const ratingsTable = pgTable("ratings", {
reviewerName: text("reviewer_name"), reviewerName: text("reviewer_name"),
voterToken: text("voter_token"), voterToken: text("voter_token"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), 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 const insertRatingSchema = createInsertSchema(ratingsTable).omit({ id: true, createdAt: true });
export type InsertRating = z.infer<typeof insertRatingSchema>; export type InsertRating = z.infer<typeof insertRatingSchema>;
+1 -1
View File
@@ -11,6 +11,6 @@ export const toolCostsTable = pgTable("tool_costs", {
currency: text("currency").default("EUR"), currency: text("currency").default("EUR"),
renewalDate: timestamp("renewal_date", { withTimezone: true }), renewalDate: timestamp("renewal_date", { withTimezone: true }),
notes: text("notes"), 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(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}); });
+1 -1
View File
@@ -8,6 +8,6 @@ export const toolRelationsTable = pgTable("tool_relations", {
relatedToolId: integer("related_tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), relatedToolId: integer("related_tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }),
relationType: text("relation_type", { enum: ["similar", "replaces", "superseded_by", "recommended"] }).notNull().default("similar"), relationType: text("relation_type", { enum: ["similar", "replaces", "superseded_by", "recommended"] }).notNull().default("similar"),
notes: text("notes"), 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(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}); });