261 lines
9.9 KiB
JavaScript
261 lines
9.9 KiB
JavaScript
import { promises as fs } from "fs";
|
|
import db from '../../sql.mjs';
|
|
import lib from '../../lib.mjs';
|
|
import cfg from '../../config.mjs';
|
|
import queue from '../../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 with debug logging
|
|
const collectBody = (req) => {
|
|
return new Promise((resolve, reject) => {
|
|
console.log('[UPLOAD DEBUG] collectBody started');
|
|
const chunks = [];
|
|
req.on('data', chunk => {
|
|
// console.log(`[UPLOAD DEBUG] chunk received: ${chunk.length} bytes`);
|
|
chunks.push(chunk);
|
|
});
|
|
req.on('end', () => {
|
|
console.log(`[UPLOAD DEBUG] Stream ended. Total size: ${chunks.reduce((acc, c) => acc + c.length, 0)}`);
|
|
resolve(Buffer.concat(chunks));
|
|
});
|
|
req.on('error', err => {
|
|
console.error('[UPLOAD DEBUG] Stream error:', err);
|
|
reject(err);
|
|
});
|
|
|
|
// Ensure stream is flowing
|
|
if (req.isPaused()) {
|
|
console.log('[UPLOAD DEBUG] Stream was paused, resuming...');
|
|
req.resume();
|
|
}
|
|
});
|
|
};
|
|
|
|
export default router => {
|
|
router.group(/^\/api\/v2/, group => {
|
|
|
|
group.post(/\/upload$/, lib.loggedin, async (req, res) => {
|
|
try {
|
|
console.log('[UPLOAD DEBUG] Request received');
|
|
// Use stored content type if available (from middleware bypass), otherwise use header
|
|
const contentType = req._multipartContentType || req.headers['content-type'] || '';
|
|
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
|
|
|
if (!boundaryMatch) {
|
|
console.log('[UPLOAD DEBUG] No boundary found');
|
|
return res.json({ success: false, msg: 'Invalid content type' }, 400);
|
|
}
|
|
|
|
let body;
|
|
if (req.bodyPromise) {
|
|
console.log('[UPLOAD DEBUG] Waiting for buffered body from middleware promise...');
|
|
body = await req.bodyPromise;
|
|
console.log('[UPLOAD DEBUG] Received body from promise');
|
|
} else if (req.rawBody) {
|
|
console.log('[UPLOAD DEBUG] Using buffered body from middleware');
|
|
body = req.rawBody;
|
|
} else {
|
|
console.log('[UPLOAD DEBUG] Collecting body via collectBody...');
|
|
body = await collectBody(req);
|
|
}
|
|
|
|
if (!body) {
|
|
return res.json({ success: false, msg: 'Failed to receive file body' }, 400);
|
|
}
|
|
|
|
console.log('[UPLOAD DEBUG] Body size:', body.length);
|
|
const parts = parseMultipart(body, boundaryMatch[1]);
|
|
console.log('[UPLOAD DEBUG] Parsed parts:', Object.keys(parts));
|
|
|
|
// Validate required fields
|
|
const file = parts.file;
|
|
const rating = parts.rating; // 'sfw' or 'nsfw'
|
|
const tagsRaw = parts.tags; // comma-separated tags
|
|
|
|
if (!file || !file.data) {
|
|
return res.json({ success: false, msg: 'No file provided' }, 400);
|
|
}
|
|
|
|
if (!rating || !['sfw', 'nsfw'].includes(rating)) {
|
|
return res.json({ 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 res.json({ 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 res.json({ 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 res.json({
|
|
success: false,
|
|
msg: `File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`
|
|
}, 400);
|
|
}
|
|
|
|
// Generate UUID for filename
|
|
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}`;
|
|
|
|
// Save file temporarily
|
|
await fs.writeFile(tmpPath, file.data);
|
|
|
|
// Verify MIME with file command
|
|
const actualMime = (await queue.exec(`file --mime-type -b ${tmpPath}`)).stdout.trim();
|
|
if (!allowedMimes.includes(actualMime)) {
|
|
await fs.unlink(tmpPath).catch(() => { });
|
|
return res.json({ success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
|
|
}
|
|
|
|
// Generate checksum
|
|
const checksum = (await queue.exec(`sha256sum ${tmpPath}`)).stdout.trim().split(" ")[0];
|
|
|
|
// Check for repost
|
|
const repost = await queue.checkrepostsum(checksum);
|
|
if (repost) {
|
|
await fs.unlink(tmpPath).catch(() => { });
|
|
return res.json({
|
|
success: false,
|
|
msg: `This file already exists`,
|
|
repost: repost
|
|
}, 409);
|
|
}
|
|
|
|
// Move to public folder
|
|
await fs.copyFile(tmpPath, destPath);
|
|
await fs.unlink(tmpPath).catch(() => { });
|
|
|
|
// Insert into database (active=false for admin approval)
|
|
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')
|
|
}
|
|
`;
|
|
|
|
// Get the new item ID
|
|
const itemid = await queue.getItemID(filename);
|
|
|
|
// Generate thumbnail
|
|
try {
|
|
await queue.genThumbnail(filename, actualMime, itemid, '');
|
|
} catch (err) {
|
|
await queue.exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
|
|
}
|
|
|
|
// Assign rating tag (sfw=1, nsfw=2)
|
|
const ratingTagId = rating === 'sfw' ? 1 : 2;
|
|
await db`
|
|
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
|
|
`;
|
|
|
|
// Assign user tags
|
|
for (const tagName of tags) {
|
|
// Check if tag exists, create if not
|
|
let tagRow = await db`
|
|
select id from tags where normalized = slugify(${tagName}) limit 1
|
|
`;
|
|
|
|
let tagId;
|
|
if (tagRow.length === 0) {
|
|
// Create new tag
|
|
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;
|
|
|
|
// Assign tag to item
|
|
await db`
|
|
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
|
|
on conflict do nothing
|
|
`;
|
|
}
|
|
|
|
return res.json({
|
|
success: true,
|
|
msg: 'Upload successful! Your upload is pending admin approval.',
|
|
itemid: itemid
|
|
});
|
|
|
|
} catch (err) {
|
|
console.error('[UPLOAD ERROR]', err);
|
|
return res.json({ success: false, msg: 'Upload failed: ' + err.message }, 500);
|
|
}
|
|
});
|
|
|
|
});
|
|
|
|
return router;
|
|
};
|