realizing webupload with approval functionality

This commit is contained in:
x
2026-01-23 23:35:12 +01:00
parent 42f4e19897
commit 577d73af11
9 changed files with 1433 additions and 137 deletions

262
public/s/js/upload.js Normal file
View File

@@ -0,0 +1,262 @@
(() => {
const form = document.getElementById('upload-form');
if (!form) return;
const fileInput = document.getElementById('file-input');
const dropZone = document.getElementById('drop-zone');
const filePreview = document.getElementById('file-preview');
const dropZonePrompt = dropZone.querySelector('.drop-zone-prompt');
const fileName = document.getElementById('file-name');
const fileSize = document.getElementById('file-size');
const removeFile = document.getElementById('remove-file');
const tagInput = document.getElementById('tag-input');
const tagsList = document.getElementById('tags-list');
const tagsHidden = document.getElementById('tags-hidden');
const tagCount = document.getElementById('tag-count');
const tagSuggestions = document.getElementById('tag-suggestions');
const submitBtn = document.getElementById('submit-btn');
const progressContainer = document.getElementById('upload-progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
const statusDiv = document.getElementById('upload-status');
let tags = [];
let selectedFile = null;
const formatSize = (bytes) => {
const units = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (bytes >= 1024 && i < units.length - 1) {
bytes /= 1024;
i++;
}
return bytes.toFixed(2) + ' ' + units[i];
};
const updateSubmitButton = () => {
const rating = document.querySelector('input[name="rating"]:checked');
const hasFile = selectedFile !== null;
const hasRating = rating !== null;
const hasTags = tags.length >= 3;
submitBtn.disabled = !(hasFile && hasRating && hasTags);
if (!hasTags) {
submitBtn.querySelector('.btn-text').textContent = (3 - tags.length) + ' more tag' + (3 - tags.length !== 1 ? 's' : '') + ' required';
} else if (!hasFile) {
submitBtn.querySelector('.btn-text').textContent = 'Select a file';
} else if (!hasRating) {
submitBtn.querySelector('.btn-text').textContent = 'Select SFW or NSFW';
} else {
submitBtn.querySelector('.btn-text').textContent = 'Upload';
}
tagCount.textContent = '(' + tags.length + '/3 minimum)';
tagCount.classList.toggle('valid', tags.length >= 3);
};
const handleFile = (file) => {
if (!file) return;
const validTypes = ['video/mp4', 'video/webm'];
if (!validTypes.includes(file.type)) {
statusDiv.textContent = 'Only mp4 and webm files are allowed';
statusDiv.className = 'upload-status error';
return;
}
selectedFile = file;
fileName.textContent = file.name;
fileSize.textContent = formatSize(file.size);
dropZonePrompt.style.display = 'none';
filePreview.style.display = 'flex';
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
updateSubmitButton();
};
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
removeFile.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
selectedFile = null;
fileInput.value = '';
dropZonePrompt.style.display = 'block';
filePreview.style.display = 'none';
updateSubmitButton();
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
handleFile(e.dataTransfer.files[0]);
});
const addTag = (tagName) => {
tagName = tagName.trim().toLowerCase();
if (!tagName || tags.includes(tagName)) return;
if (tagName === 'sfw' || tagName === 'nsfw') return;
tags.push(tagName);
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.innerHTML = tagName + '<button type="button">&times;</button>';
chip.querySelector('button').addEventListener('click', () => {
tags = tags.filter(t => t !== tagName);
chip.remove();
updateSubmitButton();
});
tagsList.appendChild(chip);
tagsHidden.value = tags.join(',');
tagInput.value = '';
tagSuggestions.innerHTML = '';
tagSuggestions.classList.remove('show');
updateSubmitButton();
};
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag(tagInput.value);
}
});
let debounceTimer;
tagInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
const query = tagInput.value.trim();
if (query.length < 2) {
tagSuggestions.classList.remove('show');
return;
}
debounceTimer = setTimeout(async () => {
try {
const res = await fetch('/api/v2/admin/tags/suggest?q=' + encodeURIComponent(query));
const data = await res.json();
if (data.success && data.suggestions && data.suggestions.length > 0) {
const filtered = data.suggestions.filter(s => !tags.includes(s.tag.toLowerCase()));
let html = '';
for (let i = 0; i < Math.min(8, filtered.length); i++) {
html += '<div class="tag-suggestion">' + filtered[i].tag + '</div>';
}
tagSuggestions.innerHTML = html;
tagSuggestions.classList.add('show');
tagSuggestions.querySelectorAll('.tag-suggestion').forEach(el => {
el.addEventListener('click', () => addTag(el.textContent));
});
} else {
tagSuggestions.classList.remove('show');
}
} catch (err) {
console.error(err);
}
}, 200);
});
document.addEventListener('click', (e) => {
if (!tagInput.contains(e.target) && !tagSuggestions.contains(e.target)) {
tagSuggestions.classList.remove('show');
}
});
document.querySelectorAll('input[name="rating"]').forEach(radio => {
radio.addEventListener('change', updateSubmitButton);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile || tags.length < 3) return;
const rating = document.querySelector('input[name="rating"]:checked');
if (!rating) return;
submitBtn.disabled = true;
submitBtn.querySelector('.btn-text').style.display = 'none';
submitBtn.querySelector('.btn-loading').style.display = 'inline';
progressContainer.style.display = 'flex';
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('rating', rating.value);
formData.append('tags', tags.join(','));
try {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressText.textContent = percent + '%';
}
});
xhr.onload = () => {
const res = JSON.parse(xhr.responseText);
if (res.success) {
statusDiv.innerHTML = '✓ ' + res.msg;
statusDiv.className = 'upload-status success';
form.reset();
tags = [];
tagsList.innerHTML = '';
selectedFile = null;
dropZonePrompt.style.display = 'block';
filePreview.style.display = 'none';
} else {
statusDiv.textContent = '✕ ' + res.msg;
statusDiv.className = 'upload-status error';
if (res.repost) {
statusDiv.innerHTML += ' <a href="/' + res.repost + '">View existing</a>';
}
}
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
progressContainer.style.display = 'none';
progressFill.style.width = '0%';
updateSubmitButton();
};
xhr.onerror = () => {
statusDiv.textContent = '✕ Upload failed. Please try again.';
statusDiv.className = 'upload-status error';
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
progressContainer.style.display = 'none';
updateSubmitButton();
};
xhr.open('POST', '/api/v2/upload');
xhr.send(formData);
} catch (err) {
console.error(err);
statusDiv.textContent = '✕ Upload failed: ' + err.message;
statusDiv.className = 'upload-status error';
submitBtn.querySelector('.btn-text').style.display = 'inline';
submitBtn.querySelector('.btn-loading').style.display = 'none';
updateSubmitButton();
}
});
updateSubmitButton();
})();

View File

@@ -49,8 +49,7 @@ export default (router, tpl) => {
}; };
await db` await db`
insert into "user_sessions" ${ insert into "user_sessions" ${db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi')
} }
`; `;
@@ -121,79 +120,142 @@ export default (router, tpl) => {
}); });
}); });
// router.get(/^\/admin\/log(\/)?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/approve\/?/, lib.auth, async (req, res) => {
// // Funktioniert ohne systemd service natürlich nicht. if (req.url.qs?.id) {
// exec("journalctl -qeu f0ck --no-pager", (err, stdout) => { const id = +req.url.qs.id;
// res.reply({ const f0ck = await db`
// body: tpl.render("admin/log", { select dest, mime
// log: stdout.split("\n").slice(0, -1), from "items"
// tmp: null where
// }, req) id = ${id} and
// }); active = 'false'
// }); limit 1
// }); `;
if (f0ck.length === 0) {
return res.reply({
body: `f0ck ${id}: f0ck not found`
});
}
// router.get(/^\/admin\/recover\/?/, lib.auth, async (req, res) => { await db`update "items" set active = 'true' where id = ${id}`;
// Gelöschte Objekte werden nicht aufgehoben.
// if(req.url.qs?.id) {
// const id = +req.url.qs.id;
// const f0ck = await db`
// select dest, mime
// from "items"
// where
// id = ${id} and
// active = 'false'
// limit 1
// `;
// if(f0ck.length === 0) {
// return res.reply({
// body: `f0ck ${id}: f0ck not found`
// });
// }
// await db`update "items" set active = 'true' where id = ${id}`; // Check if files need moving (if they are in deleted/)
try {
await fs.access(`./public/b/${f0ck[0].dest}`);
// Exists in public, good (new upload)
} catch {
// Not in public, likely a deleted item being recovered
await fs.copyFile(`./deleted/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).catch(_ => { });
await fs.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).catch(_ => { });
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_ => { });
await fs.unlink(`./deleted/t/${id}.webp`).catch(_ => { });
// await fs.copyFile(`./deleted/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).catch(_=>{}); if (f0ck[0].mime.startsWith('audio')) {
// await fs.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).catch(_=>{}); await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_ => { });
// await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_=>{}); await fs.unlink(`./deleted/ca/${id}.webp`).catch(_ => { });
// await fs.unlink(`./deleted/t/${id}.webp`).catch(_=>{}); }
}
// if(f0ck[0].mime.startsWith('audio')) { return res.writeHead(302, {
// await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_=>{}); "Location": `/${id}`
// await fs.unlink(`./deleted/ca/${id}.webp`).catch(_=>{}); }).end();
// } }
// return res.reply({ const _posts = await db`
// body: `f0ck ${id} recovered. <a href="/admin/recover">back</a>` select id, mime, username, dest
// }); from "items"
// } where
active = 'false'
order by id desc
`;
// const _posts = await db` if (_posts.length === 0) {
// select id, mime, username return res.reply({
// from "items" body: tpl.render('admin/approve', { posts: [], tmp: null }, req)
// where });
// active = 'false' }
// order by id desc
// `;
// if(_posts.length === 0) { const posts = await Promise.all(_posts.map(async p => {
// return res.reply({ // Try to get thumbnail from public or deleted
// body: 'blah' let thumb;
// }); try {
// } // Try public first
thumb = (await fs.readFile(`./public/t/${p.id}.webp`)).toString('base64');
} catch {
try {
thumb = (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64');
} catch {
thumb = ""; // No thumbnail?
}
}
return {
...p,
thumbnail: thumb
};
}));
// const posts = await Promise.all(_posts.map(async p => ({ res.reply({
// ...p, body: tpl.render('admin/approve', {
// thumbnail: (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64') posts,
// }))); tmp: null
}, req)
});
});
// res.reply({ router.get(/^\/admin\/deny\/?/, lib.auth, async (req, res) => {
// body: tpl.render('admin/recover', { console.log('[ADMIN DENY] Logs initiated');
// posts, if (req.url.qs?.id) {
// tmp: null const id = +req.url.qs.id;
// }, req) console.log(`[ADMIN DENY] Denying ID: ${id}`);
// });
// }); try {
const f0ck = await db`
select dest, mime
from "items"
where
id = ${id}
limit 1
`;
if (f0ck.length > 0) {
console.log(`[ADMIN DENY] Found item, deleting files: ${f0ck[0].dest}`);
// Delete files
await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(e => console.log('File error pub/b:', e.message));
await fs.unlink(`./public/t/${id}.webp`).catch(e => console.log('File error pub/t:', e.message));
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(e => console.log('File error del/b:', e.message));
await fs.unlink(`./deleted/t/${id}.webp`).catch(e => console.log('File error del/t:', e.message));
if (f0ck[0].mime.startsWith('audio')) {
await fs.unlink(`./public/ca/${id}.webp`).catch(() => { });
await fs.unlink(`./deleted/ca/${id}.webp`).catch(() => { });
}
// Delete DB entries
console.log('[ADMIN DENY] Deleting DB entries...');
try {
await db`delete from "tags_assign" where item_id = ${id}`;
await db`delete from "favorites" where item_id = ${id}`;
await db`delete from "comments" where item_id = ${id}`.catch(() => { });
await db`delete from "items" where id = ${id}`;
console.log('[ADMIN DENY] Deleted successfully');
} catch (dbErr) {
console.error('[ADMIN DENY DB ERROR]', dbErr);
}
} else {
console.log('[ADMIN DENY] Item not found in DB');
}
} catch (err) {
console.error('[ADMIN DENY ERROR]', err);
}
return res.writeHead(302, {
"Location": `/admin/approve`
}).end();
}
console.log('[ADMIN DENY] No ID provided');
return res.writeHead(302, { "Location": "/admin/approve" }).end();
});
return router; return router;
}; };

View File

@@ -0,0 +1,260 @@
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;
};

View File

@@ -4,8 +4,10 @@ import lib from "./inc/lib.mjs";
import cuffeo from "cuffeo"; import cuffeo from "cuffeo";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import flummpress from "flummpress"; import flummpress from "flummpress";
import { handleUpload } from "./upload_handler.mjs";
process.on('unhandledRejection', err => { process.on('unhandledRejection', err => {
if (err.code === 'ERR_HTTP_HEADERS_SENT') return;
console.error(err); console.error(err);
throw err; throw err;
}); });
@@ -61,6 +63,7 @@ process.on('unhandledRejection', err => {
const router = app.router; const router = app.router;
const tpl = app.tpl; const tpl = app.tpl;
app.use(async (req, res) => { app.use(async (req, res) => {
// sessionhandler // sessionhandler
req.session = false; req.session = false;
@@ -91,8 +94,7 @@ process.on('unhandledRejection', err => {
// log last action // log last action
await db` await db`
update "user_sessions" set ${ update "user_sessions" set ${db({
db({
last_used: ~~(Date.now() / 1e3), last_used: ~~(Date.now() / 1e3),
last_action: req.url.pathname, last_action: req.url.pathname,
browser: req.headers['user-agent'] browser: req.headers['user-agent']
@@ -106,8 +108,7 @@ process.on('unhandledRejection', err => {
// update userprofile // update userprofile
await db` await db`
insert into "user_options" ${ insert into "user_options" ${db({
db({
user_id: +user[0].id, user_id: +user[0].id,
mode: user[0].mode ?? 0, mode: user[0].mode ?? 0,
theme: req.session.theme ?? 'amoled', theme: req.session.theme ?? 'amoled',
@@ -123,6 +124,15 @@ process.on('unhandledRejection', err => {
} }
}); });
// Bypass middleware for direct upload handling
app.use(async (req, res) => {
if (req.method === 'POST' && req.url.pathname === '/api/v2/upload') {
await handleUpload(req, res);
// Modify URL to prevent router matching and double execution
req.url.pathname = '/handled_upload_bypass';
}
});
tpl.views = "views"; tpl.views = "views";
tpl.debug = true; tpl.debug = true;
tpl.cache = false; tpl.cache = false;

250
src/upload_handler.mjs Normal file
View File

@@ -0,0 +1,250 @@
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}`;
// 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);
}
};

View File

@@ -6,14 +6,15 @@
<span>Hier entsteht eine Internetpräsenz!</span><br> <span>Hier entsteht eine Internetpräsenz!</span><br>
<hr> <hr>
<p>f0ck stats: @if(typeof totals !== "undefined") <p>f0ck stats: @if(typeof totals !== "undefined")
total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }} total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }}
| nsfw: {{ totals.nsfw }}
@endif</p> @endif</p>
<hr> <hr>
<div class="admintools"> <div class="admintools">
<p>Adminwerkzeuge</p> <p>Adminwerkzeuge</p>
<ul> <ul>
<!-- <li><a href="/admin/log">Logs</a></li> <!-- <li><a href="/admin/log">Logs</a></li> -->
<li><a href="/admin/recover">Recover f0cks</a></li> --> <li><a href="/admin/approve">Approval Queue</a></li>
<li><a href="/admin/sessions">Sessions</a></li> <li><a href="/admin/sessions">Sessions</a></li>
</ul> </ul>
</div> </div>

44
views/admin/approve.html Normal file
View File

@@ -0,0 +1,44 @@
@include(snippets/header)
<div id="main">
<div class="container">
<h1>APPROVAL QUEUE</h1>
<p>Items here are pending approval.</p>
<table class="table" style="width: 100%">
<thead>
<tr>
<td>Preview</td>
<td>ID</td>
<td>Uploader</td>
<td>Type</td>
<td>Action</td>
</tr>
</thead>
<tbody>
@each(posts as post)
<tr>
<td>
<video controls loop muted preload="metadata" style="max-height: 200px; max-width: 300px;">
<source src="/b/{{ post.dest }}" type="{{ post.mime }}">
</video>
</td>
<td>{{ post.id }}</td>
<td>{{ post.username }}</td>
<td>{{ post.mime }}</td>
<td>
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-success">Approve</a>
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger">Deny / Delete</a>
</td>
</tr>
@endeach
@if(posts.length === 0)
<tr>
<td colspan="5">No pending items.</td>
</tr>
@endif
</tbody>
</table>
<br>
<a href="/admin">Back to Admin</a>
</div>
</div>
@include(snippets/footer)

View File

@@ -10,6 +10,10 @@
<div class="nav-user-menu" id="nav-user-menu"> <div class="nav-user-menu" id="nav-user-menu">
<a href="/user/{{ session.user.toLowerCase() }}">profile</a> <a href="/user/{{ session.user.toLowerCase() }}">profile</a>
<a href="/user/{{ session.user.toLowerCase() }}/favs">favs</a> <a href="/user/{{ session.user.toLowerCase() }}/favs">favs</a>
<a href="/upload">upload</a>
@if(session.admin)
<a href="/admin">admin</a>
@endif
<a href="/settings">settings</a> <a href="/settings">settings</a>
<div class="nav-user-divider"></div> <div class="nav-user-divider"></div>
<a href="/logout">logout</a> <a href="/logout">logout</a>

View File

@@ -1,37 +1,440 @@
@include(snippets/header) @include(snippets/header)
<div class="upload"> <div class="upload-container">
<h5>Upload</h5> <h2>Upload</h2>
<p>To add videos to the w0bm catalogue you must join our <a href="https://t.me/+w97TCd988ehkNWEy">Telegram</a> group</p>
<h5>Content Guideline</h5> <div class="content-guidelines">
<h4>Content Guideline</h4>
<p>w0bm follows strict principles when it comes to content, please keep this in mind.</p> <p>w0bm follows strict principles when it comes to content, please keep this in mind.</p>
<p>We do not want content that</p> <div class="guidelines-grid">
<div class="guidelines-dont">
<h5>We do not want</h5>
<ul> <ul>
<li>glorifies Nazis</li> <li>Content glorifying Nazis</li>
<li>sexualizes children and minors</li> <li>Sexualization of children/minors</li>
<li>is political</li> <li>Political content</li>
<li>glorifies military</li> <li>Military glorification</li>
<li>depicts gore</li> <li>Gore</li>
<li>depicts acts of terrorism</li> <li>Acts of terrorism</li>
<li>depicts violence and cruelty against animals</li> <li>Violence against animals</li>
</ul>
<p>We want content that</p>
<ul>
<li>is cool</li>
<li>has deeper value</li>
<li>is fun to watch</li>
<li>has a vibe to it</li>
<li>can be looped for 5000 times and doesnt get boring</li>
</ul>
<p>but in general we welcome content that has been curated beforehand by the uploader and believe that they understand the vibe.</p>
<p>Content that is deemed NSFW (Not Safe For Work) MUST be tagged with "nsfw"</p>
<p>This list is subject to change, please review it periodically.</p>
<br>
<h5>How it works</h5>
<ul>
<li>The maximum filesize for direct file upload is 20MB and cannot be exceeded.</li>
<li>There is a much higher limit for non-direct uploads via sending a URL.</li>
<li>You can send a link to the group and put a !f behind it and the bot will pick it up and add it to w0bm.</li>
<li>In the menu below the bots message you can select the rating and additional tags.</li>
</ul> </ul>
</div> </div>
<div class="guidelines-do">
<h5>We want</h5>
<ul>
<li>Cool content</li>
<li>Deeper value</li>
<li>Fun to watch</li>
<li>Has a vibe to it</li>
<li>Can be looped 5000 times</li>
</ul>
</div>
</div>
</div>
@if(session)
<form id="upload-form" class="upload-form" enctype="multipart/form-data">
<div class="form-section">
<label>Video File <span class="required">*</span></label>
<div class="drop-zone" id="drop-zone">
<input type="file" id="file-input" name="file" accept="video/mp4,video/webm" required>
<div class="drop-zone-prompt">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<p>Drop your mp4 or webm here<br>or click to browse</p>
</div>
<div class="file-preview" id="file-preview" style="display: none;">
<span class="file-name" id="file-name"></span>
<span class="file-size" id="file-size"></span>
<button type="button" class="btn-remove" id="remove-file"></button>
</div>
</div>
</div>
<div class="form-section">
<label>Rating <span class="required">*</span></label>
<div class="rating-options">
<label class="rating-option">
<input type="radio" name="rating" value="sfw" required>
<span class="rating-label sfw">SFW</span>
</label>
<label class="rating-option">
<input type="radio" name="rating" value="nsfw">
<span class="rating-label nsfw">NSFW</span>
</label>
</div>
</div>
<div class="form-section">
<label>Tags <span class="required">*</span> <span class="tag-count" id="tag-count">(0/3
minimum)</span></label>
<div class="tag-input-container">
<div class="tags-list" id="tags-list"></div>
<input type="text" id="tag-input" placeholder="Type a tag and press Enter" autocomplete="off">
<div class="tag-suggestions" id="tag-suggestions"></div>
</div>
<input type="hidden" name="tags" id="tags-hidden">
</div>
<div class="form-actions">
<button type="submit" id="submit-btn" class="btn-upload" disabled>
<span class="btn-text">3 tags required</span>
<span class="btn-loading" style="display: none;">Uploading...</span>
</button>
</div>
<div class="upload-progress" id="upload-progress" style="display: none;">
<div class="progress-bar">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="progress-text" id="progress-text">0%</span>
</div>
<div class="upload-status" id="upload-status"></div>
</form>
@else
<div class="login-required">
<p>You must be logged in to upload content.</p>
<a href="/login" class="btn-login">Login</a>
</div>
@endif
</div>
<style>
.upload-container {
max-width: 700px;
margin: 2rem auto;
padding: 2rem;
}
.upload-container h2 {
margin-bottom: 1.5rem;
color: var(--accent);
}
.content-guidelines {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.content-guidelines h4 {
margin-bottom: 0.5rem;
color: var(--accent);
}
.guidelines-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-top: 1rem;
}
.guidelines-dont h5 {
color: #ff6b6b;
}
.guidelines-do h5 {
color: #51cf66;
}
.guidelines-grid ul {
list-style: none;
padding: 0;
margin: 0.5rem 0 0 0;
}
.guidelines-grid li {
padding: 0.3rem 0;
font-size: 0.9rem;
opacity: 0.8;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-section label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.required {
color: #ff6b6b;
}
.drop-zone {
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 8px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: var(--accent);
background: rgba(255, 255, 255, 0.02);
}
.drop-zone input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.drop-zone-prompt {
color: rgba(255, 255, 255, 0.5);
}
.drop-zone-prompt svg {
margin-bottom: 0.5rem;
}
.file-preview {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: rgba(255, 255, 255, 0.05);
border-radius: 4px;
}
.file-name {
font-weight: 500;
flex: 1;
}
.file-size {
opacity: 0.6;
font-size: 0.9rem;
}
.btn-remove {
background: rgba(255, 107, 107, 0.2);
border: none;
color: #ff6b6b;
width: 28px;
height: 28px;
border-radius: 4px;
cursor: pointer;
}
.rating-options {
display: flex;
gap: 1rem;
}
.rating-option {
cursor: pointer;
}
.rating-option input {
display: none;
}
.rating-label {
display: inline-block;
padding: 0.75rem 1.5rem;
border-radius: 4px;
border: 2px solid transparent;
transition: all 0.2s;
}
.rating-label.sfw {
background: rgba(81, 207, 102, 0.1);
border-color: rgba(81, 207, 102, 0.3);
}
.rating-label.nsfw {
background: rgba(255, 107, 107, 0.1);
border-color: rgba(255, 107, 107, 0.3);
}
.rating-option input:checked+.rating-label.sfw {
background: rgba(81, 207, 102, 0.3);
border-color: #51cf66;
}
.rating-option input:checked+.rating-label.nsfw {
background: rgba(255, 107, 107, 0.3);
border-color: #ff6b6b;
}
.tag-input-container {
position: relative;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
background: var(--accent);
color: #000;
padding: 0.3rem 0.6rem;
border-radius: 3px;
font-size: 0.9rem;
}
.tag-chip button {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 1rem;
line-height: 1;
}
#tag-input {
flex: 1;
min-width: 120px;
background: transparent;
border: none;
color: inherit;
padding: 0.5rem;
outline: none;
}
.tag-count {
font-weight: normal;
font-size: 0.85rem;
opacity: 0.7;
}
.tag-count.valid {
color: #51cf66;
}
.tag-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--background, #1a1a1a);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0 0 4px 4px;
max-height: 200px;
overflow-y: auto;
display: none;
z-index: 100;
}
.tag-suggestions.show {
display: block;
}
.tag-suggestion {
padding: 0.5rem 1rem;
cursor: pointer;
}
.tag-suggestion:hover {
background: rgba(255, 255, 255, 0.05);
}
.btn-upload {
background: var(--accent);
color: #000;
border: none;
padding: 1rem 2rem;
border-radius: 4px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-upload:disabled {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
cursor: not-allowed;
}
.btn-upload:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.upload-progress {
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar {
flex: 1;
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
width: 0%;
transition: width 0.2s;
}
.upload-status {
text-align: center;
padding: 1rem;
}
.upload-status.success {
color: #51cf66;
}
.upload-status.error {
color: #ff6b6b;
}
.login-required {
text-align: center;
padding: 3rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
}
.btn-login {
display: inline-block;
margin-top: 1rem;
padding: 0.75rem 2rem;
background: var(--accent);
color: #000;
text-decoration: none;
border-radius: 4px;
font-weight: 600;
}
</style>
<script src="/s/js/upload.js"></script>
@include(snippets/footer) @include(snippets/footer)