From 2be243336b4ce1580e168b7126c9e3cee89c6d28 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 2 May 2026 16:46:29 +0200 Subject: [PATCH] feat: all profile uploads through transferStore; delete legacy POST /api/uploads Migrates the remaining 5 profile/space upload sites (CreateSpace, AccountPanel avatar+banner, OverviewPanel icon+banner) to transferStore.startUpload with tray:false. Space sites pass _instanceOrigin so uploads route to the space's home instance. Removes upload/uploadWithProgress from api.uploads (and their private uploadFile/uploadFileWithProgress helpers); api.uploads.url is preserved for GET-path URL building. Deletes the server-side POST /api/uploads handler and the now-unused @fastify/multipart plugin registration. GET /api/uploads/:filename remains intact. --- packages/server/src/index.ts | 7 -- packages/server/src/routes/uploads.ts | 119 ------------------ packages/web/src/api/client.ts | 90 ------------- .../web/src/components/modals/CreateSpace.tsx | 8 +- .../modals/settingsPanels/AccountPanel.tsx | 12 +- .../spaceSettingsPanels/OverviewPanel.tsx | 22 ++-- 6 files changed, 28 insertions(+), 230 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index cfcc9b61..53e76fc1 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -2,7 +2,6 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; import rateLimit from '@fastify/rate-limit'; import websocket from '@fastify/websocket'; -import multipart from '@fastify/multipart'; import fastifyStatic from '@fastify/static'; import { config } from './config.js'; import { getDb, getRawDb } from './db/index.js'; @@ -65,12 +64,6 @@ async function main(): Promise { await app.register(websocket); - await app.register(multipart, { - limits: { - fileSize: Number.MAX_SAFE_INTEGER, // No global cap — actual limit enforced per-request from DB - }, - }); - // Serve built frontend in production const webDistPath = path.resolve(import.meta.dirname ?? '.', '../../web/dist'); if (fs.existsSync(webDistPath)) { diff --git a/packages/server/src/routes/uploads.ts b/packages/server/src/routes/uploads.ts index 09f6ab45..3a6a72d8 100644 --- a/packages/server/src/routes/uploads.ts +++ b/packages/server/src/routes/uploads.ts @@ -1,14 +1,9 @@ import type { FastifyInstance } from 'fastify'; -import { authenticate } from '../utils/auth.js'; -import { generateSnowflake } from '../utils/snowflake.js'; import { config } from '../config.js'; import { getDb, schema } from '../db/index.js'; import { eq } from 'drizzle-orm'; import fs from 'fs'; import path from 'path'; -import { pipeline } from 'stream/promises'; -import type { Attachment } from '@backspace/shared'; -import { generateThumbnail, isResizableImage, probeImageDimensions, probeMediaMeta, generateVideoThumbnail } from '../utils/thumbnail.js'; const EXT_MIMETYPES: Record = { '.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', @@ -27,120 +22,6 @@ export async function uploadRoutes(app: FastifyInstance): Promise { fs.mkdirSync(config.uploadDir, { recursive: true }); } - // POST /api/uploads - Upload a file - app.post('/api/uploads', { - preHandler: authenticate, - config: { - rateLimit: { - max: 30, - timeWindow: '1 minute', - keyGenerator: (request: any) => (request as any).userId || request.ip, - }, - }, - }, async (request, reply) => { - // Read dynamic upload limit from instance settings - const db = getDb(); - const settings = db.select({ maxUploadSizeBytes: schema.instanceSettings.maxUploadSizeBytes }) - .from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get(); - const maxSize = settings?.maxUploadSizeBytes ?? config.maxUploadSize; - - const data = await request.file({ limits: { fileSize: maxSize } }); - if (!data) { - return reply.code(400).send({ error: 'No file provided', statusCode: 400 }); - } - - const originalName = data.filename; - const mimetype = data.mimetype; - - // Generate unique filename - const id = generateSnowflake(); - const ext = path.extname(originalName); - const filename = `${id}${ext}`; - const filepath = path.join(config.uploadDir, filename); - - // Save file to disk - const writeStream = fs.createWriteStream(filepath); - await pipeline(data.file, writeStream); - - // Check if file was truncated by multipart limit - if ((data.file as any).truncated) { - fs.unlinkSync(filepath); - return reply.code(413).send({ error: 'File too large', statusCode: 413 }); - } - - // Get file size - const stats = fs.statSync(filepath); - const size = stats.size; - - const now = Date.now(); - - // ─── Media processing ──────────────────────────────────────────────────── - let thumbnailFilename: string | null = null; - let width: number | null = null; - let height: number | null = null; - let duration: number | null = null; - - if (isResizableImage(mimetype)) { - // Image: generate thumbnail (unchanged) + probe dimensions - thumbnailFilename = await generateThumbnail(filepath, mimetype, config.uploadDir); - const dims = await probeImageDimensions(filepath); - if (dims) { - width = dims.width; - height = dims.height; - } - } else if (mimetype.startsWith('video/')) { - // Video: generate thumbnail frame + probe duration - const videoThumb = await generateVideoThumbnail(filepath, config.uploadDir); - if (videoThumb) { - thumbnailFilename = videoThumb.thumbnailFilename; - width = videoThumb.width; - height = videoThumb.height; - } - // Duration (and fallback dimensions if thumbnail failed) - const meta = await probeMediaMeta(filepath, mimetype); - if (meta) { - duration = meta.duration ?? null; - if (width === null && meta.width) width = meta.width; - if (height === null && meta.height) height = meta.height; - } - } else if (mimetype.startsWith('audio/')) { - // Audio: duration only - const meta = await probeMediaMeta(filepath, mimetype); - if (meta?.duration) duration = meta.duration; - } - - // Save attachment record - db.insert(schema.attachments).values({ - id, - uploaderId: request.userId, - filename, - originalName, - mimetype, - size, - thumbnailFilename, - width, - height, - duration, - createdAt: now, - }).run(); - - const attachment: Attachment = { - id, - messageId: '', - filename, - originalName, - mimetype, - size, - thumbnailFilename: thumbnailFilename ?? undefined, - width: width ?? undefined, - height: height ?? undefined, - duration: duration ?? undefined, - createdAt: now, - }; - - return reply.code(201).send(attachment); - }); - // GET /api/uploads/:filename - Serve uploaded file app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => { const { filename } = request.params; diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index a5c40a89..804c6178 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -9,7 +9,6 @@ import type { ChannelCategory, MessageWithUser, MemberWithUser, - Attachment, DmChannel, DmMessageWithUser, CreateSpaceRequest, @@ -160,8 +159,6 @@ export class BackspaceApiClient { }; readonly uploads: { - upload: (file: File) => Promise; - uploadWithProgress: (file: File, onProgress: (loaded: number, total: number) => void) => Promise; url: (filename: string) => string; }; @@ -331,91 +328,6 @@ export class BackspaceApiClient { return response.json() as Promise; } - async function uploadFile(file: File): Promise { - const formData = new FormData(); - formData.append('file', file); - - const token = getToken(); - const headers: Record = {}; - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 120000); - - let response: Response; - try { - response = await fetch(`${baseUrl}/uploads`, { - method: 'POST', - headers, - body: formData, - signal: controller.signal, - }); - } catch (err) { - clearTimeout(timeoutId); - if (err instanceof DOMException && err.name === 'AbortError') { - throw new Error('Request timed out'); - } - throw err; - } - clearTimeout(timeoutId); - - if (!response.ok) { - if (response.status === 401 && onUnauthorized) { - onUnauthorized(); - } - if (response.status === 429) { - const body = await response.json().catch(() => ({})); - const retryAfter = (body as { retryAfter?: number }).retryAfter - ?? (parseInt(response.headers.get('retry-after') || '', 10) || 60); - throw new RateLimitError(retryAfter); - } - const error = await response.json().catch(() => ({ error: 'Upload failed' })); - throw new HttpError(response.status, (error as { error: string }).error || `HTTP ${response.status}`); - } - - return response.json() as Promise; - } - - function uploadFileWithProgress(file: File, onProgress: (loaded: number, total: number) => void): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - const formData = new FormData(); - formData.append('file', file); - - xhr.upload.addEventListener('progress', (e) => { - if (e.lengthComputable) onProgress(e.loaded, e.total); - }); - - xhr.addEventListener('load', () => { - if (xhr.status >= 200 && xhr.status < 300) { - try { resolve(JSON.parse(xhr.responseText)); } - catch { reject(new Error('Invalid server response')); } - } else if (xhr.status === 401 && onUnauthorized) { - onUnauthorized(); - reject(new Error('Unauthorized')); - } else if (xhr.status === 413) { - reject(new Error('File too large')); - } else { - try { - const body = JSON.parse(xhr.responseText); - reject(new Error(body.error || `Upload failed (${xhr.status})`)); - } catch { reject(new Error(`Upload failed (${xhr.status})`)); } - } - }); - - xhr.addEventListener('error', () => reject(new Error('Upload failed — network error'))); - xhr.addEventListener('timeout', () => reject(new Error('Upload timed out'))); - - xhr.open('POST', `${baseUrl}/uploads`); - xhr.timeout = 10 * 60 * 1000; // 10 minutes for large files - const token = getToken(); - if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`); - xhr.send(formData); - }); - } - this.auth = { register: (data: RegisterRequest) => request('POST', '/auth/register', data, false), @@ -544,8 +456,6 @@ export class BackspaceApiClient { }; this.uploads = { - upload: uploadFile, - uploadWithProgress: uploadFileWithProgress, url: (filename: string) => `${baseUrl}/uploads/${filename}`, }; diff --git a/packages/web/src/components/modals/CreateSpace.tsx b/packages/web/src/components/modals/CreateSpace.tsx index f5820029..8025d3e9 100644 --- a/packages/web/src/components/modals/CreateSpace.tsx +++ b/packages/web/src/components/modals/CreateSpace.tsx @@ -3,8 +3,9 @@ import { Modal } from '../ui/Modal'; import { ImageCropModal } from '../ui/ImageCropModal'; import { useSpaceStore } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; +import { useTransferStore } from '../../stores/transferStore'; import { useNavigate } from 'react-router-dom'; -import { api } from '../../api/client'; +import { waitForTransferAttachment } from '../../utils/waitForTransfer'; import { AVATAR_COLORS } from '@backspace/shared'; import type { SpaceVisibility, AvatarColor } from '@backspace/shared'; import { SPACE_GRADIENT_MAP, getSpaceGradient } from '../../utils/gradients'; @@ -60,8 +61,9 @@ export function CreateSpaceModal() { const file = new File([blob], 'icon.png', { type: 'image/png' }); setUploadingIcon(true); try { - const attachment = await api.uploads.upload(file); - setIconFilename(attachment.filename); + const tid = await useTransferStore.getState().startUpload(file, { tray: false }); + const { filename } = await waitForTransferAttachment(tid); + setIconFilename(filename); } catch { setError('Failed to upload icon'); setIconPreview(null); diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 6f1e224c..ba48068b 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -6,6 +6,8 @@ import { Avatar } from '../../ui/Avatar'; import { ImageCropModal } from '../../ui/ImageCropModal'; import { DeleteAccountModal } from '../DeleteAccountModal'; import { api } from '../../../api/client'; +import { useTransferStore } from '../../../stores/transferStore'; +import { waitForTransferAttachment } from '../../../utils/waitForTransfer'; import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BANNER_COLOR_PRESETS } from '../../../utils/gradients'; import { AVATAR_COLORS } from '@backspace/shared'; import type { User, UserStatus, AvatarColor } from '@backspace/shared'; @@ -140,8 +142,9 @@ export function AccountPanel() { const file = new File([blob], 'avatar.webp', { type: blob.type || 'image/webp' }); setUploadingAvatar(true); try { - const attachment = await api.uploads.upload(file); - setAvatarFilename(attachment.filename); + const tid = await useTransferStore.getState().startUpload(file, { tray: false }); + const { filename } = await waitForTransferAttachment(tid); + setAvatarFilename(filename); } catch { setError('Failed to upload avatar'); setAvatarPreview(null); @@ -159,8 +162,9 @@ export function AccountPanel() { const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' }); setUploadingBanner(true); try { - const attachment = await api.uploads.upload(file); - setBannerFilename(attachment.filename); + const tid = await useTransferStore.getState().startUpload(file, { tray: false }); + const { filename } = await waitForTransferAttachment(tid); + setBannerFilename(filename); } catch { setError('Failed to upload banner'); setBannerPreview(null); diff --git a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx index 3063e4ba..125972da 100644 --- a/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx +++ b/packages/web/src/components/modals/spaceSettingsPanels/OverviewPanel.tsx @@ -8,7 +8,9 @@ import { useAuthStore } from '../../../stores/authStore'; import { useUIStore } from '../../../stores/uiStore'; import { useNavigate } from 'react-router-dom'; import { api } from '../../../api/client'; -import { getApiForOrigin, getMyUserIdForOrigin } from '../../../stores/spaceStore'; +import { useTransferStore } from '../../../stores/transferStore'; +import { waitForTransferAttachment } from '../../../utils/waitForTransfer'; +import { getMyUserIdForOrigin } from '../../../stores/spaceStore'; import { hasPermissionBit, PermissionBits } from '../../../utils/permissions'; interface OverviewPanelProps { @@ -112,9 +114,12 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { const file = new File([blob], 'icon.webp', { type: blob.type || 'image/webp' }); setUploadingIcon(true); try { - const spaceApi = getApiForOrigin(space._instanceOrigin); - const attachment = await spaceApi.uploads.upload(file); - setIconFilename(attachment.filename); + const tid = await useTransferStore.getState().startUpload(file, { + tray: false, + origin: space._instanceOrigin || undefined, + }); + const { filename } = await waitForTransferAttachment(tid); + setIconFilename(filename); } catch { setSaveError('Failed to upload icon'); setIconPreview(null); @@ -150,9 +155,12 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) { const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' }); setUploadingBanner(true); try { - const spaceApi = getApiForOrigin(space._instanceOrigin); - const attachment = await spaceApi.uploads.upload(file); - setBannerFilename(attachment.filename); + const tid = await useTransferStore.getState().startUpload(file, { + tray: false, + origin: space._instanceOrigin || undefined, + }); + const { filename } = await waitForTransferAttachment(tid); + setBannerFilename(filename); } catch { setSaveError('Failed to upload banner'); setBannerPreview(null);