Merge branch 'main' of https://gitlab.epicnabbo.nl/remco/EpicNext-Cms into codex/housekeeping-complete
This commit is contained in:
commit
64bb230de5
23 files changed
+846
-1248
No files matched your search
+1
-1
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=26.7.0 <27"
|
||||
"node": ">=26.8.1 <27"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,11 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { env } from "@/env";
|
||||
import { checkLogin } from "@/lib/auth/password";
|
||||
import { queryPreparedOne } from "@/lib/db";
|
||||
import {
|
||||
getLoginUser,
|
||||
isEmailUnverified,
|
||||
normalizeLoginInput,
|
||||
runDummyHashCheck,
|
||||
verifyLoginPassword,
|
||||
} from "@/lib/auth/login-core";
|
||||
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
||||
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
||||
import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
export type PrecheckResult =
|
||||
| "ok"
|
||||
@@ -24,10 +27,7 @@ export async function precheckLogin(
|
||||
password: string,
|
||||
captchaToken?: string | null,
|
||||
): Promise<PrecheckResult> {
|
||||
const u = String(username ?? "")
|
||||
.normalize("NFC")
|
||||
.trim();
|
||||
const p = String(password ?? "");
|
||||
const { username: u, password: p } = normalizeLoginInput(username, password);
|
||||
if (!u || !p) return "invalid";
|
||||
|
||||
const ip = await clientIp();
|
||||
@@ -38,49 +38,17 @@ export async function precheckLogin(
|
||||
if (!(await verifyCaptcha(captchaToken ?? null, ip))) return "captcha";
|
||||
}
|
||||
|
||||
let user: {
|
||||
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";
|
||||
}
|
||||
const user = await getLoginUser(u);
|
||||
if (!user) {
|
||||
// Prevent timing-based enumeration: always run a dummy hash check.
|
||||
await checkLogin(
|
||||
p,
|
||||
"$2y$12$abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZabcd",
|
||||
{
|
||||
convertPasswords: false,
|
||||
},
|
||||
);
|
||||
await runDummyHashCheck(p);
|
||||
return "invalid";
|
||||
}
|
||||
|
||||
const res = await checkLogin(p, user.password, {
|
||||
convertPasswords: env.CONVERT_PASSWORDS,
|
||||
});
|
||||
const res = await verifyLoginPassword(user, p);
|
||||
if (!res.valid) return "invalid";
|
||||
|
||||
if (
|
||||
(await siteSettings.getBool("require_email_verification", false)) &&
|
||||
user.mail &&
|
||||
user.mailVerified !== "1"
|
||||
) {
|
||||
if (await isEmailUnverified(user)) {
|
||||
return "unverified";
|
||||
}
|
||||
|
||||
|
||||
+82
-170
File diff suppressed because it is too large.
Load diff
+22
-68
File diff suppressed because it is too large.
Load diff
+154
-317
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
+158
-245
File diff suppressed because it is too large.
Load diff
@@ -3,6 +3,7 @@ import { CheckCircle2, Clock, MailX } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { isValidVerificationToken } from "@/actions/email-verify";
|
||||
import { SurfaceCard } from "@/components/surface-card";
|
||||
import { db, User } from "@/lib/db";
|
||||
|
||||
type Status = "verified" | "already" | "invalid" | "unavailable";
|
||||
@@ -20,38 +21,47 @@ function StatusCard({
|
||||
children: React.ReactNode;
|
||||
color: string;
|
||||
}) {
|
||||
const gradientMap: Record<string, string> = {
|
||||
green:
|
||||
"linear-gradient(140deg, #0A2F1A 0%, #0F3D22 20%, #154C2A 40%, #1B5A32 60%, #20683A 75%, #1A5A30 90%, #144826 100%)",
|
||||
yellow:
|
||||
"linear-gradient(140deg, #3A2F0A 0%, #4A3D0F 20%, #5A4C15 40%, #6A5A1B 60%, #7A6820 75%, #6A5A1A 90%, #5A4814 100%)",
|
||||
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 tintMap: Record<string, string> = {
|
||||
green: "#16a34a",
|
||||
yellow: "#d97706",
|
||||
red: "#dc2626",
|
||||
blue: "#2563eb",
|
||||
};
|
||||
const tint = tintMap[color] ?? tintMap.blue;
|
||||
|
||||
return (
|
||||
<main className="flex min-h-[60vh] items-center justify-center px-4">
|
||||
<div
|
||||
className="w-full max-w-md overflow-hidden rounded-xl border"
|
||||
style={{
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 12%, transparent)",
|
||||
backgroundColor: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
<SurfaceCard className="w-full max-w-md">
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-5 py-4"
|
||||
style={{ background: gradientMap[color] ?? gradientMap.blue }}
|
||||
className="flex items-center gap-2.5 border-b px-5 py-4"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${tint} 12%, var(--color-surface))`,
|
||||
borderColor:
|
||||
"color-mix(in srgb, var(--color-text-muted) 10%, transparent)",
|
||||
}}
|
||||
>
|
||||
<span className="text-white">{icon}</span>
|
||||
<h1 className="text-sm font-bold text-white">{title}</h1>
|
||||
<span
|
||||
className="flex h-7 w-7 items-center justify-center rounded-lg"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${tint} 18%, transparent)`,
|
||||
color: tint,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<h1
|
||||
className="text-sm font-bold tracking-tight"
|
||||
style={{ color: "var(--color-text-readable)" }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-4 px-6 py-8 text-center">
|
||||
<div
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full"
|
||||
style={{
|
||||
background:
|
||||
"color-mix(in srgb, var(--color-primary) 12%, transparent)",
|
||||
background: `color-mix(in srgb, ${tint} 12%, transparent)`,
|
||||
color: tint,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
@@ -64,7 +74,7 @@ function StatusCard({
|
||||
</p>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</SurfaceCard>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
import { HABBO_GAMEDATA_HOTELS } from "@/lib/habbo-gamedata-hotel";
|
||||
|
||||
export type FieldType =
|
||||
@@ -45,13 +44,6 @@ export const SETTINGS_GROUPS: SettingsGroup[] = [
|
||||
icon: "building",
|
||||
description: "Name, branding and defaults shown across the site.",
|
||||
fields: [
|
||||
{
|
||||
key: "hotel_name",
|
||||
label: "Hotel name",
|
||||
type: "text",
|
||||
placeholder: "Epicnabbo",
|
||||
defaultValue: FALLBACK_HOTEL_NAME,
|
||||
},
|
||||
{
|
||||
key: "cms_logo",
|
||||
label: "Logo URL",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { count, desc, eq } from "drizzle-orm";
|
||||
import { env } from "@/env";
|
||||
import { apiJson } from "@/lib/api";
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
import { db, User, WebsiteArticles } from "@/lib/db";
|
||||
import { resolveHotelName } from "@/lib/hotel-name";
|
||||
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
|
||||
@@ -38,7 +38,7 @@ export async function GET(_req: Request) {
|
||||
return apiJson(data);
|
||||
} catch {
|
||||
return apiJson(
|
||||
{ articles: [], online: 0, hotelName: FALLBACK_HOTEL_NAME },
|
||||
{ articles: [], online: 0, hotelName: env.HOTEL_NAME },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
import { mobileMenuVariants } from "@/lib/motion";
|
||||
|
||||
interface MobileNavProps {
|
||||
@@ -17,7 +16,7 @@ export function MobileNav({
|
||||
children,
|
||||
menuLabel = "Open menu",
|
||||
closeLabel = "Close menu",
|
||||
brandLabel = FALLBACK_HOTEL_NAME,
|
||||
brandLabel = "",
|
||||
}: MobileNavProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const detailsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -16,10 +16,9 @@ import {
|
||||
renderToCanvas,
|
||||
} from "@/components/public/sprite-font";
|
||||
import { ContentCard } from "@/components/public/ui";
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
|
||||
export default function LogoGenerator({
|
||||
initialText = FALLBACK_HOTEL_NAME,
|
||||
initialText = "",
|
||||
}: {
|
||||
initialText?: string;
|
||||
}) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+6
-2
@@ -1,5 +1,4 @@
|
||||
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
|
||||
// will move to @t3-oss/env-nextjs (the habbo-next pattern), but the data layer
|
||||
@@ -25,7 +24,12 @@ const schema = z
|
||||
.int()
|
||||
.positive()
|
||||
.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"),
|
||||
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
|
||||
// Public imager URL — overrides the default /imaging relative path.
|
||||
|
||||
+19
-92
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
@@ -1,17 +1,12 @@
|
||||
import "server-only";
|
||||
|
||||
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> {
|
||||
const fromSettings = await siteSettings.get("hotel_name", 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;
|
||||
return env.HOTEL_NAME;
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import "server-only";
|
||||
|
||||
import { FALLBACK_HOTEL_NAME } from "@/lib/brand";
|
||||
import { db, WebsiteSetting } from "@/lib/db";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
hotel_name: FALLBACK_HOTEL_NAME,
|
||||
habbo_imaging_url: "/imaging",
|
||||
logo_url: "",
|
||||
nitro_client_url: "",
|
||||
@@ -16,7 +14,7 @@ const DEFAULTS: Record<string, string> = {
|
||||
const CACHE_TTL_MS = 300_000;
|
||||
const REDIS_CACHE_KEY = "site_settings";
|
||||
// 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.
|
||||
const MEMORY_TTL_MS = 60_000;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
// unit tests run without a populated .env.
|
||||
env: {
|
||||
SKIP_ENV_VALIDATION: "1",
|
||||
HOTEL_NAME: "TestHotel",
|
||||
DATABASE_URL: "mysql://root:root@localhost:3306/test",
|
||||
},
|
||||
testTimeout: 10000,
|
||||
|
||||
Reference in new issue
Block a user