diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index aabb7a5..5ef8571 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -13871,8 +13871,8 @@ body.layout-modern .xd-score-wrapper { } .emoji-card .emoji-preview { - height: 48px; - max-width: 80px; + height: 80px; + max-width: 90px; object-fit: contain; image-rendering: auto; } diff --git a/src/emoji_upload_handler.mjs b/src/emoji_upload_handler.mjs index 0360bbc..1b8f822 100644 --- a/src/emoji_upload_handler.mjs +++ b/src/emoji_upload_handler.mjs @@ -7,6 +7,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { execFile as _execFile } from "child_process"; import { promisify } from "util"; +import crypto from "crypto"; const execFile = promisify(_execFile); @@ -80,11 +81,11 @@ export const handleEmojiUpload = async (req, res) => { const file = parts.file; if (file && file.data && file.data.length > 0) { - const randSuffix = Math.random().toString(36).substring(7); + const randSuffix = crypto.randomBytes(24).toString('hex'); const extMatch = file.filename.match(/\.([a-z0-9]+)$/i); const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png'; - const webpFilename = `${name}_${randSuffix}.webp`; + const webpFilename = `${randSuffix}.webp`; const webpPath = path.join(cfg.paths.emojis, webpFilename); if (originalExt === 'webp') { @@ -135,3 +136,133 @@ export const handleEmojiUpload = async (req, res) => { return sendJson(res, { success: false, message: err.message }, 500); } }; + +export const handleEmojiEdit = async (req, res) => { + // Manual Session Lookup + let user = []; + if (req.cookies && req.cookies.session) { + user = await db` + select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token + from "user_sessions" + left join "user" on "user".id = "user_sessions".user_id + where "user_sessions".session = ${lib.sha256(req.cookies.session)} + limit 1 + `; + } + + if (user.length === 0 || !user[0].admin) { + return sendJson(res, { success: false, message: 'Unauthorized' }, 403); + } + + req.session = user[0]; + + // CSRF validation + if (req.session.csrf_token) { + const csrfToken = req.headers['x-csrf-token']; + if (!csrfToken || csrfToken !== req.session.csrf_token) { + console.warn(`[CSRF] Blocked emoji edit for user ${req.session.user}. Invalid token.`); + return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403); + } + } + + const id = req.params.id; + + try { + const contentType = req.headers['content-type'] || ''; + const boundaryMatch = contentType.match(/boundary=([^;]+)/); + + if (!boundaryMatch) { + return sendJson(res, { success: false, message: 'Invalid content type' }, 400); + } + + let boundary = boundaryMatch[1].trim(); + if (boundary.startsWith('"') && boundary.endsWith('"')) { + boundary = boundary.substring(1, boundary.length - 1); + } + + const bodyBuffer = await collectBody(req); + const parts = parseMultipart(bodyBuffer, boundary); + + const name = (parts.name || '').trim().toLowerCase(); + let url = (parts.url || '').trim(); + + if (!name) { + return sendJson(res, { success: false, message: 'Emoji name is required' }, 400); + } + + if (!/^[a-z0-9_-]+$/.test(name)) { + return sendJson(res, { success: false, message: 'Invalid name. Use lowercase a-z, 0-9, _, - only.' }, 400); + } + + // Fetch the current emoji record + const current = await db`SELECT id, name, url FROM custom_emojis WHERE id = ${id} LIMIT 1`; + if (current.length === 0) { + return sendJson(res, { success: false, message: 'Emoji not found' }, 404); + } + + // Check name collision (allow keeping the same name) + if (name !== current[0].name) { + const conflict = await db`SELECT id FROM custom_emojis WHERE name = ${name} AND id != ${id} LIMIT 1`; + if (conflict.length > 0) { + return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400); + } + } + + const file = parts.file; + if (file && file.data && file.data.length > 0) { + const randSuffix = crypto.randomBytes(24).toString('hex'); + const extMatch = file.filename.match(/\.([a-z0-9]+)$/i); + const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png'; + + const webpFilename = `${randSuffix}.webp`; + const webpPath = path.join(cfg.paths.emojis, webpFilename); + + if (originalExt === 'webp') { + await fs.writeFile(webpPath, file.data); + } else { + const tmpFilename = `${name}_${randSuffix}_tmp.${originalExt}`; + const tmpPath = path.join(cfg.paths.emojis, tmpFilename); + await fs.writeFile(tmpPath, file.data); + try { + await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv }); + } finally { + await fs.unlink(tmpPath).catch(() => {}); + } + } + + const stat = await fs.stat(webpPath); + if (!stat || stat.size === 0) throw new Error('File write/conversion verification failed'); + + // Delete the old local file if it was a hosted emoji + if (current[0].url && current[0].url.startsWith('/s/emojis/')) { + const oldFilename = path.basename(current[0].url); + const oldPath = path.join(cfg.paths.emojis, oldFilename); + await fs.unlink(oldPath).catch(() => {}); + } + + url = `/s/emojis/${webpFilename}`; + } + + // If no new file and no new URL, keep the existing URL + if (!url) { + url = current[0].url; + } + + const updated = await db` + UPDATE custom_emojis + SET name = ${name}, url = ${url} + WHERE id = ${id} + RETURNING id, name, url + `; + + await db`NOTIFY emojis_updated, '{}'`; + return sendJson(res, { success: true, emoji: updated[0] }); + + } catch (err) { + if (err.code === '23505') { + return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400); + } + console.error('[EMOJI EDIT ERROR]', err); + return sendJson(res, { success: false, message: err.message }, 500); + } +}; diff --git a/src/inc/routes/emojis.mjs b/src/inc/routes/emojis.mjs index 3ddb6cf..81ddf94 100644 --- a/src/inc/routes/emojis.mjs +++ b/src/inc/routes/emojis.mjs @@ -26,7 +26,7 @@ export default (router, tpl) => { // List all emojis (Public) router.get('/api/v2/emojis', async (req, res) => { try { - const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`; + const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id DESC`; return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, emojis }) diff --git a/src/index.mjs b/src/index.mjs index dd99084..7140bda 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -12,7 +12,7 @@ import { handleUpload } from "./upload_handler.mjs"; import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs"; import { handleRethumbUpload } from "./rethumb_handler.mjs"; import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs"; -import { handleEmojiUpload } from "./emoji_upload_handler.mjs"; +import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs"; import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs"; import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; @@ -795,6 +795,16 @@ process.on('uncaughtException', err => { } }); + // Bypass middleware for emoji edits + app.use(async (req, res) => { + const editMatch = req.url.pathname.match(/^\/api\/v2\/admin\/emojis\/(\d+)\/edit$/); + if (req.method === 'POST' && editMatch) { + req.params = { id: editMatch[1] }; + await handleEmojiEdit(req, res); + req.url.pathname = '/handled_emoji_edit_bypass'; + } + }); + // Bypass middleware for hall image uploads (multipart — needs raw body) app.use(async (req, res) => { if (cfg.websrv.halls_enabled === false) return; diff --git a/views/admin/emojis.html b/views/admin/emojis.html index 9536b19..f709a29 100644 --- a/views/admin/emojis.html +++ b/views/admin/emojis.html @@ -16,43 +16,66 @@ -