Foro: edición/borrado de posts, moderación de temas y búsqueda

Portado desde forum/db.py + forum/permissions.py de Django:
- lib/forum-perm.ts: forumIsModerator (gmlevel >= FORUM_MOD_GMLEVEL) y
  canEditPost (autor o moderador).
- Edición y borrado lógico de posts (autor o mod), respetando tema bloqueado,
  con validación de longitud mínima (plainLength).
- Moderación de temas (solo mod): bloquear/desbloquear, fijar/no fijar, borrar.
- Búsqueda de temas por título/contenido (searchTopics/countSearchTopics)
  con página /forum/search y buscador en el índice.
- API: PATCH/DELETE /api/forum/post, POST /api/forum/moderate (401/403 correctos).
- UI: PostActions, TopicModBar, ForumSearchBox; marca "editado" y "[Fijado]".
- i18n: 22 claves nuevas en es/en.

Verificado: build OK, /forum y /forum/search 200, APIs 401 sin sesión.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 00:28:39 +00:00
parent 13636d097a
commit 1de1002938
14 changed files with 590 additions and 9 deletions
+37
View File
@@ -0,0 +1,37 @@
import type { RowDataPacket } from 'mysql2'
import { db, DB } from './db'
import type { SessionData } from './session'
/** Nivel GM mínimo para moderar el foro (equivale a FORUM_MOD_GMLEVEL en Django). */
const MOD_GMLEVEL = Number(process.env.FORUM_MOD_GMLEVEL || 2)
/** Nivel GM máximo de una cuenta (acore_auth.account_access). 0 si no tiene. */
export async function getGmlevel(accountId: number | undefined): Promise<number> {
if (!accountId) return 0
try {
const [rows] = await db(DB.auth).query<RowDataPacket[]>(
'SELECT MAX(gmlevel) AS lvl FROM account_access WHERE id = ?',
[accountId],
)
return rows[0] && rows[0].lvl != null ? Number(rows[0].lvl) : 0
} catch {
return 0
}
}
/** ¿El usuario puede moderar el foro? */
export async function forumIsModerator(session: SessionData): Promise<boolean> {
if (!session.accountId) return false
return (await getGmlevel(session.accountId)) >= MOD_GMLEVEL
}
/** Puede editar/borrar un post: su autor o un moderador. */
export function canEditPost(
session: SessionData,
isMod: boolean,
posterId: number | null | undefined,
): boolean {
if (!session.accountId) return false
if (posterId != null && Number(posterId) === Number(session.accountId)) return true
return isMod
}
+8
View File
@@ -26,3 +26,11 @@ export function cleanPostHtml(html: string): string {
},
})
}
/** Longitud del texto plano (sin etiquetas) — para validar longitud mínima. */
export function plainLength(html: string): number {
return sanitizeHtml(html || '', { allowedTags: [], allowedAttributes: {} })
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim().length
}
+33
View File
@@ -38,6 +38,39 @@ export async function createPost(
return res.insertId
}
/** Datos mínimos de un post para comprobar permisos (autor + tema). */
export async function getPost(
postId: number,
): Promise<{ id: number; topic_id: number; poster_id: number | null; text: string } | null> {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, topic_id, poster_id, text FROM forum_posts WHERE id = ? AND deleted = 0',
[postId],
)
const r = rows[0]
return r ? { id: r.id, topic_id: r.topic_id, poster_id: r.poster_id ?? null, text: r.text } : null
}
/** Edita el texto de un post (ya saneado por el llamador). */
export async function updatePost(postId: number, text: string): Promise<void> {
const clean = cleanPostHtml(text)
await db(DB.web).query('UPDATE forum_posts SET text = ?, updated_at = NOW() WHERE id = ?', [clean, postId])
}
/** Marca/desmarca un post como borrado (borrado lógico). */
export async function setPostDeleted(postId: number, deleted: boolean): Promise<void> {
await db(DB.web).query('UPDATE forum_posts SET deleted = ? WHERE id = ?', [deleted ? 1 : 0, postId])
}
/** Cambia una bandera del tema (sticky/locked/deleted). Solo moderación. */
export async function setTopicFlag(
topicId: number,
field: 'sticky' | 'locked' | 'deleted',
value: boolean,
): Promise<void> {
if (!['sticky', 'locked', 'deleted'].includes(field)) throw new Error('campo no permitido')
await db(DB.web).query(`UPDATE forum_topics SET ${field} = ? WHERE id = ?`, [value ? 1 : 0, topicId])
}
/** 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[]>(
+84 -4
View File
@@ -33,8 +33,28 @@ export interface Topic {
export interface Post {
id: number
poster: string
poster_id: number | null
text: string
time: string | null
edited: boolean
}
export interface TopicFull {
id: number
name: string
forum_id: number
poster_id: number | null
locked: boolean
sticky: boolean
}
export interface SearchResult {
id: number
name: string
poster: string
forum_id: number
forum_name: string
updated_at: string | null
}
/** Índice del foro: categorías con sus foros visibles (+ contadores y último tema). */
@@ -104,13 +124,23 @@ export async function getTopics(forumId: number): Promise<Topic[]> {
}
}
export async function getTopic(id: number): Promise<{ name: string; locked: boolean } | null> {
export async function getTopic(id: number): Promise<TopicFull | null> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT name, locked FROM forum_topics WHERE id = ? AND deleted = 0',
'SELECT id, name, forum_id, poster_id, locked, sticky FROM forum_topics WHERE id = ? AND deleted = 0',
[id],
)
return rows[0] ? { name: rows[0].name, locked: rows[0].locked === 1 } : null
const r = rows[0]
return r
? {
id: r.id,
name: r.name,
forum_id: r.forum_id,
poster_id: r.poster_id ?? null,
locked: r.locked === 1,
sticky: r.sticky === 1,
}
: null
} catch {
return null
}
@@ -119,14 +149,64 @@ export async function getTopic(id: number): Promise<{ name: string; locked: bool
export async function getPosts(topicId: number): Promise<Post[]> {
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
'SELECT id, poster, text, time FROM forum_posts WHERE topic_id = ? AND deleted = 0 ORDER BY time ASC, id ASC',
'SELECT id, poster, poster_id, text, time, created_at, updated_at FROM forum_posts WHERE topic_id = ? AND deleted = 0 ORDER BY time ASC, id ASC',
[topicId],
)
return rows.map((r) => ({
id: r.id,
poster: r.poster,
poster_id: r.poster_id ?? null,
text: r.text,
time: r.time ? new Date(r.time).toISOString() : null,
// editado si updated_at es posterior (más de 2s) al alta del post
edited: Boolean(
r.updated_at && r.created_at && new Date(r.updated_at).getTime() - new Date(r.created_at).getTime() > 2000,
),
}))
} catch {
return []
}
}
/** Nº de temas que coinciden con la búsqueda (foros visibles). */
export async function countSearchTopics(query: string): Promise<number> {
const like = `%${query}%`
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT COUNT(DISTINCT t.id) AS n
FROM forum_topics t
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)`,
[like, like],
)
return Number(rows[0]?.n ?? 0)
} catch {
return 0
}
}
/** Busca temas por título o contenido de sus posts (solo foros visibles). */
export async function searchTopics(query: string, offset = 0, limit = 20): Promise<SearchResult[]> {
const like = `%${query}%`
try {
const [rows] = await db(DB.web).query<RowDataPacket[]>(
`SELECT DISTINCT t.id, t.name, t.poster, t.forum_id, t.updated_at, f.name AS forum_name
FROM forum_topics t
JOIN forums f ON f.id = t.forum_id AND f.visibility = 1 AND f.deleted = 0
LEFT JOIN forum_posts p ON p.topic_id = t.id AND p.deleted = 0
WHERE t.deleted = 0 AND (t.name LIKE ? OR p.text LIKE ?)
ORDER BY t.updated_at DESC
LIMIT ? OFFSET ?`,
[like, like, limit, offset],
)
return rows.map((r) => ({
id: r.id,
name: r.name,
poster: r.poster,
forum_id: r.forum_id,
forum_name: r.forum_name,
updated_at: r.updated_at ? new Date(r.updated_at).toISOString() : null,
}))
} catch {
return []