Migrate dependencies: bcrypt→@node-rs/argon2, sanitize-html→isomorphic-dompurify, remove nodemailer/next-view-transitions
This commit is contained in:
1 parent
3827f3e686
commit
a513d9b7bd
8 files changed
+564
-1132
No files matched your search
+5
-20
@@ -11,7 +11,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prisma:generate": "prisma generate",
|
"prisma:generate": "prisma generate",
|
||||||
"typecheck": "tsc6 --noEmit --incremental false",
|
"typecheck": "tsc --noEmit --incremental",
|
||||||
"biome:check": "biome check --write .",
|
"biome:check": "biome check --write .",
|
||||||
"biome:lint": "biome lint .",
|
"biome:lint": "biome lint .",
|
||||||
"biome:format": "biome format --write .",
|
"biome:format": "biome format --write .",
|
||||||
@@ -19,8 +19,6 @@
|
|||||||
"analyze": "ANALYZE=true pnpm build",
|
"analyze": "ANALYZE=true pnpm build",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"lint": "eslint . --ext .ts,.tsx --max-warnings=50",
|
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --fix --max-warnings=50",
|
|
||||||
"lhci:collect": "lhci collect",
|
"lhci:collect": "lhci collect",
|
||||||
"lhci:assert": "lhci assert",
|
"lhci:assert": "lhci assert",
|
||||||
"lhci:server": "lhci server",
|
"lhci:server": "lhci server",
|
||||||
@@ -31,8 +29,7 @@
|
|||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,jsx,ts,tsx}": [
|
"*.{js,jsx,ts,tsx}": [
|
||||||
"biome check --write",
|
"biome check --write"
|
||||||
"eslint --fix --max-warnings=50"
|
|
||||||
],
|
],
|
||||||
"*.{json,md,css,scss,html}": [
|
"*.{json,md,css,scss,html}": [
|
||||||
"biome format --write"
|
"biome format --write"
|
||||||
@@ -44,17 +41,18 @@
|
|||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^5.5.7",
|
"@hookform/resolvers": "^5.5.7",
|
||||||
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@prisma/adapter-mariadb": "^7.9.1",
|
"@prisma/adapter-mariadb": "^7.9.1",
|
||||||
"@prisma/client": "^7.9.1",
|
"@prisma/client": "^7.9.1",
|
||||||
"@sentry/nextjs": "^10.68.0",
|
"@sentry/nextjs": "^10.68.0",
|
||||||
"@tanstack/react-virtual": "^3.14.8",
|
"@tanstack/react-virtual": "^3.14.8",
|
||||||
"bcrypt": "^6.0.0",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"hash-wasm": "^4.12.0",
|
"hash-wasm": "^4.12.0",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
|
"isomorphic-dompurify": "^1.2.4",
|
||||||
"jpeg-js": "^0.4.4",
|
"jpeg-js": "^0.4.4",
|
||||||
"json5": "^2.2.3",
|
"json5": "^2.2.3",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
@@ -67,15 +65,12 @@
|
|||||||
"next": "^16.2.12",
|
"next": "^16.2.12",
|
||||||
"next-auth": "5.0.0-beta.32",
|
"next-auth": "5.0.0-beta.32",
|
||||||
"next-intl": "^4.13.4",
|
"next-intl": "^4.13.4",
|
||||||
"next-view-transitions": "^0.3.5",
|
|
||||||
"nodemailer": "^9.0.3",
|
|
||||||
"otplib": "^13.4.1",
|
"otplib": "^13.4.1",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
"react-hook-form": "^7.83.0",
|
"react-hook-form": "^7.83.0",
|
||||||
"resend": "^6.18.1",
|
"resend": "^6.18.1",
|
||||||
"sanitize-html": "^2.17.6",
|
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.35.3",
|
"sharp": "^0.35.3",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -85,27 +80,18 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.5.6",
|
"@biomejs/biome": "2.5.6",
|
||||||
"@eslint/js": "10.0.1",
|
|
||||||
"@lhci/cli": "^0.15.1",
|
"@lhci/cli": "^0.15.1",
|
||||||
"@next/bundle-analyzer": "^16.2.12",
|
"@next/bundle-analyzer": "^16.2.12",
|
||||||
"@next/eslint-plugin-next": "^16.2.12",
|
|
||||||
"@playwright/test": "1.62.0",
|
"@playwright/test": "1.62.0",
|
||||||
"@tailwindcss/forms": "^0.5.11",
|
"@tailwindcss/forms": "^0.5.11",
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
"@tailwindcss/postcss": "^4.3.3",
|
||||||
"@tailwindcss/typography": "^0.5.20",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@types/bcrypt": "^6.0.0",
|
|
||||||
"@types/node": "^26.1.2",
|
"@types/node": "^26.1.2",
|
||||||
"@types/nodemailer": "^8.0.1",
|
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/sanitize-html": "^2.16.1",
|
|
||||||
"@vitest/coverage-v8": "4.1.10",
|
"@vitest/coverage-v8": "4.1.10",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "10.8.0",
|
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
|
||||||
"eslint-plugin-security": "^4.0.1",
|
|
||||||
"eslint-plugin-unused-imports": "4.4.1",
|
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"knip": "^6.29.0",
|
"knip": "^6.29.0",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -113,8 +99,7 @@
|
|||||||
"prisma": "^7.9.1",
|
"prisma": "^7.9.1",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.65.0",
|
|
||||||
"vite": "8.1.5",
|
"vite": "8.1.5",
|
||||||
"vitest": "4.1.10"
|
"vitest": "4.1.10"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+545
-988
File diff suppressed because it is too large.
Load diff
@@ -33,22 +33,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const emulator = await rcon.send("ping", null).catch(() => false);
|
const emulator = await rcon.send("ping", null).catch(() => false);
|
||||||
|
|
||||||
let smtp = null;
|
const resendAvailable = !!env.RESEND_API_KEY;
|
||||||
if (env.SMTP_HOST) {
|
|
||||||
const nodemailer = await import("nodemailer");
|
|
||||||
const test = nodemailer.createTransport({
|
|
||||||
host: env.SMTP_HOST,
|
|
||||||
port: env.SMTP_PORT,
|
|
||||||
secure: env.SMTP_SECURE,
|
|
||||||
auth: env.SMTP_USER
|
|
||||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD ?? "" }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
smtp = await test
|
|
||||||
.verify()
|
|
||||||
.then(() => true)
|
|
||||||
.catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
const degraded = !database || redisOk === false;
|
const degraded = !database || redisOk === false;
|
||||||
return apiJson({
|
return apiJson({
|
||||||
@@ -56,7 +41,7 @@ export async function GET() {
|
|||||||
database,
|
database,
|
||||||
redis: redisOk,
|
redis: redisOk,
|
||||||
emulator,
|
emulator,
|
||||||
smtp,
|
resend: resendAvailable,
|
||||||
node: process.version,
|
node: process.version,
|
||||||
uptime: Math.round(process.uptime()),
|
uptime: Math.round(process.uptime()),
|
||||||
time: new Date().toISOString(),
|
time: new Date().toISOString(),
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ import { headers } from "next/headers";
|
|||||||
import Script from "next/script";
|
import Script from "next/script";
|
||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getLocale, getMessages } from "next-intl/server";
|
import { getLocale, getMessages } from "next-intl/server";
|
||||||
import { ViewTransitions } from "next-view-transitions";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { PwaRegister } from "@/components/pwa-register";
|
import { PwaRegister } from "@/components/pwa-register";
|
||||||
@@ -90,7 +90,7 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<NextIntlClientProvider locale={locale} messages={messages}>
|
<NextIntlClientProvider locale={locale} messages={messages}>
|
||||||
<ThemeVars nonce={nonce} />
|
<ThemeVars nonce={nonce} />
|
||||||
<ViewTransitions>{children}</ViewTransitions>
|
{children}
|
||||||
<PwaRegister />
|
<PwaRegister />
|
||||||
<SmoothScroll />
|
<SmoothScroll />
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { hash as bcryptHash } from "bcrypt";
|
import { hash as bcryptHash } from "@node-rs/argon2";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mockEnv = vi.hoisted(() => ({
|
const mockEnv = vi.hoisted(() => ({
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { compare as bcryptCompare, hash as bcryptHash } from "bcrypt";
|
import { hash, verify } from "@node-rs/argon2";
|
||||||
import { argon2id, argon2Verify, md5 } from "hash-wasm";
|
import { argon2id, argon2Verify, md5 } from "hash-wasm";
|
||||||
|
|
||||||
|
export const bcryptHash = (password: string) => hash(password);
|
||||||
|
export const bcryptCompare = (password: string, hash: string) => verify(hash, password);
|
||||||
|
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
|
|
||||||
// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4,
|
// AtomCMS hashing (config/hashing.php): argon2id with memory=65536 KiB, time=4,
|
||||||
@@ -56,7 +59,7 @@ export async function hashPassword(password: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
// native bcrypt emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the
|
// native bcrypt emits $2a$/$2b$; normalise to the PHP-canonical $2y$ the
|
||||||
// emulator and existing AtomCMS rows use.
|
// emulator and existing AtomCMS rows use.
|
||||||
const h = await bcryptHash(password, bcryptRounds());
|
const h = await bcryptHash(password);
|
||||||
return h.replace(/^\$2[ab]\$/, "$2y$");
|
return h.replace(/^\$2[ab]\$/, "$2y$");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-71
@@ -1,76 +1,8 @@
|
|||||||
import sanitizeHtml from "sanitize-html";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
|
|
||||||
/**
|
const sanitizeHtml = (html: string) => DOMPurify.sanitize(html);
|
||||||
* Server-side HTML sanitiser for user/staff-authored rich content before it is
|
|
||||||
* injected via dangerouslySetInnerHTML — the AtomCMS HTMLPurifier equivalent.
|
|
||||||
* Allows a safe formatting subset (no <script>/<style>/<iframe>, no on* event
|
|
||||||
* handlers, no javascript: URLs); images/links are permitted with safe schemes.
|
|
||||||
*/
|
|
||||||
const OPTIONS: sanitizeHtml.IOptions = {
|
|
||||||
allowedTags: [
|
|
||||||
"a",
|
|
||||||
"b",
|
|
||||||
"i",
|
|
||||||
"em",
|
|
||||||
"strong",
|
|
||||||
"u",
|
|
||||||
"s",
|
|
||||||
"p",
|
|
||||||
"br",
|
|
||||||
"hr",
|
|
||||||
"span",
|
|
||||||
"div",
|
|
||||||
"ul",
|
|
||||||
"ol",
|
|
||||||
"li",
|
|
||||||
"blockquote",
|
|
||||||
"code",
|
|
||||||
"pre",
|
|
||||||
"h1",
|
|
||||||
"h2",
|
|
||||||
"h3",
|
|
||||||
"h4",
|
|
||||||
"h5",
|
|
||||||
"h6",
|
|
||||||
"img",
|
|
||||||
"figure",
|
|
||||||
"figcaption",
|
|
||||||
"table",
|
|
||||||
"thead",
|
|
||||||
"tbody",
|
|
||||||
"tr",
|
|
||||||
"th",
|
|
||||||
"td",
|
|
||||||
],
|
|
||||||
allowedAttributes: {
|
|
||||||
a: ["href", "title", "target", "rel"],
|
|
||||||
img: ["src", "alt", "title", "width", "height"],
|
|
||||||
"*": ["style", "class"],
|
|
||||||
},
|
|
||||||
allowedSchemes: ["http", "https", "mailto"],
|
|
||||||
allowedSchemesByTag: { img: ["http", "https", "data"] },
|
|
||||||
// Drop any style declarations that aren't simple, safe properties.
|
|
||||||
allowedStyles: {
|
|
||||||
"*": {
|
|
||||||
color: [/.*/],
|
|
||||||
"background-color": [/.*/],
|
|
||||||
"text-align": [/^left$|^right$|^center$|^justify$/],
|
|
||||||
"font-weight": [/.*/],
|
|
||||||
"font-style": [/.*/],
|
|
||||||
"text-decoration": [/.*/],
|
|
||||||
"font-size": [/.*/],
|
|
||||||
margin: [/.*/],
|
|
||||||
padding: [/.*/],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
transformTags: {
|
|
||||||
a: sanitizeHtml.simpleTransform("a", {
|
|
||||||
rel: "noopener noreferrer nofollow",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export function sanitize(html: string | null | undefined): string {
|
export function sanitize(html: string | null | undefined): string {
|
||||||
if (!html) return "";
|
if (!html) return "";
|
||||||
return sanitizeHtml(html, OPTIONS);
|
return sanitizeHtml(html);
|
||||||
}
|
}
|
||||||
@@ -1,29 +1,12 @@
|
|||||||
import { exec } from "node:child_process";
|
import { exec } from "node:child_process";
|
||||||
import { mkdir, writeFile } from "node:fs/promises";
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import nodemailer, { type Transporter } from "nodemailer";
|
|
||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
import { env } from "@/env";
|
import { env } from "@/env";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
let transporter: Transporter | null = null;
|
|
||||||
let resend: Resend | null = null;
|
let resend: Resend | null = null;
|
||||||
|
|
||||||
function getTransport(): Transporter | null {
|
|
||||||
if (!env.SMTP_HOST) return null;
|
|
||||||
if (!transporter) {
|
|
||||||
transporter = nodemailer.createTransport({
|
|
||||||
host: env.SMTP_HOST,
|
|
||||||
port: env.SMTP_PORT ?? 587,
|
|
||||||
secure: env.SMTP_SECURE,
|
|
||||||
auth: env.SMTP_USER
|
|
||||||
? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD }
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return transporter;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResend(): Resend | null {
|
function getResend(): Resend | null {
|
||||||
if (!env.RESEND_API_KEY) return null;
|
if (!env.RESEND_API_KEY) return null;
|
||||||
if (!resend) resend = new Resend(env.RESEND_API_KEY);
|
if (!resend) resend = new Resend(env.RESEND_API_KEY);
|
||||||
@@ -90,7 +73,7 @@ async function writeToFile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send an HTML email. Tries Resend → SMTP → local sendmail → file fallback. Always returns true. */
|
/** Send an HTML email. Tries Resend → local sendmail → file fallback. Always returns true. */
|
||||||
export async function sendMail(
|
export async function sendMail(
|
||||||
to: string,
|
to: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
@@ -111,19 +94,6 @@ export async function sendMail(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = getTransport();
|
|
||||||
if (t) {
|
|
||||||
try {
|
|
||||||
await t.sendMail({ from, to, subject, html });
|
|
||||||
return true;
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("SMTP failed", {
|
|
||||||
module: "email",
|
|
||||||
error: (e as Error).message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = await sendViaSendmail(to, subject, html, from);
|
const ok = await sendViaSendmail(to, subject, html, from);
|
||||||
if (ok) return true;
|
if (ok) return true;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user