diff --git a/public/s/js/upload.js b/public/s/js/upload.js new file mode 100644 index 0000000..587c2eb --- /dev/null +++ b/public/s/js/upload.js @@ -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 + ''; + 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 += '
' + filtered[i].tag + '
'; + } + 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 += ' View existing'; + } + } + + 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(); +})(); diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index af1f67a..04d903d 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -5,7 +5,7 @@ import { promises as fs } from "fs"; export default (router, tpl) => { router.get(/^\/login(\/)?$/, async (req, res) => { - if(req.cookies.session) { + if (req.cookies.session) { return res.reply({ body: tpl.render('error', { message: "you're already logged in lol", @@ -17,7 +17,7 @@ export default (router, tpl) => { body: tpl.render("login", { theme: req.cookies.theme ?? "f0ck" }) }); }); - + router.post(/^\/login(\/)?$/, async (req, res) => { const user = await db` select * @@ -25,9 +25,9 @@ export default (router, tpl) => { where "login" = ${req.post.username.toLowerCase()} limit 1 `; - if(user.length === 0) + if (user.length === 0) return res.reply({ body: "user doesn't exist or wrong password" }); - if(!(await lib.verify(req.post.password, user[0].password))) + if (!(await lib.verify(req.post.password, user[0].password))) return res.reply({ body: "user doesn't exist or wrong password" }); const stamp = ~~(Date.now() / 1e3); @@ -36,7 +36,7 @@ export default (router, tpl) => { where last_action <= ${(Date.now() - 6048e5)} and kmsi = 0 `; - + const session = lib.md5(lib.createID()); const blah = { user_id: user[0].id, @@ -49,8 +49,7 @@ export default (router, tpl) => { }; await db` - insert into "user_sessions" ${ - db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi') + insert into "user_sessions" ${db(blah, 'user_id', 'session', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi') } `; @@ -60,16 +59,16 @@ export default (router, tpl) => { "Location": "/" }).end(); }); - + router.get(/^\/logout$/, lib.loggedin, async (req, res) => { const usersession = await db` select * from "user_sessions" where id = ${+req.session.sess_id} `; - if(usersession.length === 0) + if (usersession.length === 0) return res.reply({ body: "nope 2" }); - + await db` delete from "user_sessions" where id = ${+req.session.sess_id} @@ -80,7 +79,7 @@ export default (router, tpl) => { "Location": "/" }).end(); }); - + router.get(/^\/login\/pwdgen$/, async (req, res) => { res.reply({ body: "
" @@ -102,7 +101,7 @@ export default (router, tpl) => { }, req) }); }); - + router.get(/^\/admin\/sessions(\/)?$/, lib.auth, async (req, res) => { const rows = await db` select "user_sessions".*, "user".user @@ -110,7 +109,7 @@ export default (router, tpl) => { left join "user" on "user".id = "user_sessions".user_id order by "user_sessions".last_used desc `; - + res.reply({ body: tpl.render("admin/sessions", { session: req.session, @@ -121,79 +120,142 @@ export default (router, tpl) => { }); }); - // router.get(/^\/admin\/log(\/)?$/, lib.auth, async (req, res) => { - // // Funktioniert ohne systemd service natürlich nicht. - // exec("journalctl -qeu f0ck --no-pager", (err, stdout) => { - // res.reply({ - // body: tpl.render("admin/log", { - // log: stdout.split("\n").slice(0, -1), - // tmp: null - // }, req) - // }); - // }); - // }); + router.get(/^\/admin\/approve\/?/, lib.auth, async (req, res) => { + 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` + }); + } - // router.get(/^\/admin\/recover\/?/, lib.auth, async (req, res) => { - // 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}`; - // 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(_=>{}); - // 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(_=>{}); + if (f0ck[0].mime.startsWith('audio')) { + await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_ => { }); + await fs.unlink(`./deleted/ca/${id}.webp`).catch(_ => { }); + } + } - // if(f0ck[0].mime.startsWith('audio')) { - // await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_=>{}); - // await fs.unlink(`./deleted/ca/${id}.webp`).catch(_=>{}); - // } + return res.writeHead(302, { + "Location": `/${id}` + }).end(); + } - // return res.reply({ - // body: `f0ck ${id} recovered. back` - // }); - // } + const _posts = await db` + select id, mime, username, dest + from "items" + where + active = 'false' + order by id desc + `; - // const _posts = await db` - // select id, mime, username - // from "items" - // where - // active = 'false' - // order by id desc - // `; + if (_posts.length === 0) { + return res.reply({ + body: tpl.render('admin/approve', { posts: [], tmp: null }, req) + }); + } - // if(_posts.length === 0) { - // return res.reply({ - // body: 'blah' - // }); - // } + const posts = await Promise.all(_posts.map(async p => { + // Try to get thumbnail from public or deleted + 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 => ({ - // ...p, - // thumbnail: (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64') - // }))); + res.reply({ + body: tpl.render('admin/approve', { + posts, + tmp: null + }, req) + }); + }); - // res.reply({ - // body: tpl.render('admin/recover', { - // posts, - // tmp: null - // }, req) - // }); - // }); + router.get(/^\/admin\/deny\/?/, lib.auth, async (req, res) => { + console.log('[ADMIN DENY] Logs initiated'); + if (req.url.qs?.id) { + const id = +req.url.qs.id; + 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; }; diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs new file mode 100644 index 0000000..db236c6 --- /dev/null +++ b/src/inc/routes/apiv2/upload.mjs @@ -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; +}; diff --git a/src/index.mjs b/src/index.mjs index b785212..b305ec0 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -4,8 +4,10 @@ import lib from "./inc/lib.mjs"; import cuffeo from "cuffeo"; import { promises as fs } from "fs"; import flummpress from "flummpress"; +import { handleUpload } from "./upload_handler.mjs"; process.on('unhandledRejection', err => { + if (err.code === 'ERR_HTTP_HEADERS_SENT') return; console.error(err); throw err; }); @@ -19,7 +21,7 @@ process.on('unhandledRejection', err => { this.level = args.level || 0; this.name = args.name; this.active = args.hasOwnProperty("active") ? args.active : true; - this.clients = args.clients || [ "irc", "tg", "slack" ]; + this.clients = args.clients || ["irc", "tg", "slack"]; this.f = args.f; }, bot: await new cuffeo(cfg.clients) @@ -27,7 +29,7 @@ process.on('unhandledRejection', err => { console.time("loading"); const modules = { - events: (await fs.readdir("./src/inc/events")).filter(f => f.endsWith(".mjs")), + events: (await fs.readdir("./src/inc/events")).filter(f => f.endsWith(".mjs")), trigger: (await fs.readdir("./src/inc/trigger")).filter(f => f.endsWith(".mjs")) }; @@ -41,7 +43,7 @@ process.on('unhandledRejection', err => { console.timeLog("loading", `${dir}/${mod}`); return res; }))).flat(2) - })))).reduce((a, b) => ({...a, ...b})); + })))).reduce((a, b) => ({ ...a, ...b })); blah.events.forEach(event => { console.timeLog("loading", `registering event > ${event.name}`); @@ -61,15 +63,16 @@ process.on('unhandledRejection', err => { const router = app.router; const tpl = app.tpl; + app.use(async (req, res) => { // sessionhandler req.session = false; - if(req.url.pathname.match(/^\/(s|b|t|ca)\//)) + if (req.url.pathname.match(/^\/(s|b|t|ca)\//)) return; req.theme = req.cookies.theme || 'amoled'; req.fullscreen = req.cookies.fullscreen || 0; - if(req.cookies.session) { + if (req.cookies.session) { const user = await db` select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".* from "user_sessions" @@ -78,8 +81,8 @@ process.on('unhandledRejection', err => { where "user_sessions".session = ${lib.md5(req.cookies.session)} limit 1 `; - - if(user.length === 0) { + + if (user.length === 0) { return res.writeHead(307, { // delete session "Cache-Control": "no-cache, public", "Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT", @@ -91,28 +94,26 @@ process.on('unhandledRejection', err => { // log last action await db` - update "user_sessions" set ${ - db({ - last_used: ~~(Date.now() / 1e3), - last_action: req.url.pathname, - browser: req.headers['user-agent'] - }, 'last_used', 'last_action', 'browser') + update "user_sessions" set ${db({ + last_used: ~~(Date.now() / 1e3), + last_action: req.url.pathname, + browser: req.headers['user-agent'] + }, 'last_used', 'last_action', 'browser') } where id = ${+user[0].sess_id} `; req.session.theme = req.cookies.theme; req.session.fullscreen = req.cookies.fullscreen; - + // update userprofile await db` - insert into "user_options" ${ - db({ - user_id: +user[0].id, - mode: user[0].mode ?? 0, - theme: req.session.theme ?? 'amoled', - fullscreen: req.session.fullscreen || 0 - }, 'user_id', 'mode', 'theme', 'fullscreen') + insert into "user_options" ${db({ + user_id: +user[0].id, + mode: user[0].mode ?? 0, + theme: req.session.theme ?? 'amoled', + fullscreen: req.session.fullscreen || 0 + }, 'user_id', 'mode', 'theme', 'fullscreen') } on conflict ("user_id") do update set mode = excluded.mode, @@ -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.debug = true; tpl.cache = false; diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs new file mode 100644 index 0000000..f5cdc41 --- /dev/null +++ b/src/upload_handler.mjs @@ -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); + } +}; diff --git a/views/admin.html b/views/admin.html index 568a498..354ad00 100644 --- a/views/admin.html +++ b/views/admin.html @@ -6,17 +6,18 @@ Hier entsteht eine Internetpräsenz!

f0ck stats: @if(typeof totals !== "undefined") - total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }} - @endif

+ total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} + | nsfw: {{ totals.nsfw }} + @endif


Adminwerkzeuge

-@include(snippets/footer) +@include(snippets/footer) \ No newline at end of file diff --git a/views/admin/approve.html b/views/admin/approve.html new file mode 100644 index 0000000..12cd843 --- /dev/null +++ b/views/admin/approve.html @@ -0,0 +1,44 @@ +@include(snippets/header) +
+
+

APPROVAL QUEUE

+

Items here are pending approval.

+ + + + + + + + + + + + @each(posts as post) + + + + + + + + @endeach + @if(posts.length === 0) + + + + @endif + +
PreviewIDUploaderTypeAction
+ + {{ post.id }}{{ post.username }}{{ post.mime }} + Approve + Deny / Delete +
No pending items.
+
+ Back to Admin +
+
+@include(snippets/footer) \ No newline at end of file diff --git a/views/snippets/navbar.html b/views/snippets/navbar.html index f81ae3e..07713ca 100644 --- a/views/snippets/navbar.html +++ b/views/snippets/navbar.html @@ -10,6 +10,10 @@