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