diff --git a/web-next/app/[locale]/forum/[forumId]/page.tsx b/web-next/app/[locale]/forum/[forumId]/page.tsx
index 5fded4f..9f89299 100644
--- a/web-next/app/[locale]/forum/[forumId]/page.tsx
+++ b/web-next/app/[locale]/forum/[forumId]/page.tsx
@@ -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 (
@@ -52,6 +56,12 @@ export default async function ForumPage({
))}
)}
+
+ {canPost ? (
+
+ ) : (
+ {t('loginToPost')}
+ )}
)
}
diff --git a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx
index e24fe31..d1f00ff 100644
--- a/web-next/app/[locale]/forum/topic/[topicId]/page.tsx
+++ b/web-next/app/[locale]/forum/topic/[topicId]/page.tsx
@@ -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 (
@@ -47,6 +51,12 @@ export default async function TopicPage({
))}
+
+ {canReply ? (
+
+ ) : (
+ !session.username && {t('loginToPost')}
+ )}
)
}
diff --git a/web-next/app/api/forum/reply/route.ts b/web-next/app/api/forum/reply/route.ts
new file mode 100644
index 0000000..04aef94
--- /dev/null
+++ b/web-next/app/api/forum/reply/route.ts
@@ -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 = {}
+ 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 })
+}
diff --git a/web-next/app/api/forum/topic/route.ts b/web-next/app/api/forum/topic/route.ts
new file mode 100644
index 0000000..38b9353
--- /dev/null
+++ b/web-next/app/api/forum/topic/route.ts
@@ -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 = {}
+ 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 })
+}
diff --git a/web-next/components/NewTopicForm.tsx b/web-next/components/NewTopicForm.tsx
new file mode 100644
index 0000000..9fcadc9
--- /dev/null
+++ b/web-next/components/NewTopicForm.tsx
@@ -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(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 (
+
+ )
+}
diff --git a/web-next/components/ReplyForm.tsx b/web-next/components/ReplyForm.tsx
new file mode 100644
index 0000000..f9ae763
--- /dev/null
+++ b/web-next/components/ReplyForm.tsx
@@ -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(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 (
+
+ )
+}
diff --git a/web-next/lib/forum-sanitize.ts b/web-next/lib/forum-sanitize.ts
new file mode 100644
index 0000000..68ccdb3
--- /dev/null
+++ b/web-next/lib/forum-sanitize.ts
@@ -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' }),
+ },
+ })
+}
diff --git a/web-next/lib/forum-write.ts b/web-next/lib/forum-write.ts
new file mode 100644
index 0000000..0d4bb89
--- /dev/null
+++ b/web-next/lib/forum-write.ts
@@ -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 {
+ const clean = cleanPostHtml(text)
+ const [res] = await db(DB.web).query(
+ '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 {
+ const clean = cleanPostHtml(text)
+ const [res] = 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],
+ )
+ 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 {
+ const [rows] = await db(DB.web).query(
+ '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 {
+ const [rows] = await db(DB.web).query(
+ 'SELECT locked FROM forum_topics WHERE id = ? AND deleted = 0',
+ [topicId],
+ )
+ return Boolean(rows[0] && rows[0].locked !== 1)
+}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index 6088f23..6da753a 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -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."
}
}
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index 17a89df..a55ddf9 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -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."
}
}
diff --git a/web-next/package-lock.json b/web-next/package-lock.json
index ae83199..2aae554 100644
--- a/web-next/package-lock.json
+++ b/web-next/package-lock.json
@@ -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",
diff --git a/web-next/package.json b/web-next/package.json
index 8041da4..2c25a7a 100644
--- a/web-next/package.json
+++ b/web-next/package.json
@@ -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",