Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/EpicNext-Cms into codex/housekeeping-complete

This commit is contained in:
Simo committed 2026-08-27 17:25:31 +02:00
commit 64bb230de5
23 files changed
+500 -902

No files matched your search

+1 -1
View File
@@ -1 +1 @@
26.7.0 26.8.1
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=26.7.0 <27" "node": ">=26.8.1 <27"
}, },
"packageManager": "[email protected]", "packageManager": "[email protected]",
"scripts": { "scripts": {
File diff suppressed because it is too large. Load diff
+12 -44
View File
@@ -1,11 +1,14 @@
"use server"; "use server";
import { env } from "@/env"; import {
import { checkLogin } from "@/lib/auth/password"; getLoginUser,
import { queryPreparedOne } from "@/lib/db"; isEmailUnverified,
normalizeLoginInput,
runDummyHashCheck,
verifyLoginPassword,
} from "@/lib/auth/login-core";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha"; import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
import { siteSettings } from "@/lib/services/site-settings";
export type PrecheckResult = export type PrecheckResult =
| "ok" | "ok"
@@ -24,10 +27,7 @@ export async function precheckLogin(
password: string, password: string,
captchaToken?: string | null, captchaToken?: string | null,
): Promise<PrecheckResult> { ): Promise<PrecheckResult> {
const u = String(username ?? "") const { username: u, password: p } = normalizeLoginInput(username, password);
.normalize("NFC")
.trim();
const p = String(password ?? "");
if (!u || !p) return "invalid"; if (!u || !p) return "invalid";
const ip = await clientIp(); const ip = await clientIp();
@@ -38,49 +38,17 @@ export async function precheckLogin(
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha"; if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
} }
let user: { const user = await getLoginUser(u);
password: string;
twoFactorConfirmedAt: Date | null;
mail: string | null;
mailVerified: string;
} | null;
try {
user = await queryPreparedOne<{
password: string;
twoFactorConfirmedAt: Date | null;
mail: string | null;
mailVerified: string;
}>(
`SELECT password, two_factor_confirmed_at AS twoFactorConfirmedAt,
mail, mail_verified AS mailVerified
FROM users WHERE username = ? LIMIT 1`,
[u],
);
} catch {
return "invalid";
}
if (!user) { if (!user) {
// Prevent timing-based enumeration: always run a dummy hash check. // Prevent timing-based enumeration: always run a dummy hash check.
await checkLogin( await runDummyHashCheck(p);
p,
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
{
convertPasswords: false,
},
);
return "invalid"; return "invalid";
} }
const res = await checkLogin(p, user.password, { const res = await verifyLoginPassword(user, p);
convertPasswords: env.CONVERT_PASSWORDS,
});
if (!res.valid) return "invalid"; if (!res.valid) return "invalid";
if ( if (await isEmailUnverified(user)) {
(await siteSettings.getBool("require_email_verification", false)) &&
user.mail &&
user.mailVerified !== "1"
) {
return "unverified"; return "unverified";
} }
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+58 -221
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+28 -18
View File
@@ -3,6 +3,7 @@ import { CheckCircle2, Clock, MailX } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { isValidVerificationToken } from "@/actions/email-verify"; import { isValidVerificationToken } from "@/actions/email-verify";
import { SurfaceCard } from "@/components/surface-card";
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
type Status = "verified" | "already" | "invalid" | "unavailable"; type Status = "verified" | "already" | "invalid" | "unavailable";
@@ -20,38 +21,47 @@ function StatusCard({
children: React.ReactNode; children: React.ReactNode;
color: string; color: string;
}) { }) {
const gradientMap: Record<string, string> = { const tintMap: Record<string, string> = {
green: green: "#16a34a",
"linear-gradient(140deg, #0A2F1A 0%, #0F3D22 20%, #154C2A 40%, #1B5A32 60%, #20683A 75%, #1A5A30 90%, #144826 100%)", yellow: "#d97706",
yellow: red: "#dc2626",
"linear-gradient(140deg, #3A2F0A 0%, #4A3D0F 20%, #5A4C15 40%, #6A5A1B 60%, #7A6820 75%, #6A5A1A 90%, #5A4814 100%)", blue: "#2563eb",
red: "linear-gradient(140deg, #3A0F0A 0%, #4A1A0F 20%, #5A2515 40%, #6A301B 60%, #7A3B20 75%, #6A301A 90%, #5A2514 100%)",
blue: "linear-gradient(140deg, #0A1A3A 0%, #0F254A 20%, #15305A 40%, #1B3B6A 60%, #20467A 75%, #1A3B6A 90%, #14305A 100%)",
}; };
const tint = tintMap[color] ?? tintMap.blue;
return ( return (
<main className="flex min-h-[60vh] items-center justify-center px-4"> <main className="flex min-h-[60vh] items-center justify-center px-4">
<SurfaceCard className="w-full max-w-md">
<div <div
className="w-full max-w-md overflow-hidden rounded-xl border" className="flex items-center gap-2.5 border-b px-5 py-4"
style={{ style={{
background: `color-mix(in srgb, ${tint} 12%, var(--color-surface))`,
borderColor: borderColor:
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)", "color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
backgroundColor: "var(--color-surface)",
}} }}
> >
<div <span
className="flex items-center gap-2.5 px-5 py-4" className="flex h-7 w-7 items-center justify-center rounded-lg"
style={{ background: gradientMap[color] ?? gradientMap.blue }} style={{
background: `color-mix(in srgb, ${tint} 18%, transparent)`,
color: tint,
}}
> >
<span className="text-white">{icon}</span> {icon}
<h1 className="text-sm font-bold text-white">{title}</h1> </span>
<h1
className="text-sm font-bold tracking-tight"
style={{ color: "var(--color-text-readable)" }}
>
{title}
</h1>
</div> </div>
<div className="flex flex-col items-center gap-4 px-6 py-8 text-center"> <div className="flex flex-col items-center gap-4 px-6 py-8 text-center">
<div <div
className="flex h-16 w-16 items-center justify-center rounded-full" className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ style={{
background: background: `color-mix(in srgb, ${tint} 12%, transparent)`,
"color-mix(in srgb, var(--color-primary) 12%, transparent)", color: tint,
}} }}
> >
{icon} {icon}
@@ -64,7 +74,7 @@ function StatusCard({
</p> </p>
{children} {children}
</div> </div>
</div> </SurfaceCard>
</main> </main>
); );
} }
@@ -1,4 +1,3 @@
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel"; import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
export type FieldType = export type FieldType =
@@ -45,13 +44,6 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
icon: "building", icon: "building",
description: "Name, branding and defaults shown across the site.", description: "Name, branding and defaults shown across the site.",
fields: [ fields: [
{
key: "hotel_name",
label: "Hotel name",
type: "text",
placeholder: "Epicnabbo",
defaultValue: FALLBACK_HOTEL_NAME,
},
{ {
key: "cms_logo", key: "cms_logo",
label: "Logo URL", label: "Logo URL",
+2 -2
View File
@@ -1,6 +1,6 @@
import { count, desc, eq } from "drizzle-orm"; import { count, desc, eq } from "drizzle-orm";
import { env } from "@/env";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, User, WebsiteArticles } from "@/lib/db"; import { db, User, WebsiteArticles } from "@/lib/db";
import { resolveHotelName } from "@/lib/hotel-name"; import { resolveHotelName } from "@/lib/hotel-name";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
@@ -38,7 +38,7 @@ export async function GET(_req: Request) {
return apiJson(data); return apiJson(data);
} catch { } catch {
return apiJson( return apiJson(
{ articles: [], online: 0, hotelName: FALLBACK_HOTEL_NAME }, { articles: [], online: 0, hotelName: env.HOTEL_NAME },
{ status: 200 }, { status: 200 },
); );
} }
+1 -2
View File
@@ -3,7 +3,6 @@
import { AnimatePresence, motion } from "motion/react"; import { AnimatePresence, motion } from "motion/react";
import Image from "next/image"; import Image from "next/image";
import { type ReactNode, useRef, useState } from "react"; import { type ReactNode, useRef, useState } from "react";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { mobileMenuVariants } from "@/lib/motion"; import { mobileMenuVariants } from "@/lib/motion";
interface MobileNavProps { interface MobileNavProps {
@@ -17,7 +16,7 @@ export function MobileNav({
children, children,
menuLabel = "Open menu", menuLabel = "Open menu",
closeLabel = "Close menu", closeLabel = "Close menu",
brandLabel = FALLBACK_HOTEL_NAME, brandLabel = "",
}: MobileNavProps) { }: MobileNavProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const detailsRef = useRef<HTMLDivElement>(null); const detailsRef = useRef<HTMLDivElement>(null);
+1 -2
View File
@@ -16,10 +16,9 @@ import {
renderToCanvas, renderToCanvas,
} from "@/components/public/sprite-font"; } from "@/components/public/sprite-font";
import { ContentCard } from "@/components/public/ui"; import { ContentCard } from "@/components/public/ui";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
export default function LogoGenerator({ export default function LogoGenerator({
initialText = FALLBACK_HOTEL_NAME, initialText = "",
}: { }: {
initialText?: string; initialText?: string;
}) { }) {
File diff suppressed because it is too large. Load diff
+6 -2
View File
@@ -1,5 +1,4 @@
import { z } from "zod"; import { z } from "zod";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
// Minimal validated env for the foundation. When the Next.js app is added this // Minimal validated env for the foundation. When the Next.js app is added this
// will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer // will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer
@@ -25,7 +24,12 @@ const schema = z
.int() .int()
.positive() .positive()
.default(10_000), .default(10_000),
HOTEL_NAME: z.string().default(FALLBACK_HOTEL_NAME), HOTEL_NAME: z
.string()
.min(
1,
"HOTEL_NAME is not set — the site has not been configured/built yet.",
),
APP_URL: z.string().url().default("http://localhost:3000"), APP_URL: z.string().url().default("http://localhost:3000"),
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"), NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
// Public imager URL — overrides the default /imaging relative path. // Public imager URL — overrides the default /imaging relative path.
+19 -92
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-10
View File
@@ -1,10 +0,0 @@
/**
* Single hardcoded fallback hotel brand.
* Override order at runtime:
* 1. website_settings.hotel_name
* 2. HOTEL_NAME env
* 3. this constant
*
* Safe for client components (no env / DB imports).
*/
export const FALLBACK_HOTEL_NAME = "Atom";
+4 -9
View File
@@ -1,17 +1,12 @@
import "server-only"; import "server-only";
import { env } from "@/env"; import { env } from "@/env";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { siteSettings } from "@/lib/services/site-settings";
/** /**
* Resolve the public hotel name: CMS setting → HOTEL_NAME env → FALLBACK_HOTEL_NAME. * The public hotel name is read directly from the required HOTEL_NAME env var.
* There is no CMS override or hardcoded preset — the site must be configured,
* otherwise HOTEL_NAME fails validation at startup.
*/ */
export async function resolveHotelName(): Promise<string> { export async function resolveHotelName(): Promise<string> {
const fromSettings = await siteSettings.get("hotel_name", env.HOTEL_NAME); return env.HOTEL_NAME;
const trimmed = fromSettings?.trim();
if (trimmed) return trimmed;
const fromEnv = env.HOTEL_NAME?.trim();
if (fromEnv) return fromEnv;
return FALLBACK_HOTEL_NAME;
} }
+1 -3
View File
@@ -1,12 +1,10 @@
import "server-only"; import "server-only";
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
import { db, WebsiteSetting } from "@/lib/db"; import { db, WebsiteSetting } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
const DEFAULTS: Record<string, string> = { const DEFAULTS: Record<string, string> = {
hotel_name: FALLBACK_HOTEL_NAME,
habbo_imaging_url: "/imaging", habbo_imaging_url: "/imaging",
logo_url: "", logo_url: "",
nitro_client_url: "", nitro_client_url: "",
@@ -16,7 +14,7 @@ const DEFAULTS: Record<string, string> = {
const CACHE_TTL_MS = 300_000; const CACHE_TTL_MS = 300_000;
const REDIS_CACHE_KEY = "site_settings"; const REDIS_CACHE_KEY = "site_settings";
// Short in-process window so repeated getters in one request (header, nav, // Short in-process window so repeated getters in one request (header, nav,
// footer all read hotel_name / logo) don't each pay a Redis round-trip. // footer all read logo and other settings) don't each pay a Redis round-trip.
// Redis stays the source of truth across instances. // Redis stays the source of truth across instances.
const MEMORY_TTL_MS = 60_000; const MEMORY_TTL_MS = 60_000;
+1
View File
@@ -19,6 +19,7 @@ export default defineConfig({
// unit tests run without a populated .env. // unit tests run without a populated .env.
env: { env: {
SKIP_ENV_VALIDATION: "1", SKIP_ENV_VALIDATION: "1",
HOTEL_NAME: "TestHotel",
DATABASE_URL: "mysql://root:root@localhost:3306/test", DATABASE_URL: "mysql://root:root@localhost:3306/test",
}, },
testTimeout: 10000, testTimeout: 10000,