From a7c811025e9641fa385de96858ee924a04f6ab5c Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Wed, 15 Jul 2026 22:56:26 +0200 Subject: [PATCH] telegram stickers v1 --- migrations/add_sticker_packs.sql | 53 ++++ public/s/css/f0ckm.css | 101 ++++++- public/s/js/comments.js | 174 ++++++++++-- src/inc/routes/emojis.mjs | 163 ++++++++++- src/index.mjs | 11 +- src/sticker_pack_handler.mjs | 279 +++++++++++++++++++ views/admin/emojis.html | 452 +++++++++++++++++++++---------- 7 files changed, 1065 insertions(+), 168 deletions(-) create mode 100644 migrations/add_sticker_packs.sql create mode 100644 src/sticker_pack_handler.mjs diff --git a/migrations/add_sticker_packs.sql b/migrations/add_sticker_packs.sql new file mode 100644 index 0000000..0bb5183 --- /dev/null +++ b/migrations/add_sticker_packs.sql @@ -0,0 +1,53 @@ +-- Migration: Add sticker_packs table and link custom_emojis to packs +-- Run this against the f0ckm database (idempotent — safe to run multiple times) + +-- Create sticker_packs table +CREATE TABLE IF NOT EXISTS public.sticker_packs ( + id serial PRIMARY KEY, + name text NOT NULL, + tg_name text UNIQUE, + tg_title text, + thumb_url text, + sticker_count integer DEFAULT 0, + created_at timestamp with time zone DEFAULT now() +); + +ALTER TABLE public.sticker_packs OWNER TO f0ckm; + +-- Add pack_id column to custom_emojis (nullable: NULL = standalone emoji, no pack) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'custom_emojis' + AND column_name = 'pack_id' + ) THEN + ALTER TABLE public.custom_emojis + ADD COLUMN pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL; + RAISE NOTICE 'pack_id column added to custom_emojis'; + ELSE + RAISE NOTICE 'pack_id column already exists on custom_emojis — skipping'; + END IF; +END +$$; + +-- Add thumb_url column to sticker_packs if missing +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'sticker_packs' + AND column_name = 'thumb_url' + ) THEN + ALTER TABLE public.sticker_packs ADD COLUMN thumb_url text; + RAISE NOTICE 'thumb_url column added to sticker_packs'; + ELSE + RAISE NOTICE 'thumb_url already exists on sticker_packs — skipping'; + END IF; +END +$$; + +-- Index for fast pack lookups +CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id); diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 37bf49e..0d19a24 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -5638,6 +5638,15 @@ body[type='login'] { margin-top: 5px; } +/* Tabbed mode — switched to column layout by JS when packs are present */ +.emoji-picker.has-tabs { + max-height: 200px; + overflow: hidden; + flex-direction: column; + flex-wrap: nowrap; + padding: 0; +} + .emoji-picker img { width: 60px; height: 60px; @@ -5653,6 +5662,95 @@ body[type='login'] { background: rgba(255, 255, 255, 0.1); } +/* ── Tabbed picker ── */ +.emoji-picker-tabs { + display: flex; + align-items: center; + gap: 0; + overflow-x: auto; + overflow-y: hidden; + flex-shrink: 0; + background: rgba(0,0,0,0.25); + border-bottom: 1px solid rgba(255,255,255,0.07); + scrollbar-width: none; + padding: 0 2px; +} +.emoji-picker-tabs::-webkit-scrollbar { display: none; } + +.ep-tab { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + flex-shrink: 0; + border: none; + background: transparent; + cursor: pointer; + opacity: 0.55; + border-radius: 6px; + padding: 4px; +} + +.ep-tab:hover { opacity: 1; background: rgba(255,255,255,0.07); } + +.ep-tab.active { + opacity: 1; + background: rgba(255,255,255,0.12); + border-bottom: 2px solid var(--accent, #fff); +} + +.ep-tab img, +.ep-tab video { + width: 28px; + height: 28px; + object-fit: contain; + pointer-events: none; +} + +.emoji-picker-grid { + display: flex; + flex-wrap: wrap; + gap: 0; + overflow-y: auto; + flex: 1; + padding: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(255,255,255,0.2) transparent; +} + +.emoji-picker-grid img, +.emoji-picker-grid video { + width: 52px; + height: 52px; + object-fit: contain; + cursor: pointer; + padding: 4px; + border-radius: 4px; +} + +.emoji-picker-grid img:hover, +.emoji-picker-grid video:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* video emojis in comment content */ +.comment-content video.emoji, +video.emoji { + height: 200px; + vertical-align: middle; + display: inline-block; +} + +/* video in emoji autocomplete dropdown */ +.emoji-ac-item video { + width: 24px; + height: 24px; + object-fit: contain; + flex-shrink: 0; +} + + /* User Mentions */ .mention { text-decoration: none !important; @@ -13902,7 +14000,8 @@ body.layout-modern .xd-score-wrapper { transform: translateY(-2px); } -.emoji-card .emoji-preview { +.emoji-card .emoji-preview, +.emoji-card video.emoji-preview { height: 80px; max-width: 90px; object-fit: contain; diff --git a/public/s/js/comments.js b/public/s/js/comments.js index 429c389..c8e56e1 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -263,7 +263,9 @@ class CommentSystem { this.customEmojis[e.name] = e.url; }); CommentSystem.emojiCache = this.customEmojis; - _f0ckDebug('Loaded Emojis:', this.customEmojis); + // Store pack structure for tabbed picker + CommentSystem.emojiPacks = data.packs || []; + _f0ckDebug('Loaded Emojis:', this.customEmojis, 'Packs:', CommentSystem.emojiPacks); if (this.container && this.lastData) { const state = this.saveState(); @@ -304,7 +306,11 @@ class CommentSystem { renderEmoji(match, name) { // _f0ckDebug('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list'); if (this.customEmojis && this.customEmojis[name]) { - return `${name}`; + const url = this.customEmojis[name]; + if (url.endsWith('.webm')) { + return ``; + } + return `:${name}:`; } return match; } @@ -559,6 +565,7 @@ class CommentSystem { contentEl.dataset.raw = this.escapeHtml(fullContent); contentEl.innerHTML = this.renderCommentContent(fullContent, commentId); CommentSystem.autoplayConvertedGifs(contentEl); + CommentSystem.playEmojiVideos(contentEl); } catch (e) { _f0ckDebug('[CommentSystem] _patchLiveCommentContent failed:', e); } @@ -583,6 +590,7 @@ class CommentSystem { if (contentEl) { contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id); CommentSystem.autoplayConvertedGifs(contentEl); + CommentSystem.playEmojiVideos(contentEl); // Flash effect to draw attention el.classList.remove('new-item-fade'); @@ -1296,6 +1304,7 @@ class CommentSystem { } this.syncSubscribeButton(isSubscribed); CommentSystem.autoplayConvertedGifs(this.container); + CommentSystem.playEmojiVideos(this.container); // Attach media load listeners to re-stabilize scroll if a hash is active. // Only during the initial anchor scroll — never on subsequent renders (tab re-focus, @@ -1415,6 +1424,7 @@ class CommentSystem { _f0ckDebug(`[CommentSystem] Reconcile: Updating content for #c${id}`); contentEl.innerHTML = this.renderCommentContent(incoming.content, incoming.id); CommentSystem.autoplayConvertedGifs(contentEl); + CommentSystem.playEmojiVideos(contentEl); contentEl.dataset.raw = incoming.content; } @@ -1929,6 +1939,15 @@ class CommentSystem { }); } + static playEmojiVideos(container) { + if (!container) return; + container.querySelectorAll('video.emoji').forEach(v => { + v.play().catch(() => { + v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true }); + }); + }); + } + buildBacklinkMap(comments) { this.backlinkMap = {}; const process = (c) => { @@ -1959,7 +1978,11 @@ class CommentSystem { renderEmoji(match, name) { if (this.customEmojis && this.customEmojis[name]) { - return `${match}`; + const url = this.customEmojis[name]; + if (url.endsWith('.webm')) { + return ``; + } + return `${match}`; } return match; } @@ -3383,6 +3406,7 @@ class CommentSystem { const newEl = tmp.firstElementChild; if (newEl) { parent.replaceChild(newEl, existingReply); + CommentSystem.playEmojiVideos(newEl); requestAnimationFrame(() => { newEl.classList.add('comment-entering', 'new-item-fade'); newEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -3404,6 +3428,7 @@ class CommentSystem { if (commentEl) { repliesEl.appendChild(commentEl); this._ensureTruncationButton(commentEl, newComment.content); + CommentSystem.playEmojiVideos(commentEl); requestAnimationFrame(() => { commentEl.classList.add('comment-entering', 'new-item-fade'); commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -3421,6 +3446,7 @@ class CommentSystem { const newEl = tmp.firstElementChild; if (newEl) { parent.replaceChild(newEl, existingTop); + CommentSystem.playEmojiVideos(newEl); requestAnimationFrame(() => { newEl.classList.add('comment-entering', 'new-item-fade'); newEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -3442,6 +3468,7 @@ class CommentSystem { list.appendChild(commentEl); } this._ensureTruncationButton(commentEl, newComment.content); + CommentSystem.playEmojiVideos(commentEl); requestAnimationFrame(() => { commentEl.classList.add('comment-entering', 'new-item-fade'); commentEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); @@ -3586,6 +3613,7 @@ class CommentSystem { const cs = window.commentSystem; if (!cs) return; CommentSystem.emojiCache = null; + CommentSystem.emojiPacks = null; CommentSystem.loadingEmojis = false; cs.loadEmojis(true); // force=true: bypass page-scan, admin just changed emojis }); @@ -3797,13 +3825,24 @@ class CommentSystem { const item = document.createElement('div'); item.className = 'emoji-ac-item'; item.dataset.idx = idx; - const img = document.createElement('img'); - img.src = this.customEmojis[name]; - img.alt = name; - img.loading = 'lazy'; + const url = this.customEmojis[name]; + let preview; + if (url && url.endsWith('.webm')) { + preview = document.createElement('video'); + preview.src = url; + preview.autoplay = true; + preview.loop = true; + preview.muted = true; + preview.playsInline = true; + } else { + preview = document.createElement('img'); + preview.src = url; + preview.alt = name; + preview.loading = 'lazy'; + } const label = document.createElement('span'); label.textContent = `:${name}:`; - item.appendChild(img); + item.appendChild(preview); item.appendChild(label); item.addEventListener('mousedown', ev => { ev.preventDefault(); // don't blur textarea @@ -3978,35 +4017,120 @@ class CommentSystem { const buildPickerContent = () => { if (!picker) return; picker.innerHTML = ''; - if (this.customEmojis && Object.keys(this.customEmojis).length > 0) { + + const packs = CommentSystem.emojiPacks || []; + const hasPacks = packs.length > 0; + + if (!this.customEmojis || Object.keys(this.customEmojis).length === 0) { + picker.innerHTML = '
No emojis found
'; + return; + } + + // Helper: create img or video element for a given emoji URL + const makeEmojiEl = (url, name) => { + let el; + if (url && url.endsWith('.webm')) { + el = document.createElement('video'); + el.src = url; + el.autoplay = true; + el.loop = true; + el.muted = true; + el.playsInline = true; + } else { + el = document.createElement('img'); + el.src = url; + el.loading = 'lazy'; + } + el.title = `:${name}:`; + el.onerror = () => { el.style.display = 'none'; }; + return el; + }; + + if (!hasPacks) { + // Flat list — old behaviour for sites without packs + picker.classList.remove('has-tabs'); Object.keys(this.customEmojis).forEach(name => { const url = this.customEmojis[name]; - const img = document.createElement('img'); - img.src = url; - img.title = `:${name}:`; - img.loading = 'lazy'; // Use native lazy loading - - // Add error handling for failed loads - img.onerror = () => { - console.warn(`Failed to load emoji: ${name}`); - img.style.display = 'none'; - }; - - img.onclick = (ev) => { + const el = makeEmojiEl(url, name); + el.onclick = (ev) => { ev.stopPropagation(); const pos = textarea.selectionStart ?? textarea.value.length; const val = textarea.value; textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos); textarea.focus(); - // Move cursor after the inserted emoji const newPos = pos + name.length + 2; textarea.setSelectionRange(newPos, newPos); }; - picker.appendChild(img); + picker.appendChild(el); }); - } else { - picker.innerHTML = '
No emojis found
'; + return; } + + // ── Tabbed picker with sticker packs ── + picker.classList.add('has-tabs'); + + // Tab bar + const tabBar = document.createElement('div'); + tabBar.className = 'emoji-picker-tabs'; + + // Emoji grid area + const gridArea = document.createElement('div'); + gridArea.className = 'emoji-picker-grid'; + + let activeTabId = null; + + const showTab = (packId) => { + activeTabId = packId; + tabBar.querySelectorAll('.ep-tab').forEach(t => t.classList.toggle('active', t.dataset.packId === String(packId ?? 'null'))); + + gridArea.innerHTML = ''; + const pack = packs.find(p => String(p.id ?? null) === String(packId ?? null)) || packs[0]; + if (!pack) return; + + pack.emojis.forEach(({ name, url }) => { + const el = makeEmojiEl(url, name); + el.onclick = (ev) => { + ev.stopPropagation(); + const pos = textarea.selectionStart ?? textarea.value.length; + const val = textarea.value; + textarea.value = val.slice(0, pos) + `:${name}:` + val.slice(pos); + textarea.focus(); + const newPos = pos + name.length + 2; + textarea.setSelectionRange(newPos, newPos); + }; + gridArea.appendChild(el); + }); + }; + + // Build tab buttons + packs.forEach((pack, i) => { + const tab = document.createElement('button'); + tab.className = 'ep-tab'; + tab.dataset.packId = String(pack.id ?? null); + tab.title = pack.name || 'Emojis'; + tab.type = 'button'; + // Tab icon — always use a static img (thumb_url from import, or first non-video emoji) + if (pack.emojis && pack.emojis.length > 0) { + const iconUrl = pack.thumb_url + || (pack.emojis.find(e => !e.url.endsWith('.webm')) || pack.emojis[0]).url; + const icon = document.createElement('img'); + icon.src = iconUrl; + icon.alt = pack.name; + icon.loading = 'lazy'; + tab.appendChild(icon); + } else { + tab.textContent = '☺'; + } + tab.addEventListener('mousedown', e => e.preventDefault()); + tab.addEventListener('click', () => showTab(pack.id ?? null)); + tabBar.appendChild(tab); + }); + + picker.appendChild(tabBar); + picker.appendChild(gridArea); + + // Show first pack by default + showTab(packs[0]?.id ?? null); }; trigger.addEventListener('click', (e) => { diff --git a/src/inc/routes/emojis.mjs b/src/inc/routes/emojis.mjs index 81ddf94..0797dd6 100644 --- a/src/inc/routes/emojis.mjs +++ b/src/inc/routes/emojis.mjs @@ -23,15 +23,170 @@ export default (router, tpl) => { }); }); - // List all emojis (Public) + // List all emojis — returns both a flat list and a grouped-by-pack structure router.get('/api/v2/emojis', async (req, res) => { try { - const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id DESC`; + // Try the full pack-aware query first; fall back if pack_id column doesn't exist yet + let emojis; + let hasPacks = true; + try { + emojis = await db` + SELECT e.id, e.name, e.url, e.pack_id, + p.name as pack_name, p.tg_name, p.tg_title, p.thumb_url as pack_thumb_url + FROM custom_emojis e + LEFT JOIN sticker_packs p ON p.id = e.pack_id + ORDER BY e.pack_id NULLS FIRST, e.id ASC + `; + } catch (colErr) { + // column pack_id or thumb_url may not exist — try without thumb_url, then flat + try { + emojis = await db` + SELECT e.id, e.name, e.url, e.pack_id, + p.name as pack_name, p.tg_name, p.tg_title, NULL as pack_thumb_url + FROM custom_emojis e + LEFT JOIN sticker_packs p ON p.id = e.pack_id + ORDER BY e.pack_id NULLS FIRST, e.id ASC + `; + } catch (_) { + // pack_id column missing entirely — serve flat list + console.warn('[EMOJIS] pack_id column missing, serving flat list'); + hasPacks = false; + emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id ASC`; + } + } + + // Flat list (backwards compat) + const flat = emojis.map(e => ({ id: e.id, name: e.name, url: e.url, pack_id: e.pack_id ?? null })); + + if (!hasPacks) { + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: true, emojis: flat, packs: [] }) + }); + } + + // Grouped by pack + const packsMap = new Map(); + // "No pack" group for standalone emojis + packsMap.set(null, { id: null, name: 'Custom Emojis', tg_name: null, emojis: [] }); + + for (const e of emojis) { + if (e.pack_id && !packsMap.has(e.pack_id)) { + packsMap.set(e.pack_id, { + id: e.pack_id, + name: e.pack_name || e.tg_title || `Pack #${e.pack_id}`, + tg_name: e.tg_name, + thumb_url: e.pack_thumb_url || null, + emojis: [] + }); + } + const packKey = e.pack_id ?? null; + packsMap.get(packKey).emojis.push({ id: e.id, name: e.name, url: e.url }); + } + + // Filter out empty groups + const packs = [...packsMap.values()].filter(p => p.emojis.length > 0); + return res.reply({ headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ success: true, emojis }) + body: JSON.stringify({ success: true, emojis: flat, packs }) }); } catch (e) { + + console.error(e); + return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) }); + } + }); + + // List sticker packs (Admin) + router.get('/api/v2/admin/sticker-packs', 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" }) }); + } + try { + let packs; + try { + packs = await db` + SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, sp.thumb_url, sp.sticker_count, sp.created_at, + count(e.id)::int as actual_count + FROM sticker_packs sp + LEFT JOIN custom_emojis e ON e.pack_id = sp.id + GROUP BY sp.id + ORDER BY sp.created_at DESC + `; + } catch (_) { + // thumb_url column may not exist yet — fall back without it + packs = await db` + SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, NULL as thumb_url, sp.sticker_count, sp.created_at, + count(e.id)::int as actual_count + FROM sticker_packs sp + LEFT JOIN custom_emojis e ON e.pack_id = sp.id + GROUP BY sp.id + ORDER BY sp.created_at DESC + `; + } + return res.reply({ + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ success: true, packs }) + }); + } catch (e) { + console.error(e); + return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) }); + } + }); + + + // Delete a sticker pack and all its emojis (Admin) + router.delete(/\/api\/v2\/admin\/sticker-packs\/(?\d+)/, async (req, res) => { + if (!req.session || !req.session.admin) { + return res.reply({ code: 403, 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, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) }); + } + const id = req.params.id; + try { + // Fetch emoji file URLs before deletion for filesystem cleanup + const emojiFiles = await db`SELECT url FROM custom_emojis WHERE pack_id = ${id}`; + await db`DELETE FROM custom_emojis WHERE pack_id = ${id}`; + await db`DELETE FROM sticker_packs WHERE id = ${id}`; + + // Clean up local files + for (const e of emojiFiles) { + if (e.url && e.url.startsWith('/s/emojis/')) { + const filename = path.basename(e.url); + await fs.unlink(path.join(cfg.paths.emojis, filename)).catch(() => {}); + } + } + + await db`NOTIFY emojis_updated, '{}'`; + return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) }); + } catch (e) { + console.error(e); + return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) }); + } + }); + + // Rename a sticker pack (Admin) + router.post(/\/api\/v2\/admin\/sticker-packs\/(?\d+)\/rename/, async (req, res) => { + if (!req.session || !req.session.admin) { + return res.reply({ code: 403, 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, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) }); + } + const id = req.params.id; + const newName = (req.body?.name || req.post?.name || '').trim(); + if (!newName) { + return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name is required' }) }); + } + try { + await db`UPDATE sticker_packs SET name = ${newName} WHERE id = ${id}`; + await db`NOTIFY emojis_updated, '{}'`; + return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) }); + } catch (e) { console.error(e); return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) }); } @@ -132,5 +287,7 @@ export default (router, tpl) => { } }); + // Edit emoji (Admin only) — handled by bypass middleware in index.mjs + return router; }; diff --git a/src/index.mjs b/src/index.mjs index 30fab78..684f997 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -13,6 +13,7 @@ import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs"; import { handleRethumbUpload } from "./rethumb_handler.mjs"; import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs"; import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs"; +import { handleImportTelegramPack } from "./sticker_pack_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"; @@ -784,7 +785,7 @@ process.on('uncaughtException', err => { // because the session middleware will have completed by the time router callbacks execute. app.use(async (req, res) => { if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return; - if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload'].includes(req.url.pathname)) return; + if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return; // DM attachment upload validates CSRF internally if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return; // Hall manager routes are handled by bypass middleware with their own session auth @@ -874,6 +875,14 @@ process.on('uncaughtException', err => { } }); + // Bypass middleware for Telegram sticker pack import + app.use(async (req, res) => { + if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/sticker-packs/import') { + await handleImportTelegramPack(req, res); + req.url.pathname = '/handled_sticker_pack_import_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/src/sticker_pack_handler.mjs b/src/sticker_pack_handler.mjs new file mode 100644 index 0000000..375eb87 --- /dev/null +++ b/src/sticker_pack_handler.mjs @@ -0,0 +1,279 @@ +import { promises as fs } from "fs"; +import db from "./inc/sql.mjs"; +import lib from "./inc/lib.mjs"; +import cfg from "./inc/config.mjs"; +import { collectBody } from "./inc/multipart.mjs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { execFile as _execFile } from "child_process"; +import { promisify } from "util"; +import crypto from "crypto"; +import fetch from "flumm-fetch"; // used for JSON API calls only +import https from "https"; +import http from "http"; + +const execFile = promisify(_execFile); + +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' }); + res.end(JSON.stringify(data)); +}; + +const requireAdmin = async (req, res) => { + 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) { + sendJson(res, { success: false, message: 'Unauthorized' }, 403); + return null; + } + const csrfToken = req.headers['x-csrf-token']; + if (user[0].csrf_token && (!csrfToken || csrfToken !== user[0].csrf_token)) { + sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403); + return null; + } + return user[0]; +}; + +/** + * Download a file from a URL and return it as a raw Buffer. + * Uses Node's built-in https/http directly — flumm-fetch's .buffer() method + * calls setEncoding('utf8') which corrupts binary image data. + */ +const downloadBuffer = (url) => { + return new Promise((resolve, reject) => { + const mod = url.startsWith('https://') ? https : http; + mod.get(url, (res) => { + if (res.statusCode >= 400) { + res.resume(); // drain the socket + return reject(new Error('HTTP ' + res.statusCode + ' fetching ' + url)); + } + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve(Buffer.concat(chunks))); + res.on('error', reject); + }).on('error', reject); + }); +}; + +/** + * Convert a buffer to WebP and write to the emojis dir. + * Returns the relative URL path. + */ +const saveAsWebP = async (buffer, originalExt) => { + const randSuffix = crypto.randomBytes(24).toString('hex'); + const webpFilename = randSuffix + '.webp'; + const webpPath = path.join(cfg.paths.emojis, webpFilename); + + if (originalExt === 'webp') { + await fs.writeFile(webpPath, buffer); + } else { + const tmpFilename = randSuffix + '_tmp.' + originalExt; + const tmpPath = path.join(cfg.paths.emojis, tmpFilename); + await fs.writeFile(tmpPath, buffer); + 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('WebP conversion produced empty file'); + + return '/s/emojis/' + webpFilename; +}; + +/** + * Save a raw buffer directly to the emojis dir with the given extension (no re-encoding). + * Used for video stickers (.webm) which browsers can play natively. + */ +const saveRaw = async (buffer, ext) => { + const randSuffix = crypto.randomBytes(24).toString('hex'); + const filename = randSuffix + '.' + ext; + const filePath = path.join(cfg.paths.emojis, filename); + await fs.writeFile(filePath, buffer); + const stat = await fs.stat(filePath); + if (!stat || stat.size === 0) throw new Error('Saved file is empty'); + return '/s/emojis/' + filename; +}; + +/** + * Import a Telegram sticker pack given its name (short name from t.me/addstickers/). + * Creates a sticker_pack record and downloads all stickers as custom_emojis. + */ +export const handleImportTelegramPack = async (req, res) => { + const user = await requireAdmin(req, res); + if (!user) return; + + let body = {}; + try { + const raw = await collectBody(req); + body = JSON.parse(raw.toString()); + } catch (e) { + return sendJson(res, { success: false, message: 'Invalid JSON body' }, 400); + } + + const { pack_name, display_name } = body; + if (!pack_name || typeof pack_name !== 'string') { + return sendJson(res, { success: false, message: 'pack_name is required' }, 400); + } + + // Get Telegram bot token + const tgClient = cfg.clients.find(c => c.type === 'tg'); + if (!tgClient || !tgClient.token) { + return sendJson(res, { success: false, message: 'Telegram bot not configured' }, 500); + } + const botToken = tgClient.token; + + const tgName = pack_name.trim().replace(/^https?:\/\/t\.me\/addstickers\//i, ''); + if (!tgName) { + return sendJson(res, { success: false, message: 'Could not extract pack name from URL' }, 400); + } + + console.log(`[STICKER PACK] Importing Telegram pack: ${tgName}`); + + // Check if pack already imported + const existing = await db`SELECT id FROM sticker_packs WHERE tg_name = ${tgName} LIMIT 1`; + if (existing.length > 0) { + return sendJson(res, { success: false, message: `Pack "${tgName}" was already imported (ID ${existing[0].id})` }, 409); + } + + // Call Telegram API + let stickerSet; + try { + const apiRes = await fetch(`https://api.telegram.org/bot${botToken}/getStickerSet?name=${encodeURIComponent(tgName)}`); + const apiData = await apiRes.json(); + if (!apiData.ok) { + return sendJson(res, { success: false, message: `Telegram API error: ${apiData.description || 'unknown'}` }, 400); + } + stickerSet = apiData.result; + } catch (e) { + console.error('[STICKER PACK] Telegram API fetch failed:', e); + return sendJson(res, { success: false, message: `Failed to contact Telegram API: ${e.message}` }, 500); + } + + const packTitle = display_name?.trim() || stickerSet.title || tgName; + const stickers = stickerSet.stickers || []; + + console.log(`[STICKER PACK] Found ${stickers.length} stickers in "${stickerSet.title}"`); + + // Create pack record + const [newPack] = await db` + INSERT INTO sticker_packs (name, tg_name, tg_title, sticker_count) + VALUES (${packTitle}, ${tgName}, ${stickerSet.title}, ${stickers.length}) + RETURNING id, name, tg_name, tg_title + `; + + // Generate a static WebP thumbnail for the pack from the first sticker's Telegram thumbnail. + // Telegram always provides a static webp thumbnail for every sticker regardless of type. + let packThumbUrl = null; + const firstThumb = stickers[0]?.thumbnail; + if (firstThumb && firstThumb.file_id) { + try { + const tRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(firstThumb.file_id)); + const tData = await tRes.json(); + if (tData.ok && tData.result && tData.result.file_path) { + const tBuf = await downloadBuffer('https://api.telegram.org/file/bot' + botToken + '/' + tData.result.file_path); + const tExt = (tData.result.file_path.match(/\.([a-z0-9]+)$/i) || ['', 'webp'])[1].toLowerCase(); + packThumbUrl = await saveAsWebP(tBuf, tExt); + await db`UPDATE sticker_packs SET thumb_url = ${packThumbUrl} WHERE id = ${newPack.id}`; + console.log('[STICKER PACK] Pack thumbnail saved: ' + packThumbUrl); + } + } catch (e) { + console.warn('[STICKER PACK] Could not save pack thumbnail:', e.message); + } + } + let imported = 0; + let failed = 0; + const errors = []; + + for (let i = 0; i < stickers.length; i++) { + const sticker = stickers[i]; + try { + // Skip only animated TGS stickers (Lottie format — cannot be displayed as-is) + const isAnimated = sticker.is_animated || false; + const isVideo = sticker.is_video || false; + if (isAnimated) { + console.log('[STICKER PACK] Skipping animated (TGS) sticker ' + (i + 1)); + continue; + } + + const fileId = sticker.file_id; + + // Resolve the download URL via getFile + const fileRes = await fetch('https://api.telegram.org/bot' + botToken + '/getFile?file_id=' + encodeURIComponent(fileId)); + const fileData = await fileRes.json(); + if (!fileData.ok || !fileData.result || !fileData.result.file_path) { + throw new Error('getFile failed: ' + (fileData.description || 'no file_path')); + } + + const filePath = fileData.result.file_path; + const extMatch = filePath.match(/\.([a-z0-9]+)$/i); + const ext = extMatch ? extMatch[1].toLowerCase() : 'webp'; + + const downloadUrl = 'https://api.telegram.org/file/bot' + botToken + '/' + filePath; + const buffer = await downloadBuffer(downloadUrl); + + // Generate emoji name from pack name + index + const safeName = tgName.replace(/[^a-z0-9_]/gi, '_').toLowerCase(); + const emojiName = safeName + '_' + String(i + 1).padStart(3, '0'); + + // Check for name conflict + const nameConflict = await db`SELECT id FROM custom_emojis WHERE name = ${emojiName} LIMIT 1`; + const finalName = nameConflict.length > 0 ? emojiName + '_' + crypto.randomBytes(3).toString('hex') : emojiName; + + // Save: video stickers (.webm) stored as-is; static stickers converted to WebP + let savedUrl; + if (isVideo) { + savedUrl = await saveRaw(buffer, ext); // keeps original webm, no re-encoding + console.log('[STICKER PACK] Saved video sticker ' + (i + 1) + ' as ' + ext); + } else { + savedUrl = await saveAsWebP(buffer, ext); + } + + await db` + INSERT INTO custom_emojis (name, url, pack_id) + VALUES (${finalName}, ${savedUrl}, ${newPack.id}) + `; + + imported++; + console.log('[STICKER PACK] Imported sticker ' + (i + 1) + '/' + stickers.length + ': ' + finalName + ' (' + (isVideo ? 'video' : 'static') + ')'); + + // Small delay to avoid Telegram rate limits + if (i < stickers.length - 1) await new Promise(function(resolve) { setTimeout(resolve, 50); }); + + } catch (e) { + console.error('[STICKER PACK] Failed sticker ' + (i + 1) + ':', e.message); + errors.push('Sticker ' + (i + 1) + ': ' + e.message); + failed++; + } + } + + // Update pack with actual imported count + await db`UPDATE sticker_packs SET sticker_count = ${imported} WHERE id = ${newPack.id}`; + + // Notify clients that emojis changed + await db`NOTIFY emojis_updated, '{}'`; + + console.log(`[STICKER PACK] Done. Imported: ${imported}, Failed: ${failed}`); + + return sendJson(res, { + success: true, + pack: { ...newPack, sticker_count: imported }, + imported, + failed, + errors + }); +}; diff --git a/views/admin/emojis.html b/views/admin/emojis.html index f709a29..863b2ef 100644 --- a/views/admin/emojis.html +++ b/views/admin/emojis.html @@ -2,54 +2,85 @@
-

Custom Emojis

+

Custom Emojis & Sticker Packs

+ +
+

+ + Import Telegram Sticker Pack +

+

+ Enter a Telegram sticker pack URL (e.g. https://t.me/addstickers/PackName) or just the short name. + All stickers will be downloaded and added as a new sticker pack. +

+
+
+ + +
+
+ + +
+ +
+ +
+ + +
+

Imported Sticker Packs

+
+
+ +
-

Add New Emoji

+ style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color); border-radius: 8px;"> +

Add Standalone Emoji

- +
- +
- +
- - -
- -
+