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.
85 lines
3.0 KiB
TypeScript
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 };
|
|
}),
|
|
});
|