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