Files
f0bm/src/upload_handler.mjs
2026-01-23 23:42:23 +01:00

255 lines
8.6 KiB
JavaScript

import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import cfg from "./inc/config.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
// Native multipart form data parser
const parseMultipart = (buffer, boundary) => {
const parts = {};
const boundaryBuffer = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0;
let idx;
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
if (start !== 0) {
segments.push(buffer.slice(start, idx - 2)); // -2 for \r\n before boundary
}
start = idx + boundaryBuffer.length + 2; // +2 for \r\n after boundary
}
for (const segment of segments) {
const headerEnd = segment.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const headers = segment.slice(0, headerEnd).toString();
const body = segment.slice(headerEnd + 4);
const nameMatch = headers.match(/name="([^"]+)"/);
const filenameMatch = headers.match(/filename="([^"]+)"/);
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
const name = nameMatch[1];
if (filenameMatch) {
parts[name] = {
filename: filenameMatch[1],
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
} else {
parts[name] = body.toString().trim();
}
}
}
return parts;
};
// Collect request body as buffer
const collectBody = (req) => {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
// Ensure stream flows
if (req.isPaused()) req.resume();
});
};
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
};
export const handleUpload = async (req, res) => {
console.log('[UPLOAD HANDLER] Started');
// Manual Session Lookup (because flummpress middleware might not have finished)
// We assume req.cookies is populated by framework or we need to parse it?
// index.mjs accesses req.cookies directly, so we assume it works.
let user = [];
if (req.cookies && req.cookies.session) {
user = await db`
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id
where "user_sessions".session = ${lib.md5(req.cookies.session)}
limit 1
`;
}
if (user.length === 0) {
console.log('[UPLOAD HANDLER] Unauthorized - No valid session found');
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
// Mock req.session for consistency if needed by other logic, though we use 'user[0]' here
req.session = user[0];
console.log('[UPLOAD HANDLER] Authorized:', req.session.user);
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
console.log('[UPLOAD HANDLER] No boundary');
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
console.log('[UPLOAD HANDLER] Collecting body...');
const body = await collectBody(req);
console.log('[UPLOAD HANDLER] Body collected, size:', body.length);
const parts = parseMultipart(body, boundaryMatch[1]);
// Validate required fields
const file = parts.file;
const rating = parts.rating;
const tagsRaw = parts.tags;
if (!file || !file.data) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
if (!rating || !['sfw', 'nsfw'].includes(rating)) {
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw) is required' }, 400);
}
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0) : [];
if (tags.length < 3) {
return sendJson(res, { success: false, msg: 'At least 3 tags are required' }, 400);
}
// Validate MIME type
const allowedMimes = ['video/mp4', 'video/webm'];
let mime = file.contentType;
if (!allowedMimes.includes(mime)) {
return sendJson(res, { success: false, msg: `Invalid file type. Only mp4 and webm allowed. Got: ${mime}` }, 400);
}
// Validate file size
const maxfilesize = cfg.main.maxfilesize;
const size = file.data.length;
if (size > maxfilesize) {
return sendJson(res, {
success: false,
msg: `File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`
}, 400);
}
// Generate UUID
const uuid = await queue.genuuid();
const ext = mime === 'video/mp4' ? 'mp4' : 'webm';
const filename = `${uuid}.${ext}`;
const tmpPath = `./tmp/${filename}`;
const destPath = `./public/b/${filename}`;
// Ensure directories exist
await fs.mkdir('./tmp', { recursive: true });
await fs.mkdir('./public/b', { recursive: true });
// Save temporarily
await fs.writeFile(tmpPath, file.data);
// Verify MIME
const actualMime = (await queue.exec(`file --mime-type -b ${tmpPath}`)).stdout.trim();
if (!allowedMimes.includes(actualMime)) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
}
// Constants
const checksum = (await queue.exec(`sha256sum ${tmpPath}`)).stdout.trim().split(" ")[0];
// Check repost
const repost = await queue.checkrepostsum(checksum);
if (repost) {
await fs.unlink(tmpPath).catch(() => { });
return sendJson(res, {
success: false,
msg: `This file already exists`,
repost: repost
}, 409);
}
// Move to public
await fs.copyFile(tmpPath, destPath);
await fs.unlink(tmpPath).catch(() => { });
// Insert
await db`
insert into items ${db({
src: '',
dest: filename,
mime: actualMime,
size: size,
checksum: checksum,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: false
}, 'src', 'dest', 'mime', 'size', 'checksum', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')
}
`;
const itemid = await queue.getItemID(filename);
// Thumbnail
try {
await queue.genThumbnail(filename, actualMime, itemid, '');
} catch (err) {
await queue.exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
}
// Tags
const ratingTagId = rating === 'sfw' ? 1 : 2;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
`;
for (const tagName of tags) {
let tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
let tagId;
if (tagRow.length === 0) {
await db`
insert into tags ${db({ tag: tagName }, 'tag')}
`;
tagRow = await db`
select id from tags where normalized = slugify(${tagName}) limit 1
`;
}
tagId = tagRow[0].id;
await db`
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
on conflict do nothing
`;
}
return sendJson(res, {
success: true,
msg: 'Upload successful! Your upload is pending admin approval.',
itemid: itemid
});
} catch (err) {
console.error('[UPLOAD HANDLER ERROR]', err);
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
}
};