feat(expressions): custom emojis and stickers per space
Emojis and stickers get separate tables on purpose: an emoji sits inside a sentence and a sticker is the whole message, so their sizes, limits and render paths differ. Emoji names are unique per space and restricted to letters, digits and underscore — :name: has to resolve to one image, and a space or colon inside the name would make the reference impossible to delimit. Sticker names are labels picked from a grid, so they accept anything. Rendering reuses the existing markdown pipeline by rewriting :name: into image syntax, which needs no plugin. Code spans and fences are left alone: text someone wrapped in backticks was meant to be shown literally, and turning it into an image would destroy what they wrote. Messages carry stickerId with ON DELETE set null — removing a sticker from the space must not delete the history of everyone who used it. The server refuses a sticker from another space, which would otherwise leak images between unrelated servers. Expressions load once per space and stay in memory: the message renderer consults the map for every :name: it finds, and a request per lookup would turn each message into a cascade. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
CREATE TABLE `space_emojis` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`space_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`uploader_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `space_stickers` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`space_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`filename` text NOT NULL,
|
||||
`uploader_id` text,
|
||||
`created_at` integer NOT NULL,
|
||||
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
|
||||
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`space_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -120,6 +120,13 @@
|
||||
"when": 1788295165743,
|
||||
"tag": "0016_lyrical_freak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1788295936825,
|
||||
"tag": "0017_purple_wildside",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -86,6 +86,9 @@ export const messages = sqliteTable('messages', {
|
||||
// add a join to every pin lookup.
|
||||
pinnedAt: integer('pinned_at'),
|
||||
pinnedBy: text('pinned_by'),
|
||||
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
|
||||
// a figurinha do servidor não pode apagar o histórico de quem a usou.
|
||||
stickerId: text('sticker_id'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
replyToFk: foreignKey({
|
||||
@@ -654,3 +657,37 @@ export const soundboardSounds = sqliteTable('soundboard_sounds', {
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
|
||||
*
|
||||
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
|
||||
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
|
||||
*/
|
||||
export const spaceEmojis = sqliteTable('space_emojis', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_space_emojis_space').on(table.spaceId),
|
||||
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
|
||||
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
|
||||
* caminho de render são diferentes.
|
||||
*/
|
||||
export const spaceStickers = sqliteTable('space_stickers', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
name: text('name').notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_space_stickers_space').on(table.spaceId),
|
||||
}));
|
||||
|
||||
@@ -19,6 +19,7 @@ import { spotifyRoutes } from './routes/spotify.js';
|
||||
import { auditRoutes } from './routes/audit.js';
|
||||
import { statsRoutes } from './routes/stats.js';
|
||||
import { soundboardRoutes } from './routes/soundboard.js';
|
||||
import { expressionRoutes } from './routes/expressions.js';
|
||||
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||
import { socialRoutes } from './routes/social.js';
|
||||
import { settingsRoutes } from './routes/settings.js';
|
||||
@@ -139,6 +140,7 @@ async function main(): Promise<void> {
|
||||
await app.register(auditRoutes);
|
||||
await app.register(statsRoutes);
|
||||
await app.register(soundboardRoutes);
|
||||
await app.register(expressionRoutes);
|
||||
await app.register(socialRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(utilRoutes);
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { hasPermission, isMember } from '../utils/permissions.js';
|
||||
import { PermissionBits } from '@backspace/shared/src/permissions.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
|
||||
const MAX_EMOJIS_PER_SPACE = 100;
|
||||
const MAX_STICKERS_PER_SPACE = 50;
|
||||
const MAX_NAME_LENGTH = 32;
|
||||
|
||||
/**
|
||||
* Nomes viram `:nome:` no texto, então só letras, números e sublinhado —
|
||||
* espaço ou dois-pontos dentro do nome tornariam a referência impossível de
|
||||
* delimitar.
|
||||
*/
|
||||
const NAME_PATTERN = /^[a-z0-9_]{2,32}$/;
|
||||
|
||||
function normalizeName(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const name = raw.trim().toLowerCase().replace(/\s+/g, '_').slice(0, MAX_NAME_LENGTH);
|
||||
return NAME_PATTERN.test(name) ? name : null;
|
||||
}
|
||||
|
||||
/** O nome do arquivo é chave no diretório de uploads, nunca um caminho. */
|
||||
function validFilename(raw: unknown): raw is string {
|
||||
return typeof raw === 'string' && raw.length > 0
|
||||
&& !raw.includes('/') && !raw.includes('\\') && !raw.includes('..');
|
||||
}
|
||||
|
||||
export async function expressionRoutes(app: FastifyInstance): Promise<void> {
|
||||
// ─── Emojis ──────────────────────────────────────────────────────────────
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||
if (!isMember(request.params.id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||
}
|
||||
const rows = getDb().select().from(schema.spaceEmojis)
|
||||
.where(eq(schema.spaceEmojis.spaceId, request.params.id)).all();
|
||||
return reply.code(200).send({ emojis: rows });
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||
'/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
const name = normalizeName(request.body?.name);
|
||||
if (!name) {
|
||||
return reply.code(400).send({ error: 'Name must be 2-32 chars: letters, numbers, underscore', statusCode: 400 });
|
||||
}
|
||||
if (!validFilename(request.body?.filename)) {
|
||||
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const count = db.select().from(schema.spaceEmojis)
|
||||
.where(eq(schema.spaceEmojis.spaceId, id)).all().length;
|
||||
if (count >= MAX_EMOJIS_PER_SPACE) {
|
||||
return reply.code(409).send({ error: `At most ${MAX_EMOJIS_PER_SPACE} emojis`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const existing = db.select().from(schema.spaceEmojis)
|
||||
.where(and(eq(schema.spaceEmojis.spaceId, id), eq(schema.spaceEmojis.name, name))).get();
|
||||
if (existing) {
|
||||
return reply.code(409).send({ error: `:${name}: already exists in this space`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id: generateSnowflake(),
|
||||
spaceId: id,
|
||||
name,
|
||||
filename: request.body!.filename!,
|
||||
uploaderId: request.userId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.insert(schema.spaceEmojis).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/emojis/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const emoji = db.select().from(schema.spaceEmojis)
|
||||
.where(eq(schema.spaceEmojis.id, request.params.id)).get();
|
||||
if (!emoji) return reply.code(404).send({ error: 'Emoji not found', statusCode: 404 });
|
||||
if (!hasPermission(request.userId, emoji.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
db.delete(schema.spaceEmojis).where(eq(schema.spaceEmojis.id, request.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
// ─── Stickers ────────────────────────────────────────────────────────────
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||
if (!isMember(request.params.id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
|
||||
}
|
||||
const rows = getDb().select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.spaceId, request.params.id)).all();
|
||||
return reply.code(200).send({ stickers: rows });
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||
'/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
// Figurinha é escolhida numa grade, não digitada, então o nome é rótulo
|
||||
// e aceita acento e espaço — ao contrário do emoji.
|
||||
const name = typeof request.body?.name === 'string'
|
||||
? request.body.name.trim().slice(0, MAX_NAME_LENGTH) : '';
|
||||
if (!name) return reply.code(400).send({ error: 'Name is required', statusCode: 400 });
|
||||
if (!validFilename(request.body?.filename)) {
|
||||
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const count = db.select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.spaceId, id)).all().length;
|
||||
if (count >= MAX_STICKERS_PER_SPACE) {
|
||||
return reply.code(409).send({ error: `At most ${MAX_STICKERS_PER_SPACE} stickers`, statusCode: 409 });
|
||||
}
|
||||
|
||||
const row = {
|
||||
id: generateSnowflake(),
|
||||
spaceId: id,
|
||||
name,
|
||||
filename: request.body!.filename!,
|
||||
uploaderId: request.userId,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
db.insert(schema.spaceStickers).values(row).run();
|
||||
return reply.code(201).send(row);
|
||||
},
|
||||
);
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/stickers/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const sticker = db.select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.id, request.params.id)).get();
|
||||
if (!sticker) return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
|
||||
if (!hasPermission(request.userId, sticker.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||
}
|
||||
db.delete(schema.spaceStickers).where(eq(schema.spaceStickers.id, request.params.id)).run();
|
||||
return reply.code(204).send();
|
||||
});
|
||||
}
|
||||
@@ -149,6 +149,7 @@ export function buildMessageWithUser(
|
||||
// second request, and so the pins panel and the timeline agree.
|
||||
pinnedAt: message.pinnedAt,
|
||||
pinnedBy: message.pinnedBy,
|
||||
stickerId: message.stickerId,
|
||||
user: sanitizeUser(user),
|
||||
attachments: attachmentRows.map(a => ({
|
||||
id: a.id,
|
||||
@@ -275,7 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const { content, attachments: attachmentIds, replyToId } = request.body;
|
||||
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||
|
||||
const spaceId = getChannelSpaceId(id);
|
||||
if (!spaceId) {
|
||||
@@ -291,18 +292,35 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||
}
|
||||
|
||||
const db0 = getDb();
|
||||
const db = db0;
|
||||
|
||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||
|
||||
if (!hasContent && !hasAttachments) {
|
||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
||||
// Figurinha vale como conteúdo: a mensagem é a figurinha.
|
||||
let sticker = null;
|
||||
if (stickerId) {
|
||||
sticker = db0.select().from(schema.spaceStickers)
|
||||
.where(eq(schema.spaceStickers.id, stickerId)).get() ?? null;
|
||||
if (!sticker) {
|
||||
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
|
||||
}
|
||||
// Só figurinhas do próprio espaço: aceitar de outro vazaria imagem entre
|
||||
// servidores que não têm relação nenhuma.
|
||||
if (sticker.spaceId !== spaceId) {
|
||||
return reply.code(400).send({ error: 'Sticker belongs to another space', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasContent && !hasAttachments && !sticker) {
|
||||
return reply.code(400).send({ error: 'Message must have content, attachments or a sticker', statusCode: 400 });
|
||||
}
|
||||
|
||||
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
||||
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const messageId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -327,6 +345,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
||||
channelId: id,
|
||||
userId: request.userId,
|
||||
replyToId: replyToId || null,
|
||||
stickerId: sticker ? sticker.id : null,
|
||||
content: content?.trim() || null,
|
||||
createdAt: now,
|
||||
}).run();
|
||||
|
||||
Reference in New Issue
Block a user