Second review pass covering security, performance, admin tooling and the public/room flows. All HIGH and MEDIUM findings from the audit are resolved; nothing in this commit changes the visible feature set. Authentication & session security - CSP is now set on the request headers in the proxy, which is what Next.js uses to derive the render nonce, so the nonce is effective. - 2FA: an already-enabled user cannot re-enroll, the setup endpoint is rate-limited per account, and confirmed codes are persisted so the second secret no longer silently never applies. - Password reset revokes the ticket, authTicket and all personal access tokens, and bumps the token version so existing sessions die. The same revocation is now wired into the staff-side password reset. - /reset and /verify return a stable error code instead of raw text; the mail lookups are ordered by id so duplicates cannot vary between runs. - Resending the verification mail gets a per-address cooldown on top of the per-user limit. - Issue API tokens with the narrower radio/ticket ability set instead of "*". Authorization & input handling - Mid-rank staff can no longer keep dynamically granted non-view admin.* permissions: existing grants are revoked by migration and the grant lookup is restricted to "%.view". Rank guards use the dynamic super-admin check. - Alerting a user is permission-checked and audited like the other tools. - Material mutations (giveCredits/giveDuckets/giveDiamonds, the admin user actions route, bulk user actions) are capped and rank-guarded, and bulk ids are bounded. - updateRoom / updateRoomItem write through a field allowlist, and items may only be edited through their own room. - Classnames reaching the filesystem are validated before use so a crafted value cannot escape the asset directories. - The word filter now also covers offline mails, guild forum threads and replies, and user mottos. - Media uploads are validated by magic bytes, /api/media requires the page edit permission, APP_URL must be configured once mail is enabled, and the diagnostics error route checks the fetch site header. Admin tooling - Secret settings render masked and cannot be overwritten with a blank or an arbitrary raw key; radio credentials are new password inputs. - Commandocentrum balance changes are audited. - Admin list pagination reads the caller's per-page instead of the max, and the log exporter caps offset and search length. Performance - Catalog translations are cached per module, with a cheap revision hash; the public online count uses a stale window instead of hammering the DB. - The cache warmup now primes the payload the home route actually reads. - TopHeader batches its queries into one round trip, and LCP avatars load eagerly. - motion/react and sonner are no longer part of the root layout; the nav dropdown and mobile nav panels are lazy client chunks. Anonymous visitors again get the navigation chrome, and public pages get an edge cacheable response. Accessibility - Nested <main> elements in phase pages became <section>; the page entrance and route progress animations are pure CSS that respect reduced motion.
73 lines
1.9 KiB
TypeScript
73 lines
1.9 KiB
TypeScript
import { count, desc } from "drizzle-orm";
|
|
import { apiJson, pagination, publicCacheControl } from "@/lib/api";
|
|
import { CameraWeb, db } from "@/lib/db";
|
|
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
|
|
|
/**
|
|
* GET /api/photos — recent community photos (camera_web), newest first.
|
|
* Mirrors the /photos page query. Returns id, userId, url and timestamp plus
|
|
* pagination metadata.
|
|
*/
|
|
export async function GET(req: Request) {
|
|
const sp = new URL(req.url).searchParams;
|
|
const { page, perPage, skip, take } = pagination(sp);
|
|
|
|
try {
|
|
const data = await redisCache(
|
|
apiCacheKey(`photos:${page}:${perPage}`),
|
|
60,
|
|
async () => {
|
|
const [totalRows, photos] = await Promise.all([
|
|
db.select({ total: count() }).from(CameraWeb),
|
|
db
|
|
.select({
|
|
id: CameraWeb.id,
|
|
userId: CameraWeb.userId,
|
|
url: CameraWeb.url,
|
|
timestamp: CameraWeb.timestamp,
|
|
})
|
|
.from(CameraWeb)
|
|
.orderBy(desc(CameraWeb.timestamp))
|
|
.limit(take)
|
|
.offset(skip),
|
|
]);
|
|
|
|
// Photo privacy is a per-user setting, so the API honours it like
|
|
// the /photos page does.
|
|
const { loadProfilePrivacyMap } = await import(
|
|
"@/lib/services/profile-privacy"
|
|
);
|
|
const photoPrivacy = await loadProfilePrivacyMap(
|
|
photos.map((p) => p.userId),
|
|
);
|
|
|
|
return cacheSafe({
|
|
data: photos.filter(
|
|
(p) => photoPrivacy.get(p.userId)?.photos !== false,
|
|
),
|
|
meta: {
|
|
page,
|
|
perPage,
|
|
total: totalRows[0]?.total ?? 0,
|
|
lastPage: Math.max(
|
|
1,
|
|
Math.ceil((totalRows[0]?.total ?? 0) / perPage),
|
|
),
|
|
},
|
|
});
|
|
},
|
|
{ staleMs: 120_000 },
|
|
);
|
|
|
|
return apiJson(data, {
|
|
headers: { "cache-control": publicCacheControl(60) },
|
|
});
|
|
} catch {
|
|
// DB unavailable — return an empty payload instead of a 500.
|
|
return apiJson(
|
|
{ data: [], meta: { page, perPage, total: 0, lastPage: 1 } },
|
|
{ status: 200 },
|
|
);
|
|
}
|
|
}
|