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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' }),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+255
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user