openvibers/openvibe.chat Commits
Add gated server emoji API (#452)
Changes
nnew file mode 100644@@@ -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;@@@ -352,6 +352,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settinapp.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.nnew file mode 100644@@@ -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;@@@ -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),@@@ -896,6 +896,7 @@ async function getChannelEmotes(userId) {LEFT JOIN ctx_users u ON e.user_id = u.idLEFT JOIN ctx_users up ON e.user_id = up.idWHERE ((e.channel_owner_id = ?) OR (e.channel_owner_id IS NULL AND e.user_id = ?))++ AND e.server_id IS NULLAND e.is_approved = 1ORDER 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 emotesWHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))++ AND server_id IS NULLLIMIT 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). */nnew file mode 100644@@@ -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(); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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;
@@ -352,6 +352,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -352,6 +352,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
app.use('/api/chat/support', require('./inbox/routes').support);
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.
// 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/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/servers', require('./rooms/server-routes'));
app.use('/api/chat/commands', require('./chat/slash-commands').router);
app.use('/api/chat/commands', require('./chat/slash-commands').router);
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
// Channel moderators & moderation settings (/api/chat/channels/:id/…): before /api/chat too.
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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;
@@ -183,6 +183,7 @@ module.exports = {
@@ -183,6 +183,7 @@ module.exports = {
// Custom emotes (Live's /api/emotes moved here, plan T3 step 1). Uploaded image bytes go to
// 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.
// OpenVibe.Media (media, below); the limits and provider cache TTLs are Live's own values.
emotes: {
emotes: {
+ serverEmojiEnabled: bool(process.env.CHAT_SERVER_EMOJI_ENABLED, false),
maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
maxSizeKb: int(process.env.MAX_EMOTE_SIZE_KB, 2048),
maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
maxPerUser: int(process.env.MAX_EMOTES_PER_USER, 30),
maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
maxPerChannel: int(process.env.MAX_EMOTES_PER_CHANNEL, 30),
@@ -896,6 +896,7 @@ async function getChannelEmotes(userId) {
@@ -896,6 +896,7 @@ async function getChannelEmotes(userId) {
LEFT JOIN ctx_users u ON e.user_id = u.id
LEFT JOIN ctx_users u ON e.user_id = u.id
LEFT JOIN ctx_users up ON e.user_id = up.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 = ?))
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
AND e.is_approved = 1
ORDER BY code`,
ORDER BY code`,
[userId, userId]
[userId, userId]
@@ -905,7 +906,7 @@ async function getChannelEmotes(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). */
/** How many emotes a channel holds (Live's countChannelEmotes; ownerId is the streamer's user id). */
async function countChannelEmotes(ownerId) {
async function countChannelEmotes(ownerId) {
return (await get(
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]
[ownerId, ownerId]
))?.count || 0;
))?.count || 0;
}
}
@@ -915,6 +916,7 @@ async function getChannelEmoteByCode(ownerId, code) {
@@ -915,6 +916,7 @@ async function getChannelEmoteByCode(ownerId, code) {
return await get(
return await get(
`SELECT * FROM emotes
`SELECT * FROM emotes
WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
WHERE code = ? AND ((channel_owner_id = ?) OR (channel_owner_id IS NULL AND user_id = ?))
+ AND server_id IS NULL
LIMIT 1`,
LIMIT 1`,
[code, ownerId, ownerId]
[code, ownerId, ownerId]
);
);
@@ -927,7 +929,7 @@ async function getEmoteById(id) {
@@ -927,7 +929,7 @@ async function getEmoteById(id) {
/** A user's own emotes (Live's getEmotesByUser). */
/** A user's own emotes (Live's getEmotesByUser). */
async function getEmotesByUser(userId) {
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). */
/** Moderation actions with actor/target names (Live's getModerationActions). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -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(); });