feat: GIF search (Klipy), stickers, emoji picker, and bug fixes

- Add GIF search powered by Klipy API with correct response mapping
  (file.sm/hd tiers, not flat files structure)
- Add sticker system: packs, upload with auto-downscale, send in messages
- Add tabbed InputPopover with emoji, GIF, and sticker pickers
- Fix GIF API key migration race condition (column-add loop vs rename)
- Fix masked API key corruption on settings save (server + client guards)
- Fix sticker packs 403 (reversed isMember parameter order)
- Fix emoji picker not filling popover width (perLine 8→9, CSS 100%)
- Add error logging for Klipy API failures
This commit is contained in:
Jannis Braun
2026-03-15 02:04:37 +01:00
parent 7113f47b17
commit 3de6e4a668
26 changed files with 2160 additions and 50 deletions
+41 -4
View File
@@ -68,6 +68,29 @@ 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,
@@ -89,6 +112,8 @@ export function buildDmMessageWithUser(
})),
reactions,
replyTo,
stickerId,
sticker,
};
}
@@ -881,21 +906,32 @@ export async function dmRoutes(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;
if (!isDmMember(id, request.userId)) {
return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
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 (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();
@@ -921,6 +957,7 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
stickerId: stickerId || null,
createdAt: now,
}).run();
+181
View File
@@ -0,0 +1,181 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { GifResult } from '@backspace/shared';
interface KlipyGifFile {
url: string;
width: number;
height: number;
size: number;
}
interface KlipySizeTier {
gif?: KlipyGifFile;
webp?: KlipyGifFile;
mp4?: KlipyGifFile;
jpg?: KlipyGifFile;
webm?: KlipyGifFile;
}
interface KlipyGif {
id: number;
slug: string;
title: string;
file: {
hd?: KlipySizeTier;
md?: KlipySizeTier;
sm?: KlipySizeTier;
xs?: KlipySizeTier;
};
}
interface KlipyResponse {
result: boolean;
data: {
data: KlipyGif[];
current_page: number;
per_page: number;
has_next: boolean;
};
}
function mapKlipyResults(gifs: KlipyGif[]): GifResult[] {
return gifs
.map((g) => {
if (!g.file) return null;
// Preview: small tier, prefer webp (smaller) then gif
const smTier = g.file.sm ?? g.file.xs;
const preview = smTier?.webp ?? smTier?.gif;
// Full: HD tier, prefer gif (original quality) then webp
const hdTier = g.file.hd ?? g.file.md;
const full = hdTier?.gif ?? hdTier?.webp;
if (!preview || !full) return null;
return {
id: g.slug || String(g.id),
title: g.title ?? '',
previewUrl: preview.url,
url: full.url,
width: preview.width,
height: preview.height,
};
})
.filter((r): r is GifResult => r !== null);
}
function getGifApiKey(): string | null {
const db = getDb();
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
return row?.gifApiKey ?? null;
}
export async function gifRoutes(app: FastifyInstance): Promise<void> {
// GET /api/gif/enabled — any authenticated user, returns whether GIF search is available
app.get('/api/gif/enabled', { preHandler: authenticate }, async (_request, reply) => {
const key = getGifApiKey();
return reply.code(200).send({ enabled: !!key });
});
// GET /api/gif/trending
app.get<{ Querystring: { limit?: string; pos?: string } }>('/api/gif/trending', {
preHandler: authenticate,
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const key = getGifApiKey();
if (!key) {
return reply.code(200).send({ results: [], next: '' });
}
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
const page = Number(request.query.pos) || 1;
try {
const params = new URLSearchParams({
per_page: String(perPage),
page: String(page),
});
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/trending?${params}`);
if (!response.ok) {
const body = await response.text().catch(() => '');
console.error(`[GIF] Klipy trending error: HTTP ${response.status}${body.slice(0, 200)}`);
return reply.code(200).send({ results: [], next: '' });
}
const data = (await response.json()) as KlipyResponse;
if (!data.result || !data.data) {
console.error('[GIF] Klipy trending returned result=false or missing data');
return reply.code(200).send({ results: [], next: '' });
}
return reply.code(200).send({
results: mapKlipyResults(data.data.data),
next: data.data.has_next ? String(data.data.current_page + 1) : '',
});
} catch (err) {
console.error('[GIF] Klipy trending fetch failed:', err);
return reply.code(200).send({ results: [], next: '' });
}
});
// GET /api/gif/search
app.get<{ Querystring: { q?: string; limit?: string; pos?: string } }>('/api/gif/search', {
preHandler: authenticate,
config: {
rateLimit: {
max: 30,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.userId || request.ip,
},
},
}, async (request, reply) => {
const key = getGifApiKey();
if (!key) {
return reply.code(200).send({ results: [], next: '' });
}
const q = request.query.q?.trim();
if (!q) {
return reply.code(400).send({ error: 'Search query is required', statusCode: 400 });
}
const perPage = Math.min(Math.max(Number(request.query.limit) || 24, 1), 50);
const page = Number(request.query.pos) || 1;
try {
const params = new URLSearchParams({
q,
per_page: String(perPage),
page: String(page),
});
const response = await fetch(`https://api.klipy.com/api/v1/${encodeURIComponent(key)}/gifs/search?${params}`);
if (!response.ok) {
const body = await response.text().catch(() => '');
console.error(`[GIF] Klipy search error: HTTP ${response.status}${body.slice(0, 200)}`);
return reply.code(200).send({ results: [], next: '' });
}
const data = (await response.json()) as KlipyResponse;
if (!data.result || !data.data) {
console.error('[GIF] Klipy search returned result=false or missing data');
return reply.code(200).send({ results: [], next: '' });
}
return reply.code(200).send({
results: mapKlipyResults(data.data.data),
next: data.data.has_next ? String(data.data.current_page + 1) : '',
});
} catch (err) {
console.error('[GIF] Klipy search fetch failed:', err);
return reply.code(200).send({ results: [], next: '' });
}
});
}
+41 -4
View File
@@ -121,6 +121,28 @@ 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,
@@ -128,6 +150,7 @@ export function buildMessageWithUser(
reactions: Reaction[] = [],
replyTo: MessageWithUser | null = null,
): MessageWithUser {
const stickerId = (message as any).stickerId ?? null;
return {
id: message.id,
channelId: message.channelId,
@@ -149,6 +172,8 @@ export function buildMessageWithUser(
})),
reactions,
replyTo,
stickerId,
sticker: hydrateSticker(stickerId),
};
}
@@ -252,7 +277,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) {
@@ -268,9 +293,20 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
}
if ((!content || typeof content !== 'string' || content.trim().length === 0) &&
(!attachmentIds || attachmentIds.length === 0)) {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
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 (content && content.length > MAX_MESSAGE_LENGTH) {
@@ -303,6 +339,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
userId: request.userId,
replyToId: replyToId || null,
content: content?.trim() || null,
stickerId: stickerId || null,
createdAt: now,
}).run();
+18 -1
View File
@@ -131,10 +131,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
}
const gifKey = row.gifApiKey as string | null;
const response: InstanceAdminSettings = {
instanceName: row.instanceName ?? 'Backspace',
registrationOpen: row.registrationOpen !== null ? row.registrationOpen === 1 : config.registrationOpen,
discoveryEnabled: row.discoveryEnabled === 1,
gifApiKey: gifKey ? `****${gifKey.slice(-4)}` : undefined,
gifEnabled: !!gifKey,
};
return reply.code(200).send(response);
@@ -145,7 +148,7 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
const db = getDb();
const body = request.body;
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
const updateData: Record<string, number | string | null> = { updatedAt: Date.now() };
if (body.instanceName !== undefined) {
if (typeof body.instanceName !== 'string' || body.instanceName.trim().length === 0 || body.instanceName.trim().length > 32) {
@@ -162,6 +165,17 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
updateData.discoveryEnabled = body.discoveryEnabled ? 1 : 0;
}
if (body.gifApiKey !== undefined) {
// Skip masked placeholder values — the GET endpoint returns '****xxxx' for security,
// so if the client sends that back unchanged, don't corrupt the real key
if (typeof body.gifApiKey === 'string' && body.gifApiKey.startsWith('****')) {
// Masked value — ignore, keep existing key
} else {
// Allow empty string to clear the key
updateData.gifApiKey = body.gifApiKey ? body.gifApiKey.trim() : null;
}
}
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
@@ -169,10 +183,13 @@ export async function settingsRoutes(app: FastifyInstance): Promise<void> {
return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 });
}
const updatedGifKey = updatedRow.gifApiKey as string | null;
const response: InstanceAdminSettings = {
instanceName: updatedRow.instanceName ?? 'Backspace',
registrationOpen: updatedRow.registrationOpen !== null ? updatedRow.registrationOpen === 1 : config.registrationOpen,
discoveryEnabled: updatedRow.discoveryEnabled === 1,
gifApiKey: updatedGifKey ? `****${updatedGifKey.slice(-4)}` : undefined,
gifEnabled: !!updatedGifKey,
};
return reply.code(200).send(response);
+414
View File
@@ -0,0 +1,414 @@
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 });
});
}