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.
This commit is contained in:
@@ -2,7 +2,6 @@ import Fastify from 'fastify';
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import rateLimit from '@fastify/rate-limit';
|
import rateLimit from '@fastify/rate-limit';
|
||||||
import websocket from '@fastify/websocket';
|
import websocket from '@fastify/websocket';
|
||||||
import multipart from '@fastify/multipart';
|
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { getDb, getRawDb } from './db/index.js';
|
import { getDb, getRawDb } from './db/index.js';
|
||||||
@@ -65,12 +64,6 @@ async function main(): Promise<void> {
|
|||||||
|
|
||||||
await app.register(websocket);
|
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
|
// Serve built frontend in production
|
||||||
const webDistPath = path.resolve(import.meta.dirname ?? '.', '../../web/dist');
|
const webDistPath = path.resolve(import.meta.dirname ?? '.', '../../web/dist');
|
||||||
if (fs.existsSync(webDistPath)) {
|
if (fs.existsSync(webDistPath)) {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { authenticate } from '../utils/auth.js';
|
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
|
||||||
import { config } from '../config.js';
|
import { config } from '../config.js';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
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<string, string> = {
|
const EXT_MIMETYPES: Record<string, string> = {
|
||||||
'.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
'.webp': 'image/webp', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||||
@@ -27,120 +22,6 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
fs.mkdirSync(config.uploadDir, { recursive: true });
|
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
|
// GET /api/uploads/:filename - Serve uploaded file
|
||||||
app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => {
|
app.get<{ Params: { filename: string } }>('/api/uploads/:filename', async (request, reply) => {
|
||||||
const { filename } = request.params;
|
const { filename } = request.params;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type {
|
|||||||
ChannelCategory,
|
ChannelCategory,
|
||||||
MessageWithUser,
|
MessageWithUser,
|
||||||
MemberWithUser,
|
MemberWithUser,
|
||||||
Attachment,
|
|
||||||
DmChannel,
|
DmChannel,
|
||||||
DmMessageWithUser,
|
DmMessageWithUser,
|
||||||
CreateSpaceRequest,
|
CreateSpaceRequest,
|
||||||
@@ -160,8 +159,6 @@ export class BackspaceApiClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
readonly uploads: {
|
readonly uploads: {
|
||||||
upload: (file: File) => Promise<Attachment>;
|
|
||||||
uploadWithProgress: (file: File, onProgress: (loaded: number, total: number) => void) => Promise<Attachment>;
|
|
||||||
url: (filename: string) => string;
|
url: (filename: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,91 +328,6 @@ export class BackspaceApiClient {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadFile(file: File): Promise<Attachment> {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const token = getToken();
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
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<Attachment>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadFileWithProgress(file: File, onProgress: (loaded: number, total: number) => void): Promise<Attachment> {
|
|
||||||
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 = {
|
this.auth = {
|
||||||
register: (data: RegisterRequest) =>
|
register: (data: RegisterRequest) =>
|
||||||
request<AuthResponse>('POST', '/auth/register', data, false),
|
request<AuthResponse>('POST', '/auth/register', data, false),
|
||||||
@@ -544,8 +456,6 @@ export class BackspaceApiClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.uploads = {
|
this.uploads = {
|
||||||
upload: uploadFile,
|
|
||||||
uploadWithProgress: uploadFileWithProgress,
|
|
||||||
url: (filename: string) => `${baseUrl}/uploads/${filename}`,
|
url: (filename: string) => `${baseUrl}/uploads/${filename}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { Modal } from '../ui/Modal';
|
|||||||
import { ImageCropModal } from '../ui/ImageCropModal';
|
import { ImageCropModal } from '../ui/ImageCropModal';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import { useTransferStore } from '../../stores/transferStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../../api/client';
|
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { SpaceVisibility, AvatarColor } from '@backspace/shared';
|
import type { SpaceVisibility, AvatarColor } from '@backspace/shared';
|
||||||
import { SPACE_GRADIENT_MAP, getSpaceGradient } from '../../utils/gradients';
|
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' });
|
const file = new File([blob], 'icon.png', { type: 'image/png' });
|
||||||
setUploadingIcon(true);
|
setUploadingIcon(true);
|
||||||
try {
|
try {
|
||||||
const attachment = await api.uploads.upload(file);
|
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||||
setIconFilename(attachment.filename);
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
setIconFilename(filename);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to upload icon');
|
setError('Failed to upload icon');
|
||||||
setIconPreview(null);
|
setIconPreview(null);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Avatar } from '../../ui/Avatar';
|
|||||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
import { DeleteAccountModal } from '../DeleteAccountModal';
|
import { DeleteAccountModal } from '../DeleteAccountModal';
|
||||||
import { api } from '../../../api/client';
|
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 { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BANNER_COLOR_PRESETS } from '../../../utils/gradients';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { User, UserStatus, AvatarColor } 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' });
|
const file = new File([blob], 'avatar.webp', { type: blob.type || 'image/webp' });
|
||||||
setUploadingAvatar(true);
|
setUploadingAvatar(true);
|
||||||
try {
|
try {
|
||||||
const attachment = await api.uploads.upload(file);
|
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||||
setAvatarFilename(attachment.filename);
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
setAvatarFilename(filename);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to upload avatar');
|
setError('Failed to upload avatar');
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
@@ -159,8 +162,9 @@ export function AccountPanel() {
|
|||||||
const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' });
|
const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' });
|
||||||
setUploadingBanner(true);
|
setUploadingBanner(true);
|
||||||
try {
|
try {
|
||||||
const attachment = await api.uploads.upload(file);
|
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||||
setBannerFilename(attachment.filename);
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
setBannerFilename(filename);
|
||||||
} catch {
|
} catch {
|
||||||
setError('Failed to upload banner');
|
setError('Failed to upload banner');
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { useAuthStore } from '../../../stores/authStore';
|
|||||||
import { useUIStore } from '../../../stores/uiStore';
|
import { useUIStore } from '../../../stores/uiStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { api } from '../../../api/client';
|
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';
|
import { hasPermissionBit, PermissionBits } from '../../../utils/permissions';
|
||||||
|
|
||||||
interface OverviewPanelProps {
|
interface OverviewPanelProps {
|
||||||
@@ -112,9 +114,12 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
const file = new File([blob], 'icon.webp', { type: blob.type || 'image/webp' });
|
const file = new File([blob], 'icon.webp', { type: blob.type || 'image/webp' });
|
||||||
setUploadingIcon(true);
|
setUploadingIcon(true);
|
||||||
try {
|
try {
|
||||||
const spaceApi = getApiForOrigin(space._instanceOrigin);
|
const tid = await useTransferStore.getState().startUpload(file, {
|
||||||
const attachment = await spaceApi.uploads.upload(file);
|
tray: false,
|
||||||
setIconFilename(attachment.filename);
|
origin: space._instanceOrigin || undefined,
|
||||||
|
});
|
||||||
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
setIconFilename(filename);
|
||||||
} catch {
|
} catch {
|
||||||
setSaveError('Failed to upload icon');
|
setSaveError('Failed to upload icon');
|
||||||
setIconPreview(null);
|
setIconPreview(null);
|
||||||
@@ -150,9 +155,12 @@ export function OverviewPanel({ spaceId }: OverviewPanelProps) {
|
|||||||
const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' });
|
const file = new File([blob], 'banner.webp', { type: blob.type || 'image/webp' });
|
||||||
setUploadingBanner(true);
|
setUploadingBanner(true);
|
||||||
try {
|
try {
|
||||||
const spaceApi = getApiForOrigin(space._instanceOrigin);
|
const tid = await useTransferStore.getState().startUpload(file, {
|
||||||
const attachment = await spaceApi.uploads.upload(file);
|
tray: false,
|
||||||
setBannerFilename(attachment.filename);
|
origin: space._instanceOrigin || undefined,
|
||||||
|
});
|
||||||
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
setBannerFilename(filename);
|
||||||
} catch {
|
} catch {
|
||||||
setSaveError('Failed to upload banner');
|
setSaveError('Failed to upload banner');
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user