feat: launch readiness — PWA, API hardening, memory leak fixes, sticker removal

- Add PWA infrastructure: vite-plugin-pwa, manifest, service worker,
  SW update prompt component, placeholder icons, Apple meta tags
- Harden API client: 401 auto-logout, AbortController timeouts
  (30s standard, 120s uploads), onUnauthorized callback
- Fix memory leaks: clear voice user status on leave, clean up all
  Maps (channelToSpaceMap, permissions, etc.) on removeSpace
- Upgrade error boundary to Aether Drift design with Try Again button,
  collapsible stack trace, and componentDidCatch logging
- Configure desktop icon paths in electron-builder.yml
- Remove sticker feature (server routes, schema, types, UI components)
- Fix Docker build: use **/node_modules in .dockerignore to prevent
  COPY from clobbering pnpm-installed workspace dependencies
- Add vite-env.d.ts declarations for noise suppressor wasm imports
- Exclude test files from tsc build via tsconfig
This commit is contained in:
Jannis Braun
2026-03-15 15:41:22 +01:00
parent b08f40feb7
commit 4d230711fc
36 changed files with 2740 additions and 1283 deletions
+16 -81
View File
@@ -153,18 +153,6 @@ export function runMigrations(db: Database.Database): void {
},
// gif_api_key is handled by migrateRenameGifApiKey() — do NOT add it here
// or it will race with the tenor_api_key → gif_api_key rename migration
{
name: 'messages',
columns: [
{ name: 'sticker_id', type: 'TEXT' },
]
},
{
name: 'dm_messages',
columns: [
{ name: 'sticker_id', type: 'TEXT' },
]
}
];
for (const table of tables) {
@@ -329,35 +317,6 @@ export function runMigrations(db: Database.Database): void {
// ─── Add FK constraint to dm_messages.reply_to_id ────────────────────────
migrateDmMessagesReplyToFk(db);
// ─── Ensure sticker tables exist ─────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS sticker_packs (
id TEXT PRIMARY KEY,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
created_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS stickers (
id TEXT PRIMARY KEY,
pack_id TEXT NOT NULL REFERENCES sticker_packs(id) ON DELETE CASCADE,
space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
tags TEXT DEFAULT '',
filename TEXT NOT NULL,
mimetype TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
uploaded_by TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sticker_packs_space_id ON sticker_packs(space_id);
CREATE INDEX IF NOT EXISTS idx_stickers_pack_id ON stickers(pack_id);
CREATE INDEX IF NOT EXISTS idx_stickers_space_id ON stickers(space_id);
`);
// ─── Rename tenor_api_key → gif_api_key (Klipy pivot) ────────────────────
migrateRenameGifApiKey(db);
@@ -380,46 +339,22 @@ function migrateDmMessagesReplyToFk(db: Database.Database): void {
console.log('Migrating: Adding FK constraint to dm_messages.reply_to_id...');
// Check if sticker_id column exists (may have been added by column migration)
const dmCols = db.pragma('table_info(dm_messages)') as { name: string }[];
const hasStickerId = dmCols.some(c => c.name === 'sticker_id');
if (hasStickerId) {
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
sticker_id TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, sticker_id, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
} else {
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
}
db.exec(`
CREATE TABLE dm_messages_new (
id TEXT PRIMARY KEY,
dm_channel_id TEXT NOT NULL REFERENCES dm_channels(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id),
reply_to_id TEXT REFERENCES dm_messages_new(id) ON DELETE SET NULL,
content TEXT,
edited_at INTEGER,
created_at INTEGER NOT NULL
);
INSERT INTO dm_messages_new SELECT id, dm_channel_id, user_id, reply_to_id, content, edited_at, created_at FROM dm_messages;
DROP TABLE dm_messages;
ALTER TABLE dm_messages_new RENAME TO dm_messages;
CREATE INDEX IF NOT EXISTS idx_dm_messages_dm_channel_id ON dm_messages(dm_channel_id);
CREATE INDEX IF NOT EXISTS idx_dm_messages_user_id ON dm_messages(user_id);
`);
}
/** Ensure gif_api_key column exists in instance_settings, migrating from tenor_api_key if present */
-26
View File
@@ -70,7 +70,6 @@ export const messages = sqliteTable('messages', {
userId: text('user_id').notNull().references(() => users.id),
replyToId: text('reply_to_id'),
content: text('content'),
stickerId: text('sticker_id'),
editedAt: integer('edited_at'),
createdAt: integer('created_at').notNull(),
}, (table) => ({
@@ -113,7 +112,6 @@ export const dmMessages = sqliteTable('dm_messages', {
userId: text('user_id').notNull().references(() => users.id),
replyToId: text('reply_to_id'),
content: text('content'),
stickerId: text('sticker_id'),
editedAt: integer('edited_at'),
createdAt: integer('created_at').notNull(),
}, (table) => ({
@@ -215,30 +213,6 @@ export const userSpaceLayout = sqliteTable('user_space_layout', {
updatedAt: integer('updated_at').notNull(),
});
export const stickerPacks = sqliteTable('sticker_packs', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
description: text('description'),
createdBy: text('created_by').notNull().references(() => users.id),
createdAt: integer('created_at').notNull(),
});
export const stickers = sqliteTable('stickers', {
id: text('id').primaryKey(),
packId: text('pack_id').notNull().references(() => stickerPacks.id, { onDelete: 'cascade' }),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
tags: text('tags').default(''),
filename: text('filename').notNull(),
mimetype: text('mimetype').notNull(),
size: integer('size').notNull(),
width: integer('width'),
height: integer('height'),
uploadedBy: text('uploaded_by').notNull().references(() => users.id),
createdAt: integer('created_at').notNull(),
});
export const instanceSettings = sqliteTable('instance_settings', {
id: integer('id').primaryKey().default(1),
instanceName: text('instance_name').default('Backspace'),
+1 -2
View File
@@ -24,7 +24,7 @@ import { exploreRoutes } from './routes/explore.js';
import { searchRoutes } from './routes/search.js';
import { adminRoutes } from './routes/admin.js';
import { gifRoutes } from './routes/gif.js';
import { stickerRoutes } from './routes/stickers.js';
import { registerWebSocket } from './ws/handler.js';
import path from 'path';
import fs from 'fs';
@@ -94,7 +94,6 @@ async function main(): Promise<void> {
await app.register(searchRoutes);
await app.register(adminRoutes);
await app.register(gifRoutes);
await app.register(stickerRoutes);
await app.register(registerWebSocket);
app.get('/api/health', async () => {
+3 -38
View File
@@ -68,29 +68,6 @@ export function buildDmMessageWithUser(
reactions: Reaction[] = [],
replyTo: DmMessageWithUser | null = null,
): DmMessageWithUser {
const stickerId = (message as any).stickerId ?? null;
// Lazy import to avoid circular dependency
let sticker = null;
if (stickerId) {
const db = getDb();
const row = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get();
if (row) {
sticker = {
id: row.id,
packId: row.packId,
spaceId: row.spaceId,
name: row.name,
tags: row.tags ?? '',
filename: row.filename,
mimetype: row.mimetype,
size: row.size,
width: row.width,
height: row.height,
uploadedBy: row.uploadedBy,
createdAt: row.createdAt,
};
}
}
return {
id: message.id,
dmChannelId: message.dmChannelId,
@@ -112,8 +89,6 @@ export function buildDmMessageWithUser(
})),
reactions,
replyTo,
stickerId,
sticker,
};
}
@@ -906,7 +881,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
},
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
const { content, attachments: attachmentIds, replyToId } = request.body;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
@@ -914,24 +889,15 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0;
const hasSticker = stickerId && typeof stickerId === 'string';
if (!hasContent && !hasAttachments && !hasSticker) {
return reply.code(400).send({ error: 'Message must have content, attachments, or a sticker', statusCode: 400 });
if (!hasContent && !hasAttachments) {
return reply.code(400).send({ error: 'Message must have content or attachments', 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 });
}
// Validate sticker exists if provided
if (hasSticker) {
const sticker = getDb().select().from(schema.stickers).where(eq(schema.stickers.id, stickerId!)).get();
if (!sticker) {
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
}
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
@@ -957,7 +923,6 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
stickerId: stickerId || null,
createdAt: now,
}).run();
+3 -38
View File
@@ -121,28 +121,6 @@ export function fetchReplyToMessages(messages: (typeof schema.messages.$inferSel
return map;
}
/** Hydrate a sticker by ID, returns null if not found */
export function hydrateSticker(stickerId: string | null | undefined): any {
if (!stickerId) return null;
const db = getDb();
const row = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get();
if (!row) return null;
return {
id: row.id,
packId: row.packId,
spaceId: row.spaceId,
name: row.name,
tags: row.tags ?? '',
filename: row.filename,
mimetype: row.mimetype,
size: row.size,
width: row.width,
height: row.height,
uploadedBy: row.uploadedBy,
createdAt: row.createdAt,
};
}
export function buildMessageWithUser(
message: typeof schema.messages.$inferSelect,
user: typeof schema.users.$inferSelect,
@@ -150,7 +128,6 @@ export function buildMessageWithUser(
reactions: Reaction[] = [],
replyTo: MessageWithUser | null = null,
): MessageWithUser {
const stickerId = (message as any).stickerId ?? null;
return {
id: message.id,
channelId: message.channelId,
@@ -172,8 +149,6 @@ export function buildMessageWithUser(
})),
reactions,
replyTo,
stickerId,
sticker: hydrateSticker(stickerId),
};
}
@@ -277,7 +252,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
},
}, async (request, reply) => {
const { id } = request.params;
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
const { content, attachments: attachmentIds, replyToId } = request.body;
const spaceId = getChannelSpaceId(id);
if (!spaceId) {
@@ -295,18 +270,9 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
const hasAttachments = attachmentIds && attachmentIds.length > 0;
const hasSticker = stickerId && typeof stickerId === 'string';
if (!hasContent && !hasAttachments && !hasSticker) {
return reply.code(400).send({ error: 'Message must have content, attachments, or a sticker', statusCode: 400 });
}
// Validate sticker exists if provided
if (hasSticker) {
const sticker = getDb().select().from(schema.stickers).where(eq(schema.stickers.id, stickerId!)).get();
if (!sticker) {
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
}
if (!hasContent && !hasAttachments) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
}
if (content && content.length > MAX_MESSAGE_LENGTH) {
@@ -339,7 +305,6 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
stickerId: stickerId || null,
createdAt: now,
}).run();
-414
View File
@@ -1,414 +0,0 @@
import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { hasPermission, PermissionBits, isMember } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import { config } from '../config.js';
import type { Sticker, StickerPack } from '@backspace/shared';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
const MAX_STICKER_SIZE = 500 * 1024; // 500KB
const MAX_STICKER_DIMENSION = 512;
const ALLOWED_STICKER_TYPES = ['image/png', 'image/webp', 'image/gif'];
function getSpaceIdForPack(packId: string): string | null {
const db = getDb();
const pack = db.select({ spaceId: schema.stickerPacks.spaceId })
.from(schema.stickerPacks)
.where(eq(schema.stickerPacks.id, packId))
.get();
return pack?.spaceId ?? null;
}
function getStickerSpaceId(stickerId: string): string | null {
const db = getDb();
const sticker = db.select({ spaceId: schema.stickers.spaceId })
.from(schema.stickers)
.where(eq(schema.stickers.id, stickerId))
.get();
return sticker?.spaceId ?? null;
}
function packToResponse(pack: typeof schema.stickerPacks.$inferSelect, stickerRows: (typeof schema.stickers.$inferSelect)[]): StickerPack {
return {
id: pack.id,
spaceId: pack.spaceId,
name: pack.name,
description: pack.description,
createdBy: pack.createdBy,
createdAt: pack.createdAt,
stickers: stickerRows.map(stickerToResponse),
};
}
function stickerToResponse(s: typeof schema.stickers.$inferSelect): Sticker {
return {
id: s.id,
packId: s.packId,
spaceId: s.spaceId,
name: s.name,
tags: s.tags ?? '',
filename: s.filename,
mimetype: s.mimetype,
size: s.size,
width: s.width,
height: s.height,
uploadedBy: s.uploadedBy,
createdAt: s.createdAt,
};
}
export async function stickerRoutes(app: FastifyInstance): Promise<void> {
// GET /api/spaces/:id/sticker-packs — list all packs in a space
app.get<{ Params: { id: string } }>('/api/spaces/:id/sticker-packs', {
preHandler: authenticate,
}, async (request, reply) => {
const spaceId = request.params.id;
if (!isMember(spaceId, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const db = getDb();
const packs = db.select().from(schema.stickerPacks)
.where(eq(schema.stickerPacks.spaceId, spaceId))
.all();
const packIds = packs.map(p => p.id);
const allStickers = packIds.length > 0
? db.select().from(schema.stickers).where(inArray(schema.stickers.packId, packIds)).all()
: [];
const stickersByPack = new Map<string, (typeof schema.stickers.$inferSelect)[]>();
for (const s of allStickers) {
const arr = stickersByPack.get(s.packId) ?? [];
arr.push(s);
stickersByPack.set(s.packId, arr);
}
return reply.code(200).send({
packs: packs.map(p => packToResponse(p, stickersByPack.get(p.id) ?? [])),
});
});
// POST /api/spaces/:id/sticker-packs — create a pack
app.post<{ Params: { id: string }; Body: { name: string; description?: string } }>('/api/spaces/:id/sticker-packs', {
preHandler: authenticate,
}, async (request, reply) => {
const spaceId = request.params.id;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const { name, description } = request.body;
if (!name || typeof name !== 'string' || name.trim().length === 0 || name.trim().length > 32) {
return reply.code(400).send({ error: 'Pack name must be 1-32 characters', statusCode: 400 });
}
const db = getDb();
const packId = generateSnowflake();
const now = Date.now();
db.insert(schema.stickerPacks).values({
id: packId,
spaceId,
name: name.trim(),
description: description?.trim() || null,
createdBy: request.userId,
createdAt: now,
}).run();
const pack = db.select().from(schema.stickerPacks).where(eq(schema.stickerPacks.id, packId)).get()!;
const response = packToResponse(pack, []);
connectionManager.sendToSpace(spaceId, {
type: 'sticker_pack_created',
spaceId,
pack: response,
});
return reply.code(201).send(response);
});
// PATCH /api/spaces/:id/sticker-packs/:packId — update a pack
app.patch<{ Params: { id: string; packId: string }; Body: { name?: string; description?: string } }>('/api/spaces/:id/sticker-packs/:packId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id: spaceId, packId } = request.params;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const db = getDb();
const pack = db.select().from(schema.stickerPacks).where(
and(eq(schema.stickerPacks.id, packId), eq(schema.stickerPacks.spaceId, spaceId))
).get();
if (!pack) {
return reply.code(404).send({ error: 'Pack not found', statusCode: 404 });
}
const updates: Record<string, string | null> = {};
if (request.body.name !== undefined) {
if (typeof request.body.name !== 'string' || request.body.name.trim().length === 0 || request.body.name.trim().length > 32) {
return reply.code(400).send({ error: 'Pack name must be 1-32 characters', statusCode: 400 });
}
updates.name = request.body.name.trim();
}
if (request.body.description !== undefined) {
updates.description = request.body.description?.trim() || null;
}
if (Object.keys(updates).length > 0) {
db.update(schema.stickerPacks).set(updates).where(eq(schema.stickerPacks.id, packId)).run();
}
const updatedPack = db.select().from(schema.stickerPacks).where(eq(schema.stickerPacks.id, packId)).get()!;
const stickers = db.select().from(schema.stickers).where(eq(schema.stickers.packId, packId)).all();
const response = packToResponse(updatedPack, stickers);
connectionManager.sendToSpace(spaceId, {
type: 'sticker_pack_updated',
spaceId,
pack: response,
});
return reply.code(200).send(response);
});
// DELETE /api/spaces/:id/sticker-packs/:packId — delete a pack (cascades stickers)
app.delete<{ Params: { id: string; packId: string } }>('/api/spaces/:id/sticker-packs/:packId', {
preHandler: authenticate,
}, async (request, reply) => {
const { id: spaceId, packId } = request.params;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const db = getDb();
const pack = db.select().from(schema.stickerPacks).where(
and(eq(schema.stickerPacks.id, packId), eq(schema.stickerPacks.spaceId, spaceId))
).get();
if (!pack) {
return reply.code(404).send({ error: 'Pack not found', statusCode: 404 });
}
// Get sticker files to clean up
const stickers = db.select().from(schema.stickers).where(eq(schema.stickers.packId, packId)).all();
// Delete pack (cascade deletes stickers)
db.delete(schema.stickerPacks).where(eq(schema.stickerPacks.id, packId)).run();
// Clean up files
for (const s of stickers) {
const filePath = path.join(config.uploadDir, path.basename(s.filename));
try { fs.unlinkSync(filePath); } catch {}
}
connectionManager.sendToSpace(spaceId, {
type: 'sticker_pack_deleted',
spaceId,
packId,
});
return reply.code(200).send({ success: true });
});
// POST /api/spaces/:id/sticker-packs/:packId/stickers — upload a sticker
app.post<{ Params: { id: string; packId: string } }>('/api/spaces/:id/sticker-packs/:packId/stickers', {
preHandler: authenticate,
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const { id: spaceId, packId } = request.params;
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const db = getDb();
const pack = db.select().from(schema.stickerPacks).where(
and(eq(schema.stickerPacks.id, packId), eq(schema.stickerPacks.spaceId, spaceId))
).get();
if (!pack) {
return reply.code(404).send({ error: 'Pack not found', statusCode: 404 });
}
const data = await request.file();
if (!data) {
return reply.code(400).send({ error: 'No file uploaded', statusCode: 400 });
}
const chunks: Buffer[] = [];
for await (const chunk of data.file) {
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (buffer.length > MAX_STICKER_SIZE) {
return reply.code(400).send({ error: 'Sticker must be 500KB or less', statusCode: 400 });
}
if (!ALLOWED_STICKER_TYPES.includes(data.mimetype)) {
return reply.code(400).send({ error: 'Sticker must be PNG, WebP, or GIF', statusCode: 400 });
}
// Read name and tags from multipart fields
const fields = data.fields as Record<string, any>;
const name = (fields.name?.value || 'sticker').toString().trim().slice(0, 32);
const tags = (fields.tags?.value || '').toString().trim().slice(0, 100);
// Read dimensions with sharp, auto-downscale if oversized
let width: number | null = null;
let height: number | null = null;
let finalBuffer = buffer;
try {
const sharp = (await import('sharp')).default;
const meta = await sharp(buffer).metadata();
width = meta.width ?? null;
height = meta.height ?? null;
if ((width && width > MAX_STICKER_DIMENSION) || (height && height > MAX_STICKER_DIMENSION)) {
// Auto-downscale to fit within 512x512, preserving aspect ratio and GIF animation
const isAnimated = data.mimetype === 'image/gif';
const resized = sharp(buffer, isAnimated ? { animated: true } : undefined)
.resize({ width: MAX_STICKER_DIMENSION, height: MAX_STICKER_DIMENSION, fit: 'inside' });
finalBuffer = Buffer.from(await resized.toBuffer());
const resizedMeta = await sharp(finalBuffer, isAnimated ? { animated: true } : undefined).metadata();
width = resizedMeta.width ?? null;
height = resizedMeta.height ?? null;
console.log(`[Stickers] Auto-downscaled sticker from ${meta.width}x${meta.height} to ${width}x${height}`);
}
} catch {
// Can't read dimensions — allow anyway
}
// Save file
const ext = data.mimetype === 'image/png' ? '.png' : data.mimetype === 'image/webp' ? '.webp' : '.gif';
const filename = `sticker_${crypto.randomBytes(16).toString('hex')}${ext}`;
const filePath = path.join(config.uploadDir, filename);
fs.writeFileSync(filePath, finalBuffer);
const stickerId = generateSnowflake();
const now = Date.now();
db.insert(schema.stickers).values({
id: stickerId,
packId,
spaceId,
name,
tags,
filename,
mimetype: data.mimetype,
size: finalBuffer.length,
width,
height,
uploadedBy: request.userId,
createdAt: now,
}).run();
const sticker = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get()!;
const response = stickerToResponse(sticker);
connectionManager.sendToSpace(spaceId, {
type: 'sticker_created',
spaceId,
sticker: response,
});
return reply.code(201).send(response);
});
// DELETE /api/stickers/:id — delete a sticker
app.delete<{ Params: { id: string } }>('/api/stickers/:id', {
preHandler: authenticate,
}, async (request, reply) => {
const stickerId = request.params.id;
const spaceId = getStickerSpaceId(stickerId);
if (!spaceId) {
return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
}
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const db = getDb();
const sticker = db.select().from(schema.stickers).where(eq(schema.stickers.id, stickerId)).get();
if (!sticker) {
return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
}
db.delete(schema.stickers).where(eq(schema.stickers.id, stickerId)).run();
// Clean up file
const filePath = path.join(config.uploadDir, path.basename(sticker.filename));
try { fs.unlinkSync(filePath); } catch {}
connectionManager.sendToSpace(spaceId, {
type: 'sticker_deleted',
spaceId,
stickerId,
});
return reply.code(200).send({ success: true });
});
// GET /api/users/@me/stickers — all stickers from joined spaces
app.get('/api/users/@me/stickers', {
preHandler: authenticate,
}, async (request, reply) => {
const db = getDb();
// Get all spaces the user is a member of
const memberships = db.select({ spaceId: schema.spaceMembers.spaceId })
.from(schema.spaceMembers)
.where(eq(schema.spaceMembers.userId, request.userId))
.all();
const spaceIds = memberships.map(m => m.spaceId);
if (spaceIds.length === 0) {
return reply.code(200).send({ packs: [] });
}
// Get all packs from those spaces
const packs = db.select().from(schema.stickerPacks)
.where(inArray(schema.stickerPacks.spaceId, spaceIds))
.all();
if (packs.length === 0) {
return reply.code(200).send({ packs: [] });
}
const packIds = packs.map(p => p.id);
const allStickers = db.select().from(schema.stickers)
.where(inArray(schema.stickers.packId, packIds))
.all();
const stickersByPack = new Map<string, (typeof schema.stickers.$inferSelect)[]>();
for (const s of allStickers) {
const arr = stickersByPack.get(s.packId) ?? [];
arr.push(s);
stickersByPack.set(s.packId, arr);
}
// Get space names for labeling
const spaceRows = db.select({ id: schema.spaces.id, name: schema.spaces.name })
.from(schema.spaces)
.where(inArray(schema.spaces.id, spaceIds))
.all();
const spaceNameMap = new Map(spaceRows.map(s => [s.id, s.name]));
const result: StickerPack[] = packs.map(p => ({
...packToResponse(p, stickersByPack.get(p.id) ?? []),
spaceName: spaceNameMap.get(p.spaceId) ?? 'Unknown',
}));
return reply.code(200).send({ packs: result });
});
}
-4
View File
@@ -8,7 +8,6 @@ import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser } from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
import { hydrateSticker } from '../routes/messages.js';
/**
* Re-evaluate SPEAK permission for all participants in voice channels
@@ -100,7 +99,6 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
}
}
const stickerId = (message as any).stickerId ?? null;
return {
id: message.id,
channelId: message.channelId,
@@ -113,8 +111,6 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
attachments,
reactions,
replyTo,
stickerId,
sticker: hydrateSticker(stickerId),
};
}