Foro (escritura): crear tema y responder, con saneado HTML

- lib/forum-sanitize.ts: cleanPostHtml con sanitize-html (misma allowlist que
  forum/sanitize.py de nh3: formato básico + a/img/tablas, rel nofollow, esquemas
  http/https/mailto). Verificado XSS-safe (script/onclick/javascript: eliminados).
- lib/forum-write.ts: createTopic (tema + primer post), createPost (respuesta +
  updated_at), forumIsPostable / topicIsReplyable.
- Routes /api/forum/topic y /api/forum/reply (guard de sesión; identidad = cuenta de
  juego: poster=username, poster_id=accountId). Componentes NewTopicForm y ReplyForm
  (clientes). Se muestran solo si hay sesión; el tema cerrado no admite respuesta.

Verificado: escritura 401 sin sesión, saneado correcto. Pendiente: editar/borrar,
moderación (fijar/cerrar/mover), búsqueda, editor enriquecido.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 23:49:53 +00:00
parent e743d98777
commit 239392b876
12 changed files with 516 additions and 3 deletions
@@ -2,6 +2,8 @@ import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getForum, getTopics } from '@/lib/forum'
import { getSession } from '@/lib/session'
import { NewTopicForm } from '@/components/NewTopicForm'
export const dynamic = 'force-dynamic'
@@ -18,6 +20,8 @@ export default async function ForumPage({
const forum = await getForum(id)
if (!forum) notFound()
const topics = await getTopics(id)
const session = await getSession()
const canPost = Boolean(session.username)
return (
<main className="mx-auto max-w-4xl px-4 py-8">
@@ -52,6 +56,12 @@ export default async function ForumPage({
))}
</ul>
)}
{canPost ? (
<NewTopicForm forumId={id} />
) : (
<p className="mt-8 text-sm text-amber-200/60">{t('loginToPost')}</p>
)}
</main>
)
}
@@ -2,6 +2,8 @@ import { notFound } from 'next/navigation'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { Link } from '@/i18n/navigation'
import { getTopic, getPosts } from '@/lib/forum'
import { getSession } from '@/lib/session'
import { ReplyForm } from '@/components/ReplyForm'
export const dynamic = 'force-dynamic'
@@ -18,6 +20,8 @@ export default async function TopicPage({
const topic = await getTopic(id)
if (!topic) notFound()
const posts = await getPosts(id)
const session = await getSession()
const canReply = Boolean(session.username) && !topic!.locked
return (
<main className="mx-auto max-w-4xl px-4 py-8">
@@ -47,6 +51,12 @@ export default async function TopicPage({
</article>
))}
</div>
{canReply ? (
<ReplyForm topicId={id} />
) : (
!session.username && <p className="mt-6 text-sm text-amber-200/60">{t('loginToPost')}</p>
)}
</main>
)
}
+15
View File
@@ -0,0 +1,15 @@
import { getSession } from '@/lib/session'
import { createPost, topicIsReplyable } from '@/lib/forum-write'
export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId || !session.username) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<string, string> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const topicId = Number(b.topicId)
const text = String(b.text ?? '').trim()
if (!topicId || !text) return Response.json({ success: false, error: 'emptyError' })
if (!(await topicIsReplyable(topicId))) return Response.json({ success: false, error: 'topicLocked' })
await createPost(topicId, session.username, session.accountId, text)
return Response.json({ success: true })
}
+16
View File
@@ -0,0 +1,16 @@
import { getSession } from '@/lib/session'
import { createTopic, forumIsPostable } from '@/lib/forum-write'
export async function POST(request: Request) {
const session = await getSession()
if (!session.accountId || !session.username) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
let b: Record<string, string> = {}
try { b = await request.json() } catch { return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) }
const forumId = Number(b.forumId)
const name = String(b.name ?? '').trim()
const text = String(b.text ?? '').trim()
if (!forumId || !name || !text) return Response.json({ success: false, error: 'emptyError' })
if (!(await forumIsPostable(forumId))) return Response.json({ success: false, error: 'invalidForum' })
const topicId = await createTopic(forumId, name, session.username, session.accountId, text)
return Response.json({ success: true, topicId })
}
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from '@/i18n/navigation'
export function NewTopicForm({ forumId }: { forumId: number }) {
const t = useTranslations('Forum')
const router = useRouter()
const [name, setName] = useState('')
const [text, setText] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (busy || !name.trim() || !text.trim()) return
setBusy(true)
setError(null)
try {
const res = await fetch('/api/forum/topic', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ forumId, name, text }),
})
const data: { success?: boolean; topicId?: number } = await res.json()
if (data.success && data.topicId) router.push(`/forum/topic/${data.topicId}`)
else {
setError(t('genericError'))
setBusy(false)
}
} catch {
setError(t('genericError'))
setBusy(false)
}
}
const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
return (
<form onSubmit={handleSubmit} className="mt-8 space-y-3 rounded-lg border border-amber-900/60 bg-[#241812] p-4">
<h3 className="font-semibold text-amber-400">{t('newTopic')}</h3>
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t('newTopicName')} maxLength={200} className={field} />
<textarea value={text} onChange={(e) => setText(e.target.value)} placeholder={t('newTopicText')} rows={4} className={field} />
<button type="submit" disabled={busy} className="rounded bg-amber-600 px-4 py-2 font-semibold text-[#1b120b] disabled:opacity-60">
{busy ? t('publishing') : t('publish')}
</button>
{error && <p className="text-red-400">{error}</p>}
</form>
)
}
+50
View File
@@ -0,0 +1,50 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from '@/i18n/navigation'
export function ReplyForm({ topicId }: { topicId: number }) {
const t = useTranslations('Forum')
const router = useRouter()
const [text, setText] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (busy || !text.trim()) return
setBusy(true)
setError(null)
try {
const res = await fetch('/api/forum/reply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ topicId, text }),
})
const data: { success?: boolean } = await res.json()
if (data.success) {
setText('')
router.refresh()
} else {
setError(t('genericError'))
}
} catch {
setError(t('genericError'))
} finally {
setBusy(false)
}
}
const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
return (
<form onSubmit={handleSubmit} className="mt-6 space-y-3">
<textarea value={text} onChange={(e) => setText(e.target.value)} placeholder={t('replyText')} rows={3} className={field} />
<button type="submit" disabled={busy} className="rounded bg-amber-600 px-4 py-2 font-semibold text-[#1b120b] disabled:opacity-60">
{busy ? t('sending') : t('sendReply')}
</button>
{error && <p className="text-red-400">{error}</p>}
</form>
)
}
+28
View File
@@ -0,0 +1,28 @@
import sanitizeHtml from 'sanitize-html'
// Misma allowlist que forum/sanitize.py (nh3) para el HTML de los mensajes.
export function cleanPostHtml(html: string): string {
if (!html) return ''
return sanitizeHtml(html, {
allowedTags: [
'p', 'br', 'hr', 'span', 'div',
'strong', 'b', 'em', 'i', 'u', 's', 'strike', 'sub', 'sup',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'a', 'img',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
],
allowedAttributes: {
a: ['href', 'title', 'target'],
img: ['src', 'alt', 'title', 'width', 'height'],
span: ['style'],
div: ['style'],
td: ['colspan', 'rowspan'],
th: ['colspan', 'rowspan'],
},
allowedSchemes: ['http', 'https', 'mailto'],
transformTags: {
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }),
},
})
}
+57
View File
@@ -0,0 +1,57 @@
import type { ResultSetHeader, RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import { cleanPostHtml } from './forum-sanitize'
export async function createTopic(
forumId: number,
name: string,
poster: string,
posterId: number,
text: string,
): Promise<number> {
const clean = cleanPostHtml(text)
const [res] = await db(DB.web).query<ResultSetHeader>(
'INSERT INTO forum_topics (forum_id, name, poster, poster_id, created, created_at, updated_at, locked, sticky, deleted) ' +
'VALUES (?, ?, ?, ?, NOW(), NOW(), NOW(), 0, 0, 0)',
[forumId, name, poster, posterId],
)
const topicId = res.insertId
await db(DB.web).query(
'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)',
[topicId, poster, posterId, clean],
)
return topicId
}
export async function createPost(
topicId: number,
poster: string,
posterId: number,
text: string,
): Promise<number> {
const clean = cleanPostHtml(text)
const [res] = await db(DB.web).query<ResultSetHeader>(
'INSERT INTO forum_posts (topic_id, poster, poster_id, text, time, created_at, deleted) VALUES (?, ?, ?, ?, NOW(), NOW(), 0)',
[topicId, poster, posterId, clean],
)
await db(DB.web).query('UPDATE forum_topics SET updated_at = NOW() WHERE id = ?', [topicId])
return res.insertId
}
/** Comprueba que un foro existe y es visible (para crear tema). */
export async function forumIsPostable(forumId: number): Promise<boolean> {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id FROM forums WHERE id = ? AND visibility = 1 AND deleted = 0',
[forumId],
)
return Boolean(rows[0])
}
/** Comprueba que un tema existe y no está cerrado (para responder). */
export async function topicIsReplyable(topicId: number): Promise<boolean> {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT locked FROM forum_topics WHERE id = ? AND deleted = 0',
[topicId],
)
return Boolean(rows[0] && rows[0].locked !== 1)
}
+11 -1
View File
@@ -254,6 +254,16 @@
"backToTopics": "Back to forum",
"notFound": "Not found",
"newTopic": "New topic",
"reply": "Reply"
"reply": "Reply",
"newTopicName": "Topic title",
"newTopicText": "Write your message…",
"publish": "Post topic",
"publishing": "Posting…",
"replyText": "Write your reply…",
"sendReply": "Reply",
"sending": "Sending…",
"loginToPost": "Sign in to participate.",
"emptyError": "Fill in all fields.",
"genericError": "Something went wrong. Please try again later."
}
}
+11 -1
View File
@@ -254,6 +254,16 @@
"backToTopics": "Volver al foro",
"notFound": "No encontrado",
"newTopic": "Nuevo tema",
"reply": "Responder"
"reply": "Responder",
"newTopicName": "Título del tema",
"newTopicText": "Escribe tu mensaje…",
"publish": "Publicar tema",
"publishing": "Publicando…",
"replyText": "Escribe tu respuesta…",
"sendReply": "Responder",
"sending": "Enviando…",
"loginToPost": "Inicia sesión para participar.",
"emptyError": "Completa todos los campos.",
"genericError": "Algo ha salido mal. Inténtalo más tarde."
}
}
+255 -1
View File
@@ -15,6 +15,7 @@
"nodemailer": "^9.0.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.17.6",
"stripe": "^22.3.1"
},
"devDependencies": {
@@ -23,6 +24,7 @@
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4.3.2",
@@ -2466,6 +2468,113 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/sanitize-html": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.16.1.tgz",
"integrity": "sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==",
"dev": true,
"dependencies": {
"htmlparser2": "^10.1"
}
},
"node_modules/@types/sanitize-html/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
]
},
"node_modules/@types/sanitize-html/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/@types/sanitize-html/node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.63.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.63.0.tgz",
@@ -3604,6 +3713,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA=="
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3627,6 +3741,14 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -3689,6 +3811,69 @@
"node": ">=0.10.0"
}
},
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -3728,6 +3913,17 @@
"node": ">=10.13.0"
}
},
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-abstract": {
"version": "1.24.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz",
@@ -3972,7 +4168,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"dev": true,
"engines": {
"node": ">=10"
},
@@ -4802,6 +4997,27 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
@@ -5175,6 +5391,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@@ -5466,6 +5690,14 @@
"node": ">=0.10"
}
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -6301,6 +6533,11 @@
"node": ">=6"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q=="
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -6630,6 +6867,23 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sanitize-html": {
"version": "2.17.6",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.6.tgz",
"integrity": "sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+2
View File
@@ -16,6 +16,7 @@
"nodemailer": "^9.0.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanitize-html": "^2.17.6",
"stripe": "^22.3.1"
},
"devDependencies": {
@@ -24,6 +25,7 @@
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/sanitize-html": "^2.16.1",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4.3.2",