287 lines
12 KiB
JavaScript
287 lines
12 KiB
JavaScript
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/<name>).
|
|
* 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 = [];
|
|
|
|
// Derive a slug from the pack's display name (admin-supplied > Telegram title > URL slug)
|
|
// e.g. "My Cool Pack" → "my_cool_pack"
|
|
const safeName = packTitle
|
|
.replace(/[^a-z0-9]/gi, '_')
|
|
.toLowerCase()
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_|_$/g, '') || tgName.replace(/[^a-z0-9_]/gi, '_').toLowerCase();
|
|
|
|
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: <pack_display_slug>_<padded_index>
|
|
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
|
|
});
|
|
};
|