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:
@@ -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 */
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user