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
+47 -61
View File
@@ -73,78 +73,64 @@ router.get("/analytics/top-tools", async (req, res): Promise<void> => {
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<number, { usefulness: number; usability: number }[]>();
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<number>`count(${ratingsTable.id})::int`,
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
avgUsability: sql<number | null>`avg(${ratingsTable.usability})`,
score: sql<number>`${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<void> => {
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<number>`count(*)::int` })
.from(toolsTable)
.where(eq(toolsTable.category, category));
const [ratingStats] = await db
.select({
totalRatings: sql<number>`count(*)::int`,
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
avgUsability: sql<number | null>`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<number>`count(distinct ${toolsTable.id})::int`,
totalRatings: sql<number>`count(${ratingsTable.id})::int`,
avgUsefulness: sql<number | null>`avg(${ratingsTable.usefulness})`,
avgUsability: sql<number | null>`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<void> => {