From 7ecb70062df3ae9656c2d354a6100f8345888ef7 Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Thu, 14 May 2026 00:34:22 +0200 Subject: [PATCH] reconvert all emojis to webp --- config/magick-policy/policy.xml | 48 +++++++++++++ scripts/reconvert_emojis.mjs | 119 ++++++++++++++++++++++++++++++++ src/emoji_upload_handler.mjs | 49 ++++++++++--- src/inc/routes/emojis.mjs | 82 +++++++++++++++++++++- views/admin/emojis.html | 39 +++++++++++ 5 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 config/magick-policy/policy.xml create mode 100644 scripts/reconvert_emojis.mjs diff --git a/config/magick-policy/policy.xml b/config/magick-policy/policy.xml new file mode 100644 index 0000000..c55bf6f --- /dev/null +++ b/config/magick-policy/policy.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + +]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/reconvert_emojis.mjs b/scripts/reconvert_emojis.mjs new file mode 100644 index 0000000..136f81c --- /dev/null +++ b/scripts/reconvert_emojis.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +/** + * Reconvert all existing emoji images to WebP. + * - Converts non-WebP files (PNG, GIF, JPG, etc.) to lossless WebP via ImageMagick. + * - Keeps transparency. Handles animated GIFs via -coalesce. + * - Updates the `url` column in `custom_emojis` to the new .webp path. + * - Deletes the original file after successful conversion. + * + * Usage: + * STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node scripts/reconvert_emojis.mjs + */ + +import db from "../src/inc/sql.mjs"; +import cfg from "../src/inc/config.mjs"; +import { promises as fs } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { execFile as _execFile } from "child_process"; +import { promisify } from "util"; + +const execFile = promisify(_execFile); + +// Custom IM policy that raises list-length from 128 → 65536 to handle large animated GIFs. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MAGICK_POLICY_PATH = path.resolve(__dirname, '../config/magick-policy'); +const magickEnv = { ...process.env, MAGICK_CONFIGURE_PATH: MAGICK_POLICY_PATH }; + +const emojisDir = cfg.paths.emojis; +console.log(`[reconvert_emojis] Emoji directory: ${emojisDir}`); + +let converted = 0; +let skipped = 0; +let errors = 0; + +try { + const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id`; + console.log(`[reconvert_emojis] Found ${emojis.length} emojis in DB.\n`); + + for (const emoji of emojis) { + const { id, name, url } = emoji; + + // Only handle locally hosted emojis (not external URLs) + if (!url || !url.startsWith('/s/emojis/')) { + console.log(`[${id}] "${name}" — skipping (external URL: ${url})`); + skipped++; + continue; + } + + const filename = path.basename(url); + const ext = path.extname(filename).toLowerCase().replace('.', ''); + + if (ext === 'webp') { + // Verify the file actually exists, then skip + const fullPath = path.join(emojisDir, filename); + try { + await fs.access(fullPath); + console.log(`[${id}] "${name}" — already WebP, skipping.`); + } catch { + console.warn(`[${id}] "${name}" — already WebP in DB but file missing: ${fullPath}`); + } + skipped++; + continue; + } + + const originalPath = path.join(emojisDir, filename); + const webpFilename = filename.replace(/\.[^.]+$/, '.webp'); + const webpPath = path.join(emojisDir, webpFilename); + const newUrl = `/s/emojis/${webpFilename}`; + + try { + await fs.access(originalPath); + } catch { + console.warn(`[${id}] "${name}" — original file missing: ${originalPath}, skipping.`); + skipped++; + continue; + } + + try { + console.log(`[${id}] "${name}" — converting ${ext.toUpperCase()} → WebP …`); + // -coalesce: handles animated GIFs properly + // -quality 80: lossy WebP — ~50-70% smaller than GIF, transparency preserved + await execFile('magick', [originalPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv }); + + // Verify output exists and is non-empty + const stat = await fs.stat(webpPath); + if (!stat || stat.size === 0) throw new Error('Output file is empty'); + + // Update DB + await db`UPDATE custom_emojis SET url = ${newUrl} WHERE id = ${id}`; + + // Remove original + await fs.unlink(originalPath); + + console.log(`[${id}] "${name}" — ✓ converted (${(stat.size / 1024).toFixed(1)} KB) → ${newUrl}`); + converted++; + } catch (err) { + console.error(`[${id}] "${name}" — ✗ FAILED: ${err.message}`); + // Clean up partial output if it exists + await fs.unlink(webpPath).catch(() => {}); + errors++; + } + } + + // Notify connected clients of updated emoji list + if (converted > 0) { + await db`NOTIFY emojis_updated, '{}'`; + console.log(`\n[reconvert_emojis] Notified clients of emoji update.`); + } + + console.log(`\n[reconvert_emojis] Done.`); + console.log(` Converted : ${converted}`); + console.log(` Skipped : ${skipped}`); + console.log(` Errors : ${errors}`); + + process.exit(errors > 0 ? 1 : 0); +} catch (err) { + console.error('[reconvert_emojis] Fatal error:', err); + process.exit(1); +} diff --git a/src/emoji_upload_handler.mjs b/src/emoji_upload_handler.mjs index 5c4ace3..0360bbc 100644 --- a/src/emoji_upload_handler.mjs +++ b/src/emoji_upload_handler.mjs @@ -4,6 +4,16 @@ import lib from "./inc/lib.mjs"; import cfg from "./inc/config.mjs"; import { parseMultipart, collectBody } from "./inc/multipart.mjs"; import path from "path"; +import { fileURLToPath } from "url"; +import { execFile as _execFile } from "child_process"; +import { promisify } from "util"; + +const execFile = promisify(_execFile); + +// Custom IM policy that raises list-length from 128 → 65536 to handle large animated GIFs. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MAGICK_POLICY_PATH = path.resolve(__dirname, '../config/magick-policy'); +const magickEnv = { ...process.env, MAGICK_CONFIGURE_PATH: MAGICK_POLICY_PATH }; const sendJson = (res, data, code = 200) => { res.writeHead(code, { 'Content-Type': 'application/json' }); @@ -70,20 +80,37 @@ 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 extMatch = file.filename.match(/\.([a-z0-9]+)$/i); - const ext = extMatch ? extMatch[1].toLowerCase() : 'png'; - const filename = `${name}_${Math.random().toString(36).substring(7)}.${ext}`; + const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png'; + + const webpFilename = `${name}_${randSuffix}.webp`; + const webpPath = path.join(cfg.paths.emojis, webpFilename); + + if (originalExt === 'webp') { + // Already WebP — write directly + console.error(`[BOOT] [EMOJI HANDLER] Writing WebP directly to: ${webpPath} (Size: ${file.data.length})`); + await fs.writeFile(webpPath, file.data); + } else { + // Write original to a temp file, then convert to WebP via magick + const tmpFilename = `${name}_${randSuffix}_tmp.${originalExt}`; + const tmpPath = path.join(cfg.paths.emojis, tmpFilename); + console.error(`[BOOT] [EMOJI HANDLER] Writing temp file: ${tmpPath} (Size: ${file.data.length})`); + await fs.writeFile(tmpPath, file.data); + try { + // -coalesce handles animated GIFs; quality 80 = lossy WebP (good visual quality, ~50-70% smaller than GIF) + await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv }); + console.error(`[BOOT] [EMOJI HANDLER] Converted to WebP: ${webpPath}`); + } finally { + await fs.unlink(tmpPath).catch(() => {}); + } + } - // Emojis go to public/s/emojis - const filePath = path.join(cfg.paths.emojis, filename); - console.error(`[BOOT] [EMOJI HANDLER] Writing file to: ${filePath} (Size: ${file.data.length})`); - await fs.writeFile(filePath, file.data); - // Verify write - const exists = (await fs.stat(filePath)).size > 0; - if (!exists) throw new Error("File write verification failed"); - - url = `/s/emojis/${filename}`; + const stat = await fs.stat(webpPath); + if (!stat || stat.size === 0) throw new Error("File write/conversion verification failed"); + + url = `/s/emojis/${webpFilename}`; } if (!url) { diff --git a/src/inc/routes/emojis.mjs b/src/inc/routes/emojis.mjs index b20ff34..3ddb6cf 100644 --- a/src/inc/routes/emojis.mjs +++ b/src/inc/routes/emojis.mjs @@ -1,6 +1,18 @@ import db from "../sql.mjs"; - import lib from "../lib.mjs"; +import cfg from "../config.mjs"; +import { promises as fs } from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { execFile as _execFile } from "child_process"; +import { promisify } from "util"; + +const execFile = promisify(_execFile); + +// Custom IM policy that raises list-length from 128 → 65536 to handle large animated GIFs. +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MAGICK_POLICY_PATH = path.resolve(__dirname, '../../../config/magick-policy'); +const magickEnv = { ...process.env, MAGICK_CONFIGURE_PATH: MAGICK_POLICY_PATH }; export default (router, tpl) => { @@ -25,7 +37,75 @@ export default (router, tpl) => { } }); + // Reconvert all existing emoji files to WebP (Admin only) + router.post('/api/v2/admin/emojis/reconvert', async (req, res) => { + if (!req.session || !req.session.admin) { + return res.reply({ code: 403, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: "Forbidden" }) }); + } + const csrfToken = req.headers['x-csrf-token']; + if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) { + return res.reply({ code: 403, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) }); + } + try { + const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id`; + const emojisDir = cfg.paths.emojis; + let converted = 0, skipped = 0, errors = 0; + const errorList = []; + + for (const emoji of emojis) { + const { id, name, url } = emoji; + + // Only handle locally hosted emojis + if (!url || !url.startsWith('/s/emojis/')) { skipped++; continue; } + + const filename = path.basename(url); + const ext = path.extname(filename).toLowerCase().replace('.', ''); + + if (ext === 'webp') { skipped++; continue; } + + const originalPath = path.join(emojisDir, filename); + const webpFilename = filename.replace(/\.[^.]+$/, '.webp'); + const webpPath = path.join(emojisDir, webpFilename); + const newUrl = `/s/emojis/${webpFilename}`; + + try { + await fs.access(originalPath); + } catch { + console.warn(`[EMOJI RECONVERT] Missing file for emoji "${name}": ${originalPath}`); + skipped++; + continue; + } + + try { + await execFile('magick', [originalPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv }); + const stat = await fs.stat(webpPath); + if (!stat || stat.size === 0) throw new Error('Output file is empty'); + await db`UPDATE custom_emojis SET url = ${newUrl} WHERE id = ${id}`; + await fs.unlink(originalPath); + console.log(`[EMOJI RECONVERT] Converted "${name}": ${filename} → ${webpFilename}`); + converted++; + } catch (err) { + console.error(`[EMOJI RECONVERT] Failed "${name}":`, err.message); + await fs.unlink(webpPath).catch(() => {}); + errorList.push(`${name}: ${err.message}`); + errors++; + } + } + + if (converted > 0) { + await db`NOTIFY emojis_updated, '{}'`; + } + + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: true, converted, skipped, errors, errorList }) + }); + } catch (e) { + console.error('[EMOJI RECONVERT] Fatal error:', e); + return res.reply({ code: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: e.message }) }); + } + }); // Delete emoji (Admin only) router.post(/\/api\/v2\/admin\/emojis\/(?\d+)\/delete/, async (req, res) => { diff --git a/views/admin/emojis.html b/views/admin/emojis.html index fdb0f41..9536b19 100644 --- a/views/admin/emojis.html +++ b/views/admin/emojis.html @@ -24,6 +24,12 @@ +
+ + +
@@ -123,6 +129,39 @@ const btnAddEmoji = document.getElementById('add-emoji'); if (btnAddEmoji) btnAddEmoji.addEventListener('click', addEmoji); + + // Reconvert all emojis to WebP + const reconvertEmojis = async () => { + const btn = document.getElementById('reconvert-webp'); + const status = document.getElementById('reconvert-status'); + if (!btn || !status) return; + if (!confirm('Reconvert all non-WebP emojis to WebP? This will delete the originals.')) return; + + btn.disabled = true; + status.textContent = '\u23F3 Converting\u2026'; + + try { + const csrf = '{{ csrf_token }}'; + const res = await fetch('/api/v2/admin/emojis/reconvert', { + method: 'POST', + headers: { 'X-CSRF-Token': csrf, 'X-Requested-With': 'XMLHttpRequest' } + }); + const result = await res.json(); + if (result.success) { + status.textContent = '\u2705 Done \u2014 converted: ' + result.converted + ', skipped: ' + result.skipped + ', errors: ' + result.errors; + if (result.converted > 0) loadEmojis(); + } else { + status.textContent = '\u274C Failed: ' + (result.message || 'Unknown error'); + } + } catch (err) { + status.textContent = '\u274C Error: ' + err.message; + } finally { + btn.disabled = false; + } + }; + + const btnReconvert = document.getElementById('reconvert-webp'); + if (btnReconvert) btnReconvert.addEventListener('click', reconvertEmojis); // Live Update Listener (SSE dispatched via f0ckm.js) document.addEventListener('f0ck:emojis_updated', loadEmojis);