Improvements: remove dead config, fix ESM, add URL validation, unify types, add missing logging
- Remove .prettierrc (dead config, Biome replaces Prettier) - Rename lighthouserc.json to lighthouserc.cjs with module.exports for ESM compat - Add logger.warn to empty catch blocks in auth, register, site-settings, prisma-cache, redis, security, rate-limit - Unify ActionResult type: action-helper.ts uses 'ok' consistent with safe-action-shared.ts - Add noUnusedLocals + noUnusedParameters to tsconfig + fix 25 pre-existing unused vars - Replace barrel export src/types/index.ts with direct @/types/common imports - Make trustHost conditional (development only) in auth.ts - Add pre-flight URL validation to update-Nitrov3.sh to catch image.library.url misconfigurations - Improve NITRO_IMAGE_LIBRARY_URL content validation in pre-flight & post-compute checks
This commit is contained in:
1 parent
b922f6d49f
commit
17847545dd
292 files changed
+69195
-67915
No files matched your search
@@ -5,11 +5,11 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { adminAction } from "@/lib/safe-action";
|
||||
import { ActionError, actionOk } from "@/lib/safe-action-shared";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
// CRUD for website advertisements (website_ads). Emulator does not own this
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function dismissApplication(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
async function uniqueSlug(title: string): Promise<string> {
|
||||
@@ -74,9 +74,7 @@ export async function updateArticle(formData: FormData): Promise<void> {
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
.slice(0, 255),
|
||||
...(rawSlug
|
||||
? { slug: await uniqueSlug(rawSlug) }
|
||||
: {}),
|
||||
...(rawSlug ? { slug: await uniqueSlug(rawSlug) } : {}),
|
||||
shortStory: String(formData.get("shortStory") ?? "")
|
||||
.normalize("NFC")
|
||||
.trim()
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createEmailTemplate(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { canonicalize, sanitizeField } from "@/lib/foundation/security";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -26,8 +26,10 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
|
||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
const buttonColor =
|
||||
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
|
||||
try {
|
||||
const entry = await prisma.websiteHelpCenterCategories.create({
|
||||
@@ -73,8 +75,10 @@ export async function updateHelpQuestion(formData: FormData): Promise<void> {
|
||||
const imageUrl = sanitizeField(formData.get("imageUrl"));
|
||||
const buttonText = sanitizeField(formData.get("buttonText"));
|
||||
const buttonUrl = sanitizeField(formData.get("buttonUrl"));
|
||||
const buttonColor = sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||
const buttonBorderColor = sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
const buttonColor =
|
||||
sanitizeField(formData.get("buttonColor"), 16) || "#eeb425";
|
||||
const buttonBorderColor =
|
||||
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
|
||||
|
||||
try {
|
||||
await prisma.websiteHelpCenterCategories.update({
|
||||
|
||||
@@ -4,8 +4,8 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { MEDIA_ROOT, resolveMediaPath } from "@/lib/media-storage";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const ALLOWED = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
@@ -19,7 +19,10 @@ export async function uploadMedia(
|
||||
if (file.size > MAX_SIZE)
|
||||
return { ok: false, error: "File too large (max 5MB)" };
|
||||
if (!ALLOWED.includes(file.type))
|
||||
return { ok: false, error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP" };
|
||||
return {
|
||||
ok: false,
|
||||
error: "Invalid file type. Allowed: PNG, JPEG, GIF, WebP",
|
||||
};
|
||||
|
||||
const baseDir = MEDIA_ROOT;
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function createCategory(formData: FormData): Promise<void> {
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "@/lib/habbo-gamedata-hotel";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||
import { clearHabboItCache } from "@/lib/services/habbo-furnidata-cache";
|
||||
import { clearBadgeCache } from "@/lib/services/habboassets";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
const managedKeySet = new Set(MANAGED_SETTING_KEYS);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { formPositiveBigInt } from "@/lib/form-data";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||
import {
|
||||
deleteCustomThemeStore,
|
||||
getCustomTheme,
|
||||
@@ -15,7 +16,6 @@ import {
|
||||
} from "@/lib/theme-custom-store";
|
||||
import { FONTS, PRESETS, THEME_COLOR_KEYS } from "@/lib/theme-presets";
|
||||
import { presetSettings, settingKey } from "@/lib/theme-settings";
|
||||
import { ensureReadableThemeColors } from "@/lib/theme-contrast";
|
||||
|
||||
// Only hex/keyword colour values are accepted (matches ThemeVars' sanitiser).
|
||||
const COLOR_RE = /^[#a-zA-Z0-9(),.\s%-]+$/;
|
||||
@@ -46,7 +46,10 @@ export async function saveTheme(formData: FormData): Promise<void> {
|
||||
}
|
||||
const fixed = ensureReadableThemeColors(bag);
|
||||
for (const [key, value] of Object.entries(fixed)) {
|
||||
await writeSetting(settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode), value);
|
||||
await writeSetting(
|
||||
settingKey(key as (typeof THEME_COLOR_KEYS)[number], mode),
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
const ADMIN_KEYS = [
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
type ActionResult,
|
||||
actionError,
|
||||
actionOk,
|
||||
type ActionResult,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { logServerError } from "@/lib/server-log";
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { requirePermission } from "@/lib/admin/guard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
type ActionResult,
|
||||
actionError,
|
||||
actionOk,
|
||||
type ActionResult,
|
||||
} from "@/lib/safe-action-shared";
|
||||
import { reloadWordFilter } from "@/lib/services/moderation";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
@@ -33,9 +33,7 @@ export async function addWord(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteWord(input: {
|
||||
id: string;
|
||||
}): Promise<ActionResult> {
|
||||
export async function deleteWord(input: { id: string }): Promise<ActionResult> {
|
||||
await requirePermission(PERMS.WORDFILTER_EDIT);
|
||||
const raw = String(input.id ?? "").normalize("NFC");
|
||||
if (!raw) return actionError("Missing word id");
|
||||
|
||||
+50
-48
@@ -64,63 +64,65 @@ export async function buyBadge(formData: FormData): Promise<void> {
|
||||
if (!(await rateLimit(`draw-badge-buy:${userId}`, 5, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
});
|
||||
const badge = await prisma.websiteDrawbadges.findUnique({
|
||||
where: { id: BigInt(rawId) },
|
||||
select: { id: true, badgePath: true, published: true },
|
||||
});
|
||||
|
||||
if (!badge?.published) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const code = badgeCodeFromPath(badge.badgePath);
|
||||
if (code.length === 0) {
|
||||
if (!badge?.published) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const price = await resolvePrice();
|
||||
|
||||
// Re-read the buyer's live credit balance and verify it covers the cost.
|
||||
const buyer = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
const code = badgeCodeFromPath(badge.badgePath);
|
||||
if (code.length === 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
// Atomically deduct credits and persist the badge so a failure
|
||||
// between the two operations cannot orphan the user.
|
||||
if (price > 0) {
|
||||
await prisma.$transaction(async (tx: Prisma.TransactionClient) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
const price = await resolvePrice();
|
||||
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
});
|
||||
// Re-read the buyer's live credit balance and verify it covers the cost.
|
||||
const buyer = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
if (!buyer || buyer.credits < price) {
|
||||
outcome = "credits";
|
||||
} else {
|
||||
// Atomically deduct credits and persist the badge so a failure
|
||||
// between the two operations cannot orphan the user.
|
||||
if (price > 0) {
|
||||
await prisma.$transaction(
|
||||
async (tx: Prisma.TransactionClient) => {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { decrement: price } },
|
||||
});
|
||||
|
||||
const existing = await tx.usersBadges.findFirst({
|
||||
where: { userId, badgeCode: code },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!existing) {
|
||||
const max = await tx.usersBadges.aggregate({
|
||||
where: { userId },
|
||||
_max: { slotId: true },
|
||||
});
|
||||
const slotId = (max._max.slotId ?? 0) + 1;
|
||||
await tx.usersBadges.create({
|
||||
data: { userId, slotId, badgeCode: code },
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
}
|
||||
|
||||
// Grant the badge live so it appears immediately for online users.
|
||||
await rcon.giveBadge(userId, code).catch(() => {});
|
||||
|
||||
outcome = "bought";
|
||||
boughtCode = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
outcome = "fail";
|
||||
}
|
||||
|
||||
@@ -53,9 +53,9 @@ describe("email verification tokens", () => {
|
||||
|
||||
it("rejects legacy forever-valid digests", async () => {
|
||||
const legacy = "a".repeat(64);
|
||||
expect(
|
||||
await isValidVerificationToken("[email protected]", legacy),
|
||||
).toBe(false);
|
||||
expect(await isValidVerificationToken("[email protected]", legacy)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects expired tokens", async () => {
|
||||
@@ -69,18 +69,19 @@ describe("email verification tokens", () => {
|
||||
});
|
||||
|
||||
it("sends mail with a verify link", async () => {
|
||||
mockGetTranslations.mockResolvedValue(
|
||||
((key: string, values?: { hotel?: string }) => {
|
||||
const map: Record<string, string> = {
|
||||
subject: `Verify your email · ${values?.hotel}`,
|
||||
heading: "Verify your email",
|
||||
body: `Welcome to ${values?.hotel}!`,
|
||||
button: "Verify email",
|
||||
fallback: "Paste this link:",
|
||||
};
|
||||
return map[key] ?? key;
|
||||
}) as never,
|
||||
);
|
||||
mockGetTranslations.mockResolvedValue(((
|
||||
key: string,
|
||||
values?: { hotel?: string },
|
||||
) => {
|
||||
const map: Record<string, string> = {
|
||||
subject: `Verify your email · ${values?.hotel}`,
|
||||
heading: "Verify your email",
|
||||
body: `Welcome to ${values?.hotel}!`,
|
||||
button: "Verify email",
|
||||
fallback: "Paste this link:",
|
||||
};
|
||||
return map[key] ?? key;
|
||||
}) as never);
|
||||
|
||||
await sendVerification("[email protected]");
|
||||
expect(mockSendMail).toHaveBeenCalledWith(
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { positiveBigInt } from "@/lib/api";
|
||||
import { moderateOrThrow } from "@/lib/services/moderation";
|
||||
import { createOwnedTicketReply } from "@/lib/services/ticket-replies";
|
||||
|
||||
|
||||
+96
-92
@@ -3,8 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
|
||||
type FriendOutcome =
|
||||
| "accepted"
|
||||
@@ -71,68 +71,72 @@ export async function acceptFriend(formData: FormData): Promise<void> {
|
||||
if (!(await rateLimit(`friend-accept:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const friendId = request.userFromId;
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
// Malformed/self request — clear it and treat as not found.
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
where: { id: requestId },
|
||||
});
|
||||
// The request must exist AND be addressed to the session user.
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userFromId: true, userToId: true },
|
||||
});
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
} else {
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
const friendId = request.userFromId;
|
||||
if (
|
||||
!Number.isInteger(friendId) ||
|
||||
friendId <= 0 ||
|
||||
friendId === meId
|
||||
) {
|
||||
// Malformed/self request — clear it and treat as not found.
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
where: { id: requestId },
|
||||
});
|
||||
outcome = "not_found";
|
||||
} else {
|
||||
const friendsSince = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Don't double-insert if a friendship already exists in either direction.
|
||||
const existing = await tx.messengerFriendships.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
// Clear this request and any reverse pending request between the pair.
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: requestId },
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
if (!existing) {
|
||||
await tx.messengerFriendships.createMany({
|
||||
data: [
|
||||
{ userOneId: meId, userTwoId: friendId, friendsSince },
|
||||
{ userOneId: friendId, userTwoId: meId, friendsSince },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Clear this request and any reverse pending request between the pair.
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ id: requestId },
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
outcome = "accepted";
|
||||
outcome = "accepted";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// redirect() throws a NEXT_REDIRECT control-flow signal — re-throw it.
|
||||
if (
|
||||
@@ -172,26 +176,26 @@ export async function declineFriendRequest(formData: FormData): Promise<void> {
|
||||
if (!(await rateLimit(`friend-decline:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userToId: true },
|
||||
});
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
const requestId = Number(formData.get("requestId"));
|
||||
if (!Number.isInteger(requestId) || requestId <= 0) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
const request = await prisma.messengerFriendrequests.findUnique({
|
||||
where: { id: requestId },
|
||||
select: { id: true, userToId: true },
|
||||
});
|
||||
outcome = "declined";
|
||||
if (!request) {
|
||||
outcome = "not_found";
|
||||
} else if (request.userToId !== meId) {
|
||||
outcome = "unauthorized";
|
||||
} else {
|
||||
await prisma.messengerFriendrequests.delete({
|
||||
where: { id: requestId },
|
||||
});
|
||||
outcome = "declined";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
e &&
|
||||
@@ -231,34 +235,34 @@ export async function removeFriendship(formData: FormData): Promise<void> {
|
||||
if (!(await rateLimit(`friend-remove:${meId}`, 10, 60_000)).ok) {
|
||||
outcome = "ratelimit";
|
||||
} else {
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const deleted = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.messengerFriendships.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
const friendId = Number(formData.get("friendId"));
|
||||
if (!Number.isInteger(friendId) || friendId <= 0 || friendId === meId) {
|
||||
outcome = "invalid";
|
||||
} else {
|
||||
const deleted = await prisma.$transaction(async (tx) => {
|
||||
const result = await tx.messengerFriendships.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userOneId: meId, userTwoId: friendId },
|
||||
{ userOneId: friendId, userTwoId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
});
|
||||
|
||||
await tx.messengerFriendrequests.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userFromId: meId, userToId: friendId },
|
||||
{ userFromId: friendId, userToId: meId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
});
|
||||
|
||||
outcome = deleted > 0 ? "removed" : "not_found";
|
||||
}
|
||||
outcome = deleted > 0 ? "removed" : "not_found";
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (
|
||||
|
||||
@@ -12,11 +12,7 @@ const NAME_MAX = 255;
|
||||
const TEXT_MAX = 5000;
|
||||
const STYLE_MAX = 5000;
|
||||
|
||||
type ApplyOutcome =
|
||||
| "submitted"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
type ApplyOutcome = "submitted" | "invalid" | "ratelimit" | "error";
|
||||
|
||||
function applyRedirect(outcome: ApplyOutcome): never {
|
||||
if (outcome === "submitted") redirect("/radio/apply?submitted=1");
|
||||
|
||||
@@ -9,12 +9,7 @@ import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
const SONG_MAX = 255;
|
||||
const ARTIST_MAX = 255;
|
||||
|
||||
type RequestOutcome =
|
||||
| "posted"
|
||||
| "empty"
|
||||
| "invalid"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
type RequestOutcome = "posted" | "empty" | "invalid" | "ratelimit" | "error";
|
||||
|
||||
function requestsRedirect(outcome: RequestOutcome): never {
|
||||
if (outcome === "posted") redirect("/radio/requests?posted=1");
|
||||
|
||||
@@ -12,12 +12,7 @@ const shoutSchema = z.object({
|
||||
message: z.string().min(1, "Message is required").max(255),
|
||||
});
|
||||
|
||||
type ShoutOutcome =
|
||||
| "posted"
|
||||
| "invalid"
|
||||
| "moderated"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
type ShoutOutcome = "posted" | "invalid" | "moderated" | "ratelimit" | "error";
|
||||
|
||||
function shoutsRedirect(outcome: ShoutOutcome): never {
|
||||
if (outcome === "posted") redirect("/radio/shouts?posted=1");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { sendVerification } from "@/actions/email-verify";
|
||||
import { hashPassword } from "@/lib/auth/password";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
@@ -100,6 +101,7 @@ export async function register(
|
||||
});
|
||||
if (existing) return "That username is already taken";
|
||||
} catch {
|
||||
logger.warn("Username uniqueness check failed during registration");
|
||||
return "Registration is temporarily unavailable";
|
||||
}
|
||||
|
||||
@@ -122,10 +124,11 @@ export async function register(
|
||||
try {
|
||||
await sendVerification(mail);
|
||||
} catch {
|
||||
// No-op: account is created; user can request a new link later.
|
||||
logger.warn("Failed to send verification email after registration");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.warn("Account creation failed");
|
||||
return "Could not create the account (is the username unique?)";
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
|
||||
const FAVICON_DIR = resolveMediaPath("favicon");
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
import { resolveMediaPath } from "@/lib/media-storage";
|
||||
|
||||
const MEDIA_DIR = resolveMediaPath("logo");
|
||||
|
||||
|
||||
@@ -37,10 +37,13 @@ export async function signOutEverywhere(): Promise<void> {
|
||||
where: personalTokenScope(userId),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to revoke personal access tokens during sign-out-everywhere", {
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : "Unknown",
|
||||
});
|
||||
logger.warn(
|
||||
"Failed to revoke personal access tokens during sign-out-everywhere",
|
||||
{
|
||||
userId,
|
||||
error: err instanceof Error ? err.message : "Unknown",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await signOut({ redirectTo: "/login?signedOutAll=1" });
|
||||
|
||||
+1
-6
@@ -28,12 +28,7 @@ function parseBadgeCodes(raw: string | null | undefined): string[] {
|
||||
.filter((s) => s.length > 0 && s.length <= 32);
|
||||
}
|
||||
|
||||
type BuyOutcome =
|
||||
| "bought"
|
||||
| "invalid"
|
||||
| "credits"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
type BuyOutcome = "bought" | "invalid" | "credits" | "ratelimit" | "error";
|
||||
|
||||
function shopRedirect(
|
||||
categoryId: string,
|
||||
|
||||
@@ -22,12 +22,7 @@ type FriendRequestOutcome =
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
|
||||
type ThreadOutcome =
|
||||
| "posted"
|
||||
| "invalid"
|
||||
| "not_found"
|
||||
| "ratelimit"
|
||||
| "error";
|
||||
type ThreadOutcome = "posted" | "invalid" | "not_found" | "ratelimit" | "error";
|
||||
|
||||
type ReplyOutcome =
|
||||
| "replied"
|
||||
|
||||
@@ -388,6 +388,7 @@ export const muteUser = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: muteSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
void _target;
|
||||
const success = await rcon.muteUser(ctx.data.userId, ctx.data.duration);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to mute. Is the emulator running?");
|
||||
@@ -440,6 +441,7 @@ export const sendCredits = adminAction(
|
||||
{ permission: PERMS.USERS_EDIT, schema: sendCreditsSchema },
|
||||
async (ctx) => {
|
||||
const _target = await guardRank(ctx.data.userId, ctx.session.user.rank);
|
||||
void _target;
|
||||
const success = await rcon.giveCredits(ctx.data.userId, ctx.data.amount);
|
||||
if (!success)
|
||||
throw new ActionError("Failed to send credits. Is the emulator running?");
|
||||
|
||||
Reference in new issue
Block a user