perf: optimize CMS queries, caching, and asset delivery
Database: - Add missing indexes (users.credits, users_currency(type,amount), users_settings.respects_received, camera_web.timestamp, messenger_offline.user_id) via migrations 0020/0021 - Use partial .select() everywhere instead of SELECT * (tickets, users, rooms, audit logs, catalog tree, polls, radio, password reset) - Add queryPrepared/queryPreparedOne (server-side prepared statements) and switch the login check to a prepared statement; drop dead cache options from the pool config - Raise total_users/total_rooms COUNT(*) cache TTL to 5m Caching: - Consolidate the three cache helpers (cached, redisCache, cachedQuery) into a single memory-first implementation backed by Redis - invalidateKey now clears the in-process cache as well as Redis - Cache homepage sections, news list, and leaderboard tabs; share one news_list cache key between homepage and news archive - siteSettings: in-process cache with TTL so repeated getters no longer pay a Redis round-trip per call - Share a 10s poll cache across all radio SSE connections - Normalize timestamps after cache reads (Redis JSON round-trip) Assets: - Enable AVIF/WebP via images.formats and remove unoptimized from news covers and the homepage hero (149KB jpg) with proper sizes/priority - Support ?format=webp|avif|png in the /imaging proxy via sharp Other: - Fix pnpm supply-chain minimumReleaseAge failures by excluding the freshly-published packages (next 16.3.1, hookform resolvers 5.8.0, resend 6.20.0) - Remove unused before/after fields from housekeeping AuditEntry
This commit is contained in:
1 parent
dc9e5a567c
commit
e5ec3c1f06
29 files changed
+532
-322
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { desc, eq, inArray } from "drizzle-orm";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type CurrencyKind,
|
||||
} from "@/components/shared/currency-icon";
|
||||
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User, UsersCurrency, UsersSettings } from "@/lib/db";
|
||||
|
||||
export const metadata = { title: "Leaderboard" };
|
||||
@@ -43,15 +44,17 @@ type Row = { username: string; look: string; value: number };
|
||||
|
||||
async function loadCreditsRows(): Promise<Row[]> {
|
||||
try {
|
||||
const users = await db
|
||||
.select({
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
credits: User.credits,
|
||||
})
|
||||
.from(User)
|
||||
.orderBy(desc(User.credits))
|
||||
.limit(20);
|
||||
const users = await cached("lb_credits", 60_000, () =>
|
||||
db
|
||||
.select({
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
credits: User.credits,
|
||||
})
|
||||
.from(User)
|
||||
.orderBy(desc(User.credits))
|
||||
.limit(20),
|
||||
);
|
||||
return users.map((u) => ({
|
||||
username: u.username,
|
||||
look: u.look,
|
||||
@@ -64,35 +67,19 @@ async function loadCreditsRows(): Promise<Row[]> {
|
||||
|
||||
async function loadCurrencyRows(type: number): Promise<Row[]> {
|
||||
try {
|
||||
const top = await db
|
||||
.select({
|
||||
userId: UsersCurrency.userId,
|
||||
amount: UsersCurrency.amount,
|
||||
})
|
||||
.from(UsersCurrency)
|
||||
.where(eq(UsersCurrency.type, type))
|
||||
.orderBy(desc(UsersCurrency.amount))
|
||||
.limit(20);
|
||||
if (top.length === 0) return [];
|
||||
|
||||
const users = await db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(
|
||||
inArray(
|
||||
User.id,
|
||||
top.map((t) => t.userId),
|
||||
),
|
||||
);
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return top
|
||||
.map((t) => {
|
||||
const u = byId.get(t.userId);
|
||||
if (!u) return null;
|
||||
return { username: u.username, look: u.look, value: t.amount };
|
||||
})
|
||||
.filter((r): r is Row => r !== null);
|
||||
return await cached(`lb_currency_${type}`, 60_000, () =>
|
||||
db
|
||||
.select({
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
value: UsersCurrency.amount,
|
||||
})
|
||||
.from(UsersCurrency)
|
||||
.innerJoin(User, eq(UsersCurrency.userId, User.id))
|
||||
.where(eq(UsersCurrency.type, type))
|
||||
.orderBy(desc(UsersCurrency.amount))
|
||||
.limit(20),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -103,38 +90,18 @@ async function loadSettingsRows(
|
||||
): Promise<Row[]> {
|
||||
try {
|
||||
const column = UsersSettings[field];
|
||||
const top = await db
|
||||
.select({
|
||||
userId: UsersSettings.userId,
|
||||
value: column,
|
||||
})
|
||||
.from(UsersSettings)
|
||||
.orderBy(desc(column))
|
||||
.limit(20);
|
||||
if (top.length === 0) return [];
|
||||
|
||||
const users = await db
|
||||
.select({ id: User.id, username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(
|
||||
inArray(
|
||||
User.id,
|
||||
top.map((t) => t.userId),
|
||||
),
|
||||
);
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
return top
|
||||
.map((t) => {
|
||||
const u = byId.get(t.userId);
|
||||
if (!u) return null;
|
||||
return {
|
||||
username: u.username,
|
||||
look: u.look,
|
||||
value: Number(t.value ?? 0),
|
||||
};
|
||||
})
|
||||
.filter((r): r is Row => r !== null);
|
||||
return await cached(`lb_settings_${field}`, 60_000, () =>
|
||||
db
|
||||
.select({
|
||||
username: User.username,
|
||||
look: User.look,
|
||||
value: column,
|
||||
})
|
||||
.from(UsersSettings)
|
||||
.innerJoin(User, eq(UsersSettings.userId, User.id))
|
||||
.orderBy(desc(column))
|
||||
.limit(20),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
+16
-33
@@ -18,7 +18,6 @@ import {
|
||||
UsersBadges,
|
||||
UsersSettings,
|
||||
WebsiteLoginLogs,
|
||||
WebsiteSetting,
|
||||
} from "@/lib/db";
|
||||
import { avatarImageUrl } from "@/lib/format";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
@@ -85,9 +84,9 @@ export default async function MePage({
|
||||
const [
|
||||
userRows,
|
||||
hotelName,
|
||||
neededRows,
|
||||
amountRows,
|
||||
currencyRows,
|
||||
neededRaw,
|
||||
rewardAmountRaw,
|
||||
currencySettings,
|
||||
alertRaw,
|
||||
recentRooms,
|
||||
badges,
|
||||
@@ -112,29 +111,12 @@ export default async function MePage({
|
||||
.where(eq(User.id, userId))
|
||||
.limit(1),
|
||||
resolveHotelName(),
|
||||
db
|
||||
.select({ value: WebsiteSetting.value })
|
||||
.from(WebsiteSetting)
|
||||
.where(eq(WebsiteSetting.key, "referrals_needed"))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ value: WebsiteSetting.value })
|
||||
.from(WebsiteSetting)
|
||||
.where(eq(WebsiteSetting.key, "referral_reward_amount"))
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ value: WebsiteSetting.value })
|
||||
.from(WebsiteSetting)
|
||||
.where(
|
||||
inArray(WebsiteSetting.key, [
|
||||
"referral_reward_currency_type",
|
||||
"referral_reward_currency",
|
||||
]),
|
||||
)
|
||||
.limit(1)
|
||||
.catch(() => []),
|
||||
siteSettings.get("referrals_needed", "5"),
|
||||
siteSettings.get("referral_reward_amount", "30"),
|
||||
siteSettings.getMany([
|
||||
"referral_reward_currency_type",
|
||||
"referral_reward_currency",
|
||||
]),
|
||||
siteSettings.get("hotel_alert_banner", ""),
|
||||
db
|
||||
.select({
|
||||
@@ -191,9 +173,6 @@ export default async function MePage({
|
||||
]);
|
||||
|
||||
const user = userRows[0] ?? null;
|
||||
const neededRaw = neededRows[0] ?? null;
|
||||
const amountRaw = amountRows[0] ?? null;
|
||||
const currencyRaw = currencyRows[0] ?? null;
|
||||
const lastWebLogin = lastWebLoginRows[0] ?? null;
|
||||
const userSettings = userSettingsRows[0] ?? null;
|
||||
const friendCount = Number(friendCountRows[0]?.value ?? 0);
|
||||
@@ -203,9 +182,13 @@ export default async function MePage({
|
||||
|
||||
if (!user) throw new Error("user-not-found");
|
||||
|
||||
const needed = Number.parseInt(neededRaw?.value ?? "5", 10) || 5;
|
||||
const rewardAmount = Number.parseInt(amountRaw?.value ?? "30", 10) || 0;
|
||||
const rewardCurrency = (currencyRaw?.value ?? "diamonds")
|
||||
const needed = Number.parseInt(neededRaw ?? "5", 10) || 5;
|
||||
const rewardAmount = Number.parseInt(rewardAmountRaw ?? "30", 10) || 0;
|
||||
const rewardCurrency = (
|
||||
currencySettings.referral_reward_currency_type ??
|
||||
currencySettings.referral_reward_currency ??
|
||||
"diamonds"
|
||||
)
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
|
||||
@@ -228,6 +228,7 @@ export default async function ArticlePage({
|
||||
<img
|
||||
src={article.image}
|
||||
alt=""
|
||||
decoding="async"
|
||||
style={{ width: "100%", borderRadius: 10, margin: "0 0 1rem" }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -1,28 +1,17 @@
|
||||
import { desc } from "drizzle-orm";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ContentCard, EmptyState } from "@/components/public/ui";
|
||||
import { db, WebsiteArticles } from "@/lib/db";
|
||||
import { excerpt } from "@/lib/format";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
|
||||
export const metadata = { title: "News" };
|
||||
|
||||
export default async function NewsPage() {
|
||||
const t = await getTranslations("pages.news");
|
||||
|
||||
let articles: (typeof WebsiteArticles.$inferSelect)[] = [];
|
||||
try {
|
||||
articles = await db
|
||||
.select()
|
||||
.from(WebsiteArticles)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(30);
|
||||
} catch {
|
||||
// DB unavailable — render the empty state rather than a 500.
|
||||
articles = [];
|
||||
}
|
||||
const articles = await getNewsList(30);
|
||||
|
||||
return (
|
||||
<main style={{ display: "grid", gap: "1.5rem" }}>
|
||||
@@ -58,8 +47,8 @@ export default async function NewsPage() {
|
||||
src={a.image}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 1024px) 50vw, 300px"
|
||||
style={{ objectFit: "cover" }}
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
+35
-30
@@ -15,10 +15,11 @@ import { ThemeSwitcher } from "@/components/theme-switcher";
|
||||
import { TypewriterText } from "@/components/typewriter-text";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { CameraWeb, db, Rooms, User, WebsiteArticles } from "@/lib/db";
|
||||
import { CameraWeb, db, Rooms, User } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { captchaConfig } from "@/lib/services/captcha";
|
||||
import { getNewsList } from "@/lib/services/news-list";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
async function getHotelData() {
|
||||
@@ -43,42 +44,40 @@ async function getHotelData() {
|
||||
.where(eq(User.online, "1"))
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
cached("total_users", 30_000, () =>
|
||||
cached("total_users", 300_000, () =>
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
cached("total_rooms", 30_000, () =>
|
||||
cached("total_rooms", 300_000, () =>
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(Rooms)
|
||||
.then((rows) => rows[0]?.total ?? 0),
|
||||
).catch(() => 0),
|
||||
db
|
||||
.select()
|
||||
.from(WebsiteArticles)
|
||||
.orderBy(desc(WebsiteArticles.createdAt))
|
||||
.limit(5)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(30)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select()
|
||||
.from(CameraWeb)
|
||||
.orderBy(desc(CameraWeb.timestamp))
|
||||
.limit(4)
|
||||
.catch(() => []),
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8)
|
||||
.catch(() => []),
|
||||
getNewsList(4),
|
||||
cached("home_online_users", 15_000, () =>
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"))
|
||||
.limit(30),
|
||||
).catch(() => []),
|
||||
cached("home_recent_photos", 60_000, () =>
|
||||
db
|
||||
.select({ id: CameraWeb.id, url: CameraWeb.url })
|
||||
.from(CameraWeb)
|
||||
.orderBy(desc(CameraWeb.timestamp))
|
||||
.limit(4),
|
||||
).catch(() => []),
|
||||
cached("home_latest_users", 60_000, () =>
|
||||
db
|
||||
.select({ username: User.username, look: User.look })
|
||||
.from(User)
|
||||
.orderBy(desc(User.accountCreated))
|
||||
.limit(8),
|
||||
).catch(() => []),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -191,12 +190,18 @@ export default async function Home() {
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl border"
|
||||
style={{
|
||||
background: `url(/assets/images/next_header.jpg) center/cover no-repeat`,
|
||||
backgroundPosition: "center 20%",
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src="/assets/images/next_header.jpg"
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="100vw"
|
||||
style={{ objectFit: "cover", objectPosition: "center 20%" }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
@@ -556,8 +561,8 @@ export default async function Home() {
|
||||
src={a.image}
|
||||
alt={a.title}
|
||||
fill
|
||||
sizes="(max-width: 768px) 100vw, 400px"
|
||||
className="rounded-lg object-cover transition-all duration-300 group-hover:scale-105"
|
||||
unoptimized
|
||||
/>
|
||||
<div className="absolute top-2.5 right-2.5 w-8 h-8 rounded-full flex items-center justify-center bg-black/50 backdrop-blur-sm transition-all duration-300 opacity-0 group-hover:opacity-100 translate-x-1 group-hover:translate-x-0">
|
||||
<span className="text-white text-sm font-bold">
|
||||
|
||||
@@ -81,8 +81,6 @@ interface AuditEntry {
|
||||
action: string;
|
||||
target: string;
|
||||
targetId: number | null;
|
||||
before: unknown;
|
||||
after: unknown;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,18 @@ export async function loadRoom(id: string) {
|
||||
if (Number.isNaN(roomId)) notFound();
|
||||
|
||||
const [room] = await db
|
||||
.select()
|
||||
.select({
|
||||
id: Rooms.id,
|
||||
ownerId: Rooms.ownerId,
|
||||
ownerName: Rooms.ownerName,
|
||||
name: Rooms.name,
|
||||
description: Rooms.description,
|
||||
state: Rooms.state,
|
||||
users: Rooms.users,
|
||||
usersMax: Rooms.usersMax,
|
||||
category: Rooms.category,
|
||||
score: Rooms.score,
|
||||
})
|
||||
.from(Rooms)
|
||||
.where(eq(Rooms.id, roomId))
|
||||
.limit(1);
|
||||
|
||||
@@ -21,7 +21,24 @@ export async function loadUserById(id: string) {
|
||||
numericId > 0 &&
|
||||
String(numericId) === id;
|
||||
const [user] = await db
|
||||
.select()
|
||||
.select({
|
||||
id: User.id,
|
||||
username: User.username,
|
||||
mail: User.mail,
|
||||
gender: User.gender,
|
||||
machineId: User.machineId,
|
||||
twoFactorConfirmedAt: User.twoFactorConfirmedAt,
|
||||
rank: User.rank,
|
||||
online: User.online,
|
||||
motto: User.motto,
|
||||
look: User.look,
|
||||
credits: User.credits,
|
||||
pixels: User.pixels,
|
||||
accountCreated: User.accountCreated,
|
||||
lastLogin: User.lastLogin,
|
||||
ipRegister: User.ipRegister,
|
||||
ipCurrent: User.ipCurrent,
|
||||
})
|
||||
.from(User)
|
||||
.where(isNumeric ? eq(User.id, numericId) : eq(User.username, id))
|
||||
.limit(1);
|
||||
@@ -40,7 +57,10 @@ export async function loadUserById(id: string) {
|
||||
] = await Promise.all([
|
||||
db.select().from(UsersCurrency).where(eq(UsersCurrency.userId, user.id)),
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: UsersBadges.id,
|
||||
badgeCode: UsersBadges.badgeCode,
|
||||
})
|
||||
.from(UsersBadges)
|
||||
.where(eq(UsersBadges.userId, user.id))
|
||||
.orderBy(asc(UsersBadges.slotId)),
|
||||
|
||||
@@ -27,7 +27,20 @@ type PageRow = {
|
||||
|
||||
async function getBcTreeFlat(): Promise<TreeNode[]> {
|
||||
const [allPages, itemCounts] = await Promise.all([
|
||||
db.select().from(CatalogPagesBc).orderBy(asc(CatalogPagesBc.orderNum)),
|
||||
db
|
||||
.select({
|
||||
id: CatalogPagesBc.id,
|
||||
parentId: CatalogPagesBc.parentId,
|
||||
caption: CatalogPagesBc.caption,
|
||||
pageLayout: CatalogPagesBc.pageLayout,
|
||||
iconColor: CatalogPagesBc.iconColor,
|
||||
iconImage: CatalogPagesBc.iconImage,
|
||||
orderNum: CatalogPagesBc.orderNum,
|
||||
visible: CatalogPagesBc.visible,
|
||||
enabled: CatalogPagesBc.enabled,
|
||||
})
|
||||
.from(CatalogPagesBc)
|
||||
.orderBy(asc(CatalogPagesBc.orderNum)),
|
||||
db
|
||||
.select({
|
||||
pageId: CatalogItemsBc.pageId,
|
||||
@@ -108,7 +121,17 @@ async function getChildren(
|
||||
|
||||
if (isBc) {
|
||||
const pages = await db
|
||||
.select()
|
||||
.select({
|
||||
id: CatalogPagesBc.id,
|
||||
parentId: CatalogPagesBc.parentId,
|
||||
caption: CatalogPagesBc.caption,
|
||||
pageLayout: CatalogPagesBc.pageLayout,
|
||||
iconColor: CatalogPagesBc.iconColor,
|
||||
iconImage: CatalogPagesBc.iconImage,
|
||||
orderNum: CatalogPagesBc.orderNum,
|
||||
visible: CatalogPagesBc.visible,
|
||||
enabled: CatalogPagesBc.enabled,
|
||||
})
|
||||
.from(CatalogPagesBc)
|
||||
.where(parentFilterBc)
|
||||
.orderBy(asc(CatalogPagesBc.orderNum));
|
||||
@@ -163,7 +186,17 @@ async function getChildren(
|
||||
}
|
||||
|
||||
const pages = await db
|
||||
.select()
|
||||
.select({
|
||||
id: CatalogPages.id,
|
||||
parentId: CatalogPages.parentId,
|
||||
caption: CatalogPages.caption,
|
||||
pageLayout: CatalogPages.pageLayout,
|
||||
iconColor: CatalogPages.iconColor,
|
||||
iconImage: CatalogPages.iconImage,
|
||||
orderNum: CatalogPages.orderNum,
|
||||
visible: CatalogPages.visible,
|
||||
enabled: CatalogPages.enabled,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(parentFilter)
|
||||
.orderBy(asc(CatalogPages.orderNum));
|
||||
@@ -213,7 +246,17 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
|
||||
const conditions = [like(CatalogPagesBc.caption, `%${needle}%`)];
|
||||
if (idOk) conditions.push(eq(CatalogPagesBc.id, idExact));
|
||||
const pages = await db
|
||||
.select()
|
||||
.select({
|
||||
id: CatalogPagesBc.id,
|
||||
parentId: CatalogPagesBc.parentId,
|
||||
caption: CatalogPagesBc.caption,
|
||||
pageLayout: CatalogPagesBc.pageLayout,
|
||||
iconColor: CatalogPagesBc.iconColor,
|
||||
iconImage: CatalogPagesBc.iconImage,
|
||||
orderNum: CatalogPagesBc.orderNum,
|
||||
visible: CatalogPagesBc.visible,
|
||||
enabled: CatalogPagesBc.enabled,
|
||||
})
|
||||
.from(CatalogPagesBc)
|
||||
.where(or(...conditions))
|
||||
.orderBy(asc(CatalogPagesBc.orderNum))
|
||||
@@ -240,7 +283,17 @@ async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
|
||||
const conditions = [like(CatalogPages.caption, `%${needle}%`)];
|
||||
if (idOk) conditions.push(eq(CatalogPages.id, idExact));
|
||||
const pages = await db
|
||||
.select()
|
||||
.select({
|
||||
id: CatalogPages.id,
|
||||
parentId: CatalogPages.parentId,
|
||||
caption: CatalogPages.caption,
|
||||
pageLayout: CatalogPages.pageLayout,
|
||||
iconColor: CatalogPages.iconColor,
|
||||
iconImage: CatalogPages.iconImage,
|
||||
orderNum: CatalogPages.orderNum,
|
||||
visible: CatalogPages.visible,
|
||||
enabled: CatalogPages.enabled,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.where(or(...conditions))
|
||||
.orderBy(asc(CatalogPages.orderNum))
|
||||
|
||||
@@ -13,7 +13,12 @@ const MAX_MESSAGE_LENGTH = 255;
|
||||
export async function GET(_req: Request) {
|
||||
try {
|
||||
const shouts = await db
|
||||
.select()
|
||||
.select({
|
||||
id: RadioShouts.id,
|
||||
userId: RadioShouts.userId,
|
||||
message: RadioShouts.message,
|
||||
createdAt: RadioShouts.createdAt,
|
||||
})
|
||||
.from(RadioShouts)
|
||||
.orderBy(desc(RadioShouts.createdAt))
|
||||
.limit(50);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import sharp from "sharp";
|
||||
import { resolveImagerBase } from "@/lib/imager";
|
||||
|
||||
const FIGURE_RE = /^([a-z]{2}-\d+)(\.[a-z]{2}-\d+)*$/i;
|
||||
@@ -6,6 +7,19 @@ const FIGURE_MAX_LEN = 512;
|
||||
const FIGURE_MAX_PARTS = 24;
|
||||
const UPSTREAM_TIMEOUT_MS = 10_000;
|
||||
|
||||
type AvatarFormat = "png" | "webp" | "avif";
|
||||
|
||||
function resolveFormat(raw: string | null): AvatarFormat {
|
||||
switch (raw?.toLowerCase()) {
|
||||
case "webp":
|
||||
return "webp";
|
||||
case "avif":
|
||||
return "avif";
|
||||
default:
|
||||
return "png";
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
@@ -66,6 +80,8 @@ export async function GET(request: NextRequest) {
|
||||
const imgFormat = searchParams.get("img_format");
|
||||
if (imgFormat) params.set("img_format", imgFormat);
|
||||
|
||||
const format = resolveFormat(searchParams.get("format"));
|
||||
|
||||
const upstream = resolveImagerBase();
|
||||
const upstreamUrl = `${upstream}?${params.toString()}`;
|
||||
|
||||
@@ -85,15 +101,35 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
const contentType = res.headers.get("content-type") || "image/png";
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
// Re-encode PNG avatars to WebP/AVIF with sharp to cut bandwidth.
|
||||
// Falls back to the original PNG if conversion fails.
|
||||
let body: Uint8Array = new Uint8Array(buffer);
|
||||
let contentType = res.headers.get("content-type") || "image/png";
|
||||
if (format !== "png") {
|
||||
try {
|
||||
if (format === "webp") {
|
||||
body = new Uint8Array(await sharp(buffer).webp().toBuffer());
|
||||
contentType = "image/webp";
|
||||
} else {
|
||||
body = new Uint8Array(await sharp(buffer).avif().toBuffer());
|
||||
contentType = "image/avif";
|
||||
}
|
||||
} catch {
|
||||
body = new Uint8Array(buffer);
|
||||
contentType = res.headers.get("content-type") || "image/png";
|
||||
}
|
||||
}
|
||||
|
||||
return new NextResponse(body as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(body.length),
|
||||
// s-maxage lets Cloudflare/edge cache avatars (currently DYNAMIC),
|
||||
// stale-while-revalidate keeps them fresh without blocking requests.
|
||||
// The ?format= param is part of the URL, so variants cache separately.
|
||||
"Cache-Control":
|
||||
"public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
|
||||
Reference in new issue
Block a user