fix: security hardening, validation, cache and analytics fixes
Build & Push Docker Image / build (push) Successful in 4m32s
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:
@@ -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> => {
|
||||
|
||||
Reference in New Issue
Block a user