openvibers/openvibe.chat Commits

Add gated server emoji API (#452)

OP OpenVibers committed 4 days ago · 5f0ace9009 · 6 files, +219 −2

* Add gated server emoji API

* Preserve emote scopes and remove deleted assets

* Merge remote-tracking branch 'origin/main' into chat-v3-emoji

* Test the server emoji API's limits, names, removal and fallback

* Keep server emoji out of personal and channel emote scopes

* Answer an oversized emoji upload in JSON

Changes

ddiff --git a/migrations/0057_server_emojis.sql b/migrations/0057_server_emojis.sql
nnew file mode 100644
iindex 0000000..5768b81
---- /dev/null
++++ b/migrations/0057_server_emojis.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++ALTER TABLE emotes ADD COLUMN server_id bigint;
++DROP INDEX idx_emotes_channel_code;
++CREATE UNIQUE INDEX idx_emotes_channel_code ON emotes(COALESCE(channel_owner_id, user_id), code) WHERE server_id IS NULL;
++CREATE INDEX idx_emotes_server ON emotes(server_id);
++CREATE UNIQUE INDEX idx_emotes_server_code ON emotes(server_id, lower(code)) WHERE server_id IS NOT NULL;
ddiff --git a/server/app.js b/server/app.js
iindex becca6f..cc5da74 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -352,6 +352,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
app.use('/api/chat/support', require('./inbox/routes').support);
// Feeds (server/feeds/): live activity (vibe coding) published with a feed's key, shown in rooms, on Live, anywhere.
app.use('/api/chat/feeds', require('./feeds/routes'));
++ app.use('/api/chat/servers', require('./chat/server-emojis'));
app.use('/api/chat/servers', require('./rooms/server-routes'));
app.use('/api/chat/commands', require('./chat/slash-commands').router);
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
ddiff --git a/server/chat/server-emojis.js b/server/chat/server-emojis.js
nnew file mode 100644
iindex 0000000..073ea00
---- /dev/null
++++ b/server/chat/server-emojis.js
@@@ -0,0 +1,57 @@
++'use strict';
++const express = require('express');
++const multer = require('multer');
++const db = require('../db/database');
++const config = require('../config');
++const media = require('../media/client');
++const servers = require('../rooms/servers');
++const { requireAuth } = require('../auth/auth');
++const router = express.Router();
++const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 512 * 1024 }, fileFilter: (_req, file, cb) => cb(null, ['image/png','image/jpeg','image/gif','image/webp'].includes(file.mimetype)) });
++const off = (res) => res.status(404).json({ error: 'Not found', code: 'emoji.disabled' });
++const imageType = (b) => b[0] === 0x89 && b.toString('ascii',1,4) === 'PNG' ? 'image/png' : b.toString('ascii',0,4) === 'GIF8' ? 'image/gif' : b.toString('ascii',0,4) === 'RIFF' && b.toString('ascii',8,12) === 'WEBP' ? 'image/webp' : b[0] === 0xff && b[1] === 0xd8 ? 'image/jpeg' : null;
++async function resolve(req, res) {
++ const server = await servers.bySlug(req.params.slug);
++ if (!server || !(await servers.access(server, req.user || null)).read) { res.status(404).json({ error:'No such server', code:'servers.not_found' }); return null; }
++ return server;
++}
++router.get('/:slug/emojis', async (req,res) => {
++ if (!config.emotes.serverEmojiEnabled) return off(res);
++ const server = await resolve(req,res); if (!server) return;
++ const rows = await db.all('SELECT id, code, media_url FROM emotes WHERE server_id = ? ORDER BY lower(code)', [server.id]);
++ res.json({ emojis: rows.map(e => ({ id:e.id, name:e.code, url:e.media_url || null, fallback:`:${e.code}:` })) });
++});
++router.post('/:slug/emojis', requireAuth, upload.single('file'), async (req,res) => {
++ if (!config.emotes.serverEmojiEnabled) return off(res);
++ const server = await resolve(req,res); if (!server) return;
++ const access = await servers.access(server, req.user);
++ if (!access.member) return res.status(403).json({ error:'Join this server first', code:'servers.forbidden' });
++ if (!req.file || !imageType(req.file.buffer)) return res.status(400).json({ error:'Upload a PNG, JPEG, GIF or WebP image', code:'emoji.invalid_image' });
++ const name = String(req.body.name || '').trim();
++ if (!/^[a-z0-9_]{2,32}$/i.test(name)) return res.status(400).json({ error:'Emoji names use 2–32 letters, numbers or underscores', code:'emoji.invalid_name' });
++ if (await db.get('SELECT id FROM emotes WHERE server_id = ? AND lower(code) = lower(?)', [server.id,name])) return res.status(409).json({ error:'That name is already used', code:'emoji.name_taken' });
++ const count = await db.get('SELECT COUNT(*) AS count FROM emotes WHERE server_id = ?', [server.id]);
++ if (Number(count.count) >= 50) return res.status(409).json({ error:'This server has reached its 50 emoji limit', code:'emoji.limit' });
++ try {
++ const saved = await media.uploadObject(req.file.buffer, { filename:req.file.originalname, mimeType:imageType(req.file.buffer), namespace:'chat' });
++ const row = await db.run('INSERT INTO emotes (user_id, code, url, media_url, media_asset_id, server_id) VALUES (?, ?, ?, ?, ?, ?) RETURNING id', [req.user.id,name,saved.url,saved.url,saved.id,server.id]);
++ res.status(201).json({ emoji:{ id:row.rows?.[0]?.id || row.lastInsertRowid, name, url:saved.url, fallback:`:${name}:` } });
++ } catch (err) { res.status(500).json({ error:'Could not save emoji', code:'emoji.upload_failed' }); }
++});
++router.delete('/:slug/emojis/:id', requireAuth, async (req,res) => {
++ if (!config.emotes.serverEmojiEnabled) return off(res);
++ const server = await resolve(req,res); if (!server) return;
++ const access = await servers.access(server, req.user);
++ if (!access.moderate) return res.status(403).json({ error:'Only server moderators can remove emoji', code:'emoji.forbidden' });
++ const row = await db.get('SELECT id, media_asset_id FROM emotes WHERE id = ? AND server_id = ?', [req.params.id,server.id]);
++ if (!row) return res.status(404).json({ error:'Emoji not found', code:'emoji.not_found' });
++ await db.run('DELETE FROM emotes WHERE id = ?', [row.id]);
++ if (row.media_asset_id) await media.deleteObject(row.media_asset_id);
++ res.json({ ok:true });
++});
++// An image past the 512KB limit (or of a type the filter refuses) answers JSON, not the framework's 500.
++router.use((err, _req, res, _next) => {
++ if (err && err.code === 'LIMIT_FILE_SIZE') return res.status(400).json({ error:'That image is larger than 512KB', code:'emoji.too_large' });
++ res.status(400).json({ error:'Upload a PNG, JPEG, GIF or WebP image', code:'emoji.invalid_image' });
++});
++module.exports = router;
ddiff --git a/server/config.js b/server/config.js
iindex 817a4d1..283bf5a 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -183,6 +183,7 @@ module.exports = {
// Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to
// OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.
emotes: {
++ serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, false),
maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
ddiff --git a/server/db/database.js b/server/db/database.js
iindex a6e325a..3268ce5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -896,6 +896,7 @@ async function getChannelEmotes(userId) {
LEFT JOIN ctx_users u ON e.user_id = u.id
LEFT JOIN ctx_users up ON e.user_id = up.id
WHERE ((e.channel_owner_id = ?) OR (e.channel_owner_id IS NULL AND e.user_id = ?))
++ AND e.server_id IS NULL
AND e.is_approved = 1
ORDER BY code`,
[userId, userId]
@@@ -905,7 +906,7 @@ async function getChannelEmotes(userId) {
/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */
async function countChannelEmotes(ownerId) {
return (await get(
-- 'SELECT COUNT(*) as count FROM emotes WHERE (channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)',
++ 'SELECT COUNT(*) as count FROM emotes WHERE ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?)) AND server_id IS NULL',
[ownerId, ownerId]
))?.count || 0;
}
@@@ -915,6 +916,7 @@ async function getChannelEmoteByCode(ownerId, code) {
return await get(
`SELECT * FROM emotes
WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
++ AND server_id IS NULL
LIMIT 1`,
[code, ownerId, ownerId]
);
@@@ -927,7 +929,7 @@ async function getEmoteById(id) {
/** A user's own emotes (Live's getEmotesByUser). */
async function getEmotesByUser(userId) {
-- return await all('SELECT * FROM emotes WHERE user_id = ? ORDER BY code', [userId]);
++ return await all('SELECT * FROM emotes WHERE user_id = ? AND server_id IS NULL ORDER BY code', [userId]);
}
/** Moderation actions with actor/target names (Live's getModerationActions). */
ddiff --git a/test/server-emojis.test.js b/test/server-emojis.test.js
nnew file mode 100644
iindex 0000000..ac6ba4d
---- /dev/null
++++ b/test/server-emojis.test.js
@@@ -0,0 +1,150 @@
++'use strict';
++/**
++ * Per-server custom emoji (/api/chat/servers/:slug/emojis, migration 0057_server_emojis.sql):
++ * a member uploads a small image and a name, the bytes go to OpenVibe.Media and the row keeps the
++ * server; a name is unique per server (case-insensitive), a server holds 50; a server moderator
++ * removes one and its Media object goes with it; a row without a media URL lists a `:name:` fallback
++ * so a client whose image is gone still shows what was meant. Every route answers 404 while
++ * CHAT_SERVER_EMOJI_ENABLED is off (the default).
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++
++const t = suite('server-emojis');
++let h, owner, member, outsider, mod, staff, slug, serverId;
++const uploads = [];
++const deleted = [];
++const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
++
++const api = (method, path, u, body) => h.http(method, `/api/chat/servers${path}`, { token: u && u.token, body });
++async function upload(u, name, bytes = PNG, type = 'image/png', field = 'file') {
++ const form = new FormData();
++ form.append(field, new Blob([bytes], { type }), 'emoji.png');
++ if (name !== undefined) form.append('name', String(name));
++ const res = await fetch(`${h.base}/api/chat/servers/${slug}/emojis`, {
++ method: 'POST',
++ headers: u ? { Authorization: `Bearer ${u.token}` } : {},
++ body: form,
++ });
++ return { status: res.status, body: await res.json().catch(() => null) };
++}
++const list = (u) => h.http('GET', `/api/chat/servers/${slug}/emojis`, { token: u && u.token });
++
++t('boot: a public server with an owner, a member, a moderator and an outsider', async () => {
++ h = await boot({ env: { CHAT_SERVER_EMOJI_ENABLED: 'true' } });
++ owner = h.addUser('ann');
++ member = h.addUser('bob');
++ outsider = h.addUser('cat');
++ mod = h.addUser('mox');
++ staff = h.addUser('staffer', { role: 'global_mod' });
++ for (const u of [owner, member, outsider, mod, staff]) await h.ctx.upsertUser(h.live.users.get(u.id));
++ const r = await api('POST', '/', owner, { name: 'Emoji Place' });
++ assert.strictEqual(r.status, 201, r.text);
++ slug = r.body.server.slug;
++ serverId = r.body.server.id;
++ assert.strictEqual((await api('POST', `/${slug}/join`, member)).status, 200);
++ assert.strictEqual((await api('POST', `/${slug}/join`, mod)).status, 200);
++ const m = await api('POST', `/${slug}/members`, owner, { username: 'mox', role: 'mod' });
++ assert.strictEqual(m.body.role, 'mod');
++ // The Media mock: uploads answer a public object, deletes are recorded.
++ require('../server/media/client')._setClient({
++ upload: async (buf, opts) => { const id = 900 + uploads.length + 1; uploads.push({ id, opts }); return { id, public_url: `https://openvibe.media/chat/emoji-${id}.png` }; },
++ delete: async (id) => { deleted.push(id); return true; },
++ });
++});
++
++t('the switch is off by default: every emoji route answers 404', async () => {
++ const config = require('../server/config');
++ config.emotes.serverEmojiEnabled = false;
++ try {
++ const r = await list(null);
++ assert.deepStrictEqual([r.status, r.body.code], [404, 'emoji.disabled']);
++ assert.strictEqual((await upload(member, 'party')).status, 404);
++ assert.strictEqual((await api('DELETE', `/${slug}/emojis/1`, mod)).body.code, 'emoji.disabled');
++ } finally { config.emotes.serverEmojiEnabled = true; }
++});
++
++t('the list is public and starts empty; a member uploads within the limits', async () => {
++ let r = await list(null);
++ assert.deepStrictEqual([r.status, r.body.emojis], [200, []]);
++
++ // Signed out cannot upload.
++ assert.strictEqual((await upload(null, 'party')).status, 401);
++ // A non-member is asked to join first.
++ const no = await upload(outsider, 'party');
++ assert.deepStrictEqual([no.status, no.body.code], [403, 'servers.forbidden']);
++
++ const up = await upload(member, 'party');
++ assert.strictEqual(up.status, 201, JSON.stringify(up.body));
++ assert.deepStrictEqual([up.body.emoji.name, up.body.emoji.fallback], ['party', ':party:']);
++ assert.match(up.body.emoji.url, /^https:\/\/openvibe\.media\/chat\/emoji-\d+\.png$/);
++ assert.strictEqual(uploads.length, 1);
++ assert.strictEqual(uploads[0].opts.mimeType, 'image/png');
++
++ const row = await h.db.get('SELECT * FROM emotes WHERE server_id = ? AND code = ?', [serverId, 'party']);
++ assert.ok(row, 'the row is kept for the server');
++ assert.strictEqual(Number(row.user_id), member.id, 'the uploader is recorded');
++ assert.strictEqual(row.media_url, up.body.emoji.url);
++ assert.ok(row.media_asset_id, 'the Media object id is stored');
++
++ r = await list(null);
++ assert.deepStrictEqual(r.body.emojis.map((e) => [e.name, e.url, e.fallback]), [['party', up.body.emoji.url, ':party:']]);
++
++ // A server emoji is the server's, not a personal/channel emote of the person who uploaded it:
++ // it stays out of their own lists and does not count against their channel's allowance.
++ const mine = await h.http('GET', '/api/emotes/mine', { token: member.token });
++ assert.deepStrictEqual([mine.body.emotes.length, Number(mine.body.count)], [0, 0]);
++ const channel = await h.http('GET', `/api/emotes/channel/${member.id}`);
++ assert.deepStrictEqual(channel.body.emotes, []);
++});
++
++t('a name already taken on the server is refused, in any case; bad names and non-images too', async () => {
++ let r = await upload(member, 'Party');
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'emoji.name_taken']);
++ r = await upload(member, 'a');
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'emoji.invalid_name']);
++ r = await upload(member, 'has space');
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'emoji.invalid_name']);
++ r = await upload(member, 'two', Buffer.from('<html><script>alert(1)</script></html>'), 'image/png');
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'emoji.invalid_image']);
++ r = await upload(member, 'big', Buffer.alloc(600 * 1024, 1));
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'emoji.too_large'], 'past 512KB the answer is JSON, not a framework 500');
++ assert.strictEqual(uploads.length, 1, 'a refused upload never reaches Media');
++});
++
++t('a server holds 50 emoji; the fifty-first is refused', async () => {
++ for (let i = 0; i < 49; i++) {
++ await h.db.run('INSERT INTO emotes (user_id, code, url, media_url, media_asset_id, server_id) VALUES (?, ?, ?, ?, ?, ?)',
++ [member.id, `filler_${i}`, `https://openvibe.media/chat/filler-${i}.png`, `https://openvibe.media/chat/filler-${i}.png`, 500 + i, serverId]);
++ }
++ const r = await upload(member, 'one_too_many');
++ assert.deepStrictEqual([r.status, r.body.code], [409, 'emoji.limit']);
++ await h.db.run('DELETE FROM emotes WHERE server_id = ? AND code LIKE ?', [serverId, 'filler_%']);
++});
++
++t('the :name: fallback lists when the image is gone', async () => {
++ await h.db.run("UPDATE emotes SET media_url = NULL WHERE server_id = ? AND code = 'party'", [serverId]);
++ const r = await list(null);
++ assert.strictEqual(r.status, 200);
++ const e = r.body.emojis.find((x) => x.name === 'party');
++ assert.strictEqual(e.url, null, 'no image URL to point at');
++ assert.strictEqual(e.fallback, ':party:', 'the client shows the name instead');
++});
++
++t('removal: only a server moderator, and its Media object goes too', async () => {
++ const first = (await list(null)).body.emojis.find((e) => e.name === 'party');
++ const assetId = (await h.db.get('SELECT media_asset_id FROM emotes WHERE id = ?', [first.id])).media_asset_id;
++ assert.strictEqual((await api('DELETE', `/${slug}/emojis/${first.id}`, member)).body.code, 'emoji.forbidden');
++ assert.strictEqual((await api('DELETE', `/${slug}/emojis/${first.id}`, outsider)).body.code, 'emoji.forbidden');
++
++ const del = await api('DELETE', `/${slug}/emojis/${first.id}`, mod);
++ assert.deepStrictEqual([del.status, del.body.ok], [200, true]);
++ assert.ok(!(await h.db.get('SELECT id FROM emotes WHERE id = ?', [first.id])), 'the row is gone');
++ assert.deepStrictEqual(deleted, [Number(assetId)], 'its Media object removed');
++ assert.deepStrictEqual((await list(null)).body.emojis, []);
++
++ // A second delete is a plain 404.
++ assert.deepStrictEqual([(await api('DELETE', `/${slug}/emojis/${first.id}`, mod)).status], [404]);
++});
++
++t.run(async () => { if (h && h.close) await h.close(); });