Files
petfeed/src/trpc/routers/explore.ts
T
admin f43541a4cc feat(security): add Redis-backed rate limiting and CSP/security headers
Public go-live hardening: Redis-backed rate limiting on the previously
invite-gated public surface (explore browsing, post/comment creation,
invite-cookie routes) plus CSP, HSTS, and standard security headers on
every response, now that the invite gate no longer filters traffic.
2026-08-10 07:57:56 +02:00

85 lines
3.0 KiB
TypeScript

import "server-only";
import { z } from "zod";
import { router, publicProcedure, rateLimited } from "@/trpc/init";
import { pickLocalizedName } from "@/lib/localized-name";
const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h
const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
export const exploreRouter = router({
listPets: publicProcedure
.use(rateLimited("explore.listPets", 60, 60))
.input(
z.object({
speciesId: z.string().optional(),
breedId: z.string().optional(),
limit: z.number().int().min(1).max(40).default(20),
cursor: z.string().optional(), // petId cursor
})
)
.query(async ({ ctx, input }) => {
const pets = await ctx.prisma.pet.findMany({
where: {
...(input.speciesId ? { speciesId: input.speciesId } : {}),
...(input.breedId ? { breedId: input.breedId } : {}),
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
include: {
species: { include: { translations: { where: { locale: ctx.locale } } } },
breed: { include: { translations: { where: { locale: ctx.locale } } } },
_count: { select: { followers: true } },
},
});
const hasNextPage = pets.length > input.limit;
const items = (hasNextPage ? pets.slice(0, input.limit) : pets).map((pet) => ({
...pet,
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
breed: pet.breed
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
: pet.breed,
}));
return {
pets: items,
nextCursor: hasNextPage ? items[items.length - 1]!.id : null,
};
}),
/**
* getTrending — trending posts (48 h window, ranked by reaction count)
* and trending hashtags (7 day window, ranked by post count).
*/
getTrending: publicProcedure
.use(rateLimited("explore.getTrending", 30, 60))
.query(async ({ ctx }) => {
const now = Date.now();
const since48h = new Date(now - TRENDING_POSTS_WINDOW_MS);
const since7d = new Date(now - TRENDING_HASHTAGS_WINDOW_MS);
const [posts, hashtags] = await Promise.all([
ctx.prisma.post.findMany({
where: { createdAt: { gte: since48h }, type: "PHOTO" },
include: {
images: { orderBy: { position: "asc" } },
pet: { select: { id: true, name: true, avatarKey: true } },
milestone: true,
},
orderBy: { reactionCount: "desc" },
take: 12,
}),
ctx.prisma.hashtag.findMany({
where: {
posts: { some: { post: { createdAt: { gte: since7d } } } },
},
include: { _count: { select: { posts: true } } },
orderBy: { posts: { _count: "desc" } },
take: 10,
}),
]);
return { posts, hashtags };
}),
});