Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 1m8s
CI / tests-integration (push) Successful in 1m53s
CI / tests-unit (push) Successful in 1m59s
CI / tests-ui (push) Successful in 2m42s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m33s
`home-login-form.tsx` and `login-form.tsx` were two ~240-line near-identical components. Delete the former and give `LoginForm` a `variant` prop: - `variant="page"` sr-only labels plus the register/forgot footer (/login) - `variant="compact"` visible labels, no footer (homepage sidebar) Field ids now come from `useId()`, so the two usages can never collide, and the hardcoded "Show"/"Hide"/"Loading" strings are translated. Localization of the login and register screens: - `home-login-form.tsx` was entirely hardcoded English. - `passwordStrength()` returned hardcoded "Weak"/"Fair"/"Good"/"Strong". - `register.ts` returned only English strings. It now returns a locale-independent `code` next to the message, and the form renders `t(code)` with the English string as a fallback. - Backfilled the new keys across all 25 locales, plus the login/register strings that were still English in most of them. `ar`, `fi` and `ja` had their entire login/register namespace in English and are now filled in. Locale parity stays at 0 missing keys, as `i18n:check` requires. Copy that did not match the enforced rules: the UI advertised "min 8 chars" (EN) / "min 6 tekens" (NL) while registration requires 12 characters plus an uppercase, a lowercase, a digit and a special character. Corrected in every locale. `password-reset.ts` enforced only 6 characters and is raised to 12 to match registration. Accessibility: `login-form.tsx` had no `<label>`, no `id` and no `required` on any field. All three are now present, and error banners are announced with `role="alert"`. Adds `src/i18n/auth-messages.test.ts`, which asserts every `RegisterErrorCode` resolves to a non-empty message in all 25 locales; verified it fails when a key is removed. The existing register tests now also assert the error `code`.
346 lines
9.7 KiB
TypeScript
346 lines
9.7 KiB
TypeScript
"use server";
|
|
|
|
import { count, eq } from "drizzle-orm";
|
|
import { after } from "next/server";
|
|
import { z } from "zod";
|
|
import { sendVerification } from "@/lib/auth/email-verification";
|
|
import { hashPassword } from "@/lib/auth/password";
|
|
import { invalidateKey } from "@/lib/cached-db";
|
|
import { db, User } from "@/lib/db";
|
|
import { logger } from "@/lib/logger";
|
|
import { clientIp, rateLimit } from "@/lib/rate-limit";
|
|
import { captchaConfig, verifyCaptcha } from "@/lib/services/captcha";
|
|
import { checkVpn } from "@/lib/services/ip-lookup";
|
|
import { recordReferral } from "@/lib/services/referrals";
|
|
import { siteSettings } from "@/lib/services/site-settings";
|
|
|
|
// Reserved usernames that can never be registered (prevent impersonation/admin confusion).
|
|
const RESERVED_USERNAMES = new Set([
|
|
"admin",
|
|
"root",
|
|
"system",
|
|
"moderator",
|
|
"mod",
|
|
"staff",
|
|
"support",
|
|
"help",
|
|
"service",
|
|
"api",
|
|
"webmaster",
|
|
"postmaster",
|
|
"hostmaster",
|
|
"administrator",
|
|
"superuser",
|
|
"sysadmin",
|
|
"nobody",
|
|
"anonymous",
|
|
"guest",
|
|
"default",
|
|
"test",
|
|
"demo",
|
|
"example",
|
|
"info",
|
|
"security",
|
|
"abuse",
|
|
"noreply",
|
|
"donotreply",
|
|
"bot",
|
|
"crawler",
|
|
"indexer",
|
|
]);
|
|
|
|
// Disposable/temporary email domains (subset, expandable via settings).
|
|
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
|
"10minutemail.com",
|
|
"guerrillamail.com",
|
|
"mailinator.com",
|
|
"tempmail.com",
|
|
"throwawaymail.com",
|
|
"yopmail.com",
|
|
"trashmail.com",
|
|
"fakeinbox.com",
|
|
"spamgourmet.com",
|
|
"getnada.com",
|
|
"maildrop.cc",
|
|
]);
|
|
|
|
function isReservedUsername(username: string): boolean {
|
|
const lower = username.toLowerCase();
|
|
if (RESERVED_USERNAMES.has(lower)) return true;
|
|
if (
|
|
lower.startsWith("admin") ||
|
|
lower.startsWith("mod") ||
|
|
lower.startsWith("staff")
|
|
)
|
|
return true;
|
|
if (/^(x|www|mail|ftp|smtp|pop|imap|dns|ns[0-9]*)$/.test(lower)) return true;
|
|
return false;
|
|
}
|
|
|
|
function hasDisposableEmailDomain(email: string): boolean {
|
|
const domain = email.split("@")[1]?.toLowerCase();
|
|
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
|
}
|
|
|
|
const registerSchema = z
|
|
.object({
|
|
username: z
|
|
.string()
|
|
.min(3, "Username must be at least 3 characters")
|
|
.max(25, "Username must be at most 25 characters")
|
|
.regex(
|
|
/^[A-Za-z0-9_-]+$/,
|
|
"Username may only contain letters, numbers, underscore and hyphen",
|
|
)
|
|
.refine((u) => !isReservedUsername(u), "This username is reserved"),
|
|
mail: z
|
|
.string()
|
|
.email("Enter a valid email address")
|
|
.optional()
|
|
.or(z.literal(""))
|
|
.refine(
|
|
(e) => !e || !hasDisposableEmailDomain(e),
|
|
"Temporary email domains are not allowed",
|
|
),
|
|
password: z
|
|
.string()
|
|
.min(12, "Password must be at least 12 characters") // Increased min length
|
|
.max(128, "Password is too long") // Added max length
|
|
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
.regex(/[0-9]/, "Password must contain at least one digit")
|
|
.regex(
|
|
/[^A-Za-z0-9]/,
|
|
"Password must contain at least one special character",
|
|
), // Added special character requirement
|
|
passwordConfirmation: z.string(),
|
|
look: z.string().optional(),
|
|
})
|
|
.refine((data) => data.password === data.passwordConfirmation, {
|
|
message: "Passwords do not match",
|
|
path: ["passwordConfirmation"],
|
|
});
|
|
|
|
/**
|
|
* Stable, locale-independent reason for a failed sign-up. The client maps these
|
|
* onto `pages.register.<code>` so the form speaks the visitor's language; the
|
|
* English `error` string stays as a fallback and for API/log consumers.
|
|
*/
|
|
export type RegisterErrorCode =
|
|
| "usernameMinLength"
|
|
| "usernameMaxLength"
|
|
| "usernamePattern"
|
|
| "usernameReserved"
|
|
| "usernameTaken"
|
|
| "emailValid"
|
|
| "emailDisposable"
|
|
| "passwordMinLength"
|
|
| "passwordMaxLength"
|
|
| "passwordUpper"
|
|
| "passwordLower"
|
|
| "passwordDigit"
|
|
| "passwordSpecial"
|
|
| "passwordsMatch"
|
|
| "termsRequired"
|
|
| "captchaFailed"
|
|
| "rateLimited"
|
|
| "vpnBlocked"
|
|
| "maxAccountsPerIp"
|
|
| "unavailable"
|
|
| "createFailed"
|
|
| "invalidInput";
|
|
|
|
/** Maps the schema's English messages onto locale-independent codes. */
|
|
const ZOD_MESSAGE_CODES: Record<string, RegisterErrorCode> = {
|
|
"Username must be at least 3 characters": "usernameMinLength",
|
|
"Username must be at most 25 characters": "usernameMaxLength",
|
|
"Username may only contain letters, numbers, underscore and hyphen":
|
|
"usernamePattern",
|
|
"This username is reserved": "usernameReserved",
|
|
"Enter a valid email address": "emailValid",
|
|
"Temporary email domains are not allowed": "emailDisposable",
|
|
"Password must be at least 12 characters": "passwordMinLength",
|
|
"Password is too long": "passwordMaxLength",
|
|
"Password must contain at least one uppercase letter": "passwordUpper",
|
|
"Password must contain at least one lowercase letter": "passwordLower",
|
|
"Password must contain at least one digit": "passwordDigit",
|
|
"Password must contain at least one special character": "passwordSpecial",
|
|
"Passwords do not match": "passwordsMatch",
|
|
};
|
|
|
|
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
|
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
|
|
|
export interface RegisterState {
|
|
error: string | null;
|
|
ok: boolean;
|
|
/** Locale-independent reason, present on every failure. */
|
|
code?: RegisterErrorCode;
|
|
}
|
|
|
|
export async function register(
|
|
_prevState: RegisterState,
|
|
formData: FormData,
|
|
): Promise<RegisterState> {
|
|
const fail = (error: string, code: RegisterErrorCode): RegisterState => ({
|
|
error,
|
|
ok: false,
|
|
code,
|
|
});
|
|
const raw = {
|
|
username: String(formData.get("username") ?? "")
|
|
.normalize("NFC")
|
|
.trim(),
|
|
mail: String(formData.get("mail") ?? "")
|
|
.normalize("NFC")
|
|
.trim()
|
|
.toLowerCase(),
|
|
password: String(formData.get("password") ?? "").normalize("NFC"),
|
|
passwordConfirmation: String(
|
|
formData.get("password_confirmation") ?? "",
|
|
).normalize("NFC"),
|
|
look:
|
|
String(formData.get("look") ?? "")
|
|
.normalize("NFC")
|
|
.trim() || DEFAULT_LOOK,
|
|
termsAccepted: formData.get("terms") === "on",
|
|
};
|
|
|
|
const parsed = registerSchema.safeParse(raw);
|
|
if (!parsed.success) {
|
|
const message = parsed.error.issues[0]?.message ?? "Invalid input";
|
|
return fail(message, ZOD_MESSAGE_CODES[message] ?? "invalidInput");
|
|
}
|
|
|
|
const { username, mail, password, look } = parsed.data;
|
|
const hasEmail = !!mail;
|
|
const ip = await clientIp();
|
|
|
|
// Throttle sign-ups per IP (5 per 10 minutes) to curb account spam.
|
|
if (!(await rateLimit(`register:${ip}`, 5, 10 * 60_000)).ok) {
|
|
return fail(
|
|
"Too many sign-up attempts. Please wait a few minutes and try again.",
|
|
"rateLimited",
|
|
);
|
|
}
|
|
|
|
// CAPTCHA (Turnstile / reCAPTCHA) — only enforced when configured in settings.
|
|
const cfg = await captchaConfig();
|
|
if (cfg.provider !== "none") {
|
|
const token = String(formData.get(cfg.field) ?? "").normalize("NFC");
|
|
if (!(await verifyCaptcha(token, ip)))
|
|
return fail(
|
|
"Captcha verification failed. Please try again.",
|
|
"captchaFailed",
|
|
);
|
|
}
|
|
|
|
// Terms acceptance check.
|
|
if (!raw.termsAccepted)
|
|
return fail(
|
|
"You must accept the terms and conditions to register.",
|
|
"termsRequired",
|
|
);
|
|
|
|
// VPN/proxy block (only when enabled in /admin/vpn).
|
|
if ((await checkVpn(ip)).blocked) {
|
|
return fail(
|
|
(await siteSettings.get("vpn_block_message", "")) ||
|
|
"Registrations from VPN/proxy connections are not allowed.",
|
|
"vpnBlocked",
|
|
);
|
|
}
|
|
|
|
// Max accounts per IP (0 / unset = unlimited), mirrors AtomCMS.
|
|
const max = Number(await siteSettings.get("max_accounts_per_ip", "0")) || 0;
|
|
if (max > 0) {
|
|
const [row] = await db
|
|
.select({ total: count() })
|
|
.from(User)
|
|
.where(eq(User.ipRegister, ip))
|
|
.catch(() => [{ total: 0 }]);
|
|
if (Number(row?.total ?? 0) >= max)
|
|
return fail(
|
|
"You have reached the maximum number of accounts for your connection.",
|
|
"maxAccountsPerIp",
|
|
);
|
|
}
|
|
|
|
// Uniqueness check.
|
|
try {
|
|
const [existing] = await db
|
|
.select({ id: User.id })
|
|
.from(User)
|
|
.where(eq(User.username, username))
|
|
.limit(1);
|
|
if (existing)
|
|
return fail("That username is already taken", "usernameTaken");
|
|
} catch {
|
|
logger.warn("Username uniqueness check failed during registration");
|
|
return fail("Registration is temporarily unavailable", "unavailable");
|
|
}
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
let inviteeId = 0;
|
|
try {
|
|
const [result] = await db.insert(User).values({
|
|
username,
|
|
password: await hashPassword(password),
|
|
mail: hasEmail ? mail : null,
|
|
accountCreated: now,
|
|
ipRegister: ip,
|
|
ipCurrent: ip,
|
|
look,
|
|
termsAccepted: raw.termsAccepted,
|
|
});
|
|
inviteeId = Number(result.insertId);
|
|
} catch (err) {
|
|
const code = (err as { cause?: { code?: string } }).cause?.code;
|
|
if (code === "ER_DUP_ENTRY") {
|
|
return fail("That username is already taken", "usernameTaken");
|
|
}
|
|
logger.error("Account creation failed", {
|
|
code,
|
|
message: err instanceof Error ? err.message : String(err),
|
|
});
|
|
return fail(
|
|
"Could not create the account. Please try again or contact staff.",
|
|
"createFailed",
|
|
);
|
|
}
|
|
|
|
// The login lookup is cached for 15s — drop any stale entry so the
|
|
// immediate auto sign-in sees the fresh row.
|
|
await invalidateKey(`login:user:${username}`);
|
|
|
|
// Referral attribution (`/register?ref=<username>`). Best-effort: a broken
|
|
// referral must never fail the account creation.
|
|
if (inviteeId > 0) {
|
|
const invitedBy = String(formData.get("ref") ?? "")
|
|
.normalize("NFC")
|
|
.trim();
|
|
if (invitedBy) {
|
|
await recordReferral({
|
|
inviterUsername: invitedBy,
|
|
inviteeId,
|
|
inviteeIp: ip,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Verification email must never block the sign-up response — it is sent
|
|
// after the response is flushed (no-op when mail is unconfigured).
|
|
if (hasEmail) {
|
|
after(async () => {
|
|
try {
|
|
await sendVerification(mail);
|
|
} catch {
|
|
logger.warn("Failed to send verification email after registration");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Client auto signs in with these credentials and navigates to /me.
|
|
return { error: null, ok: true };
|
|
}
|