reconvert all emojis to webp
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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\/(?<id>\d+)\/delete/, async (req, res) => {
|
||||
|
||||
Reference in New Issue
Block a user