diff --git a/debug/fix_deleted.mjs b/debug/fix_deleted.mjs new file mode 100644 index 0000000..d1adcd2 --- /dev/null +++ b/debug/fix_deleted.mjs @@ -0,0 +1,40 @@ + +import db from "../src/inc/sql.mjs"; +import { promises as fs } from "fs"; + +(async () => { + console.log("Starting migration..."); + const items = await db`select id, dest from items where active = false`; + console.log(`Found ${items.length} inactive items.`); + + let trashCount = 0; + let pendingCount = 0; + let brokenCount = 0; + + for (const item of items) { + try { + await fs.access(`./deleted/b/${item.dest}`); + // File exists in deleted, mark as is_deleted = true + await db`update items set is_deleted = true where id = ${item.id}`; + trashCount++; + } catch { + // Not in deleted, check public + try { + await fs.access(`./public/b/${item.dest}`); + // In public, is_deleted = false (default) + pendingCount++; + } catch { + // Not in either? Broken. + console.log(`Item ${item.id} (${item.dest}) missing from both locations.`); + brokenCount++; + // Default is false, which effectively puts it in pending queue as 'broken' + } + } + } + + console.log(`Migration complete.`); + console.log(`Trash (soft-deleted): ${trashCount}`); + console.log(`Pending: ${pendingCount}`); + console.log(`Broken: ${brokenCount}`); + process.exit(0); +})(); diff --git a/package.json b/package.json index 5d85a10..4e231b6 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "autotagger": "node debug/autotagger.mjs", "thumbnailer": "node debug/thumbnailer.mjs", "test": "node debug/test.mjs", - "clean": "node debug/clean.mjs" + "clean": "node debug/clean.mjs", + "fix:deleted": "node debug/fix_deleted.mjs" }, "author": "Flummi", "license": "MIT", @@ -20,4 +21,4 @@ "flummpress": "^2.0.5", "postgres": "^3.3.4" } -} +} \ No newline at end of file diff --git a/public/s/css/f0ck.css b/public/s/css/f0ck.css index 8345ddf..c5cbd5c 100644 --- a/public/s/css/f0ck.css +++ b/public/s/css/f0ck.css @@ -3386,7 +3386,7 @@ input#s_avatar { .login-modal-content { background: var(--bg); border: 1px solid var(--accent); - border-radius: 8px; + border-radius: 0; box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); max-width: 400px; width: 100%; @@ -3405,7 +3405,18 @@ input#s_avatar { .login-modal-content .login-image { max-width: 100%; margin-bottom: 1rem; - border-radius: 4px; + border-radius: 0; +} + +.notification-dot { + width: 10px; + height: 10px; + background-color: #39ff14; + /* Matrix neon green */ + border-radius: 50%; + display: inline-block; + margin-left: 5px; + box-shadow: 0 0 5px #39ff14; } .login-modal-content input[type="text"], diff --git a/src/inc/events/callback_query.mjs b/src/inc/events/callback_query.mjs index 7e4cde9..57b65e8 100644 --- a/src/inc/events/callback_query.mjs +++ b/src/inc/events/callback_query.mjs @@ -30,22 +30,21 @@ export default async bot => { f: async e => { logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${e.message}`); - let [ cmd, id ] = e.opt.data.split(':'); + let [cmd, id] = e.opt.data.split(':'); let f0ck; id = +id; - if(cmd.startsWith('b_settag_')) { + if (cmd.startsWith('b_settag_')) { const tagid = +cmd.replace('b_settag_', ''); - if(!(await lib.getTags(id)).filter(tag => tag.id == tagid).length) { + if (!(await lib.getTags(id)).filter(tag => tag.id == tagid).length) { // insert await db` - insert into "tags_assign" ${ - db({ - item_id: id, - tag_id: tagid, - user_id: 1 - }) + insert into "tags_assign" ${db({ + item_id: id, + tag_id: tagid, + user_id: 1 + }) } `; } @@ -71,9 +70,9 @@ export default async bot => { }); } - switch(cmd) { + switch (cmd) { case "b_tags": - if(!id) + if (!id) return; const keyboard = await tagkeyboard(id); @@ -87,9 +86,9 @@ export default async bot => { ]] }) }); - break; + break; case "b_back": - if(!id) + if (!id) return; await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, { @@ -104,24 +103,23 @@ export default async bot => { ]] }) }); - break; + break; case "b_sfw": - - if(!id) + + if (!id) return; - if(!await lib.hasTag(id, 1)) { + if (!await lib.hasTag(id, 1)) { // insert await db` - insert into "tags_assign" ${ - db({ - item_id: id, - tag_id: 1, // sfw - user_id: 1 - }) + insert into "tags_assign" ${db({ + item_id: id, + tag_id: 1, // sfw + user_id: 1 + }) } `; - if(await lib.hasTag(id, 2)) { + if (await lib.hasTag(id, 2)) { await db` delete from "tags_assign" where tag_id = 2 @@ -151,23 +149,22 @@ export default async bot => { }) }); - break; + break; case "b_nsfw": - if(!id) + if (!id) return; - if(!await lib.hasTag(id, 2)) { + if (!await lib.hasTag(id, 2)) { // insert await db` - insert into "tags_assign" ${ - db({ - item_id: id, - tag_id: 2, // nsfw - user_id: 1 - }) + insert into "tags_assign" ${db({ + item_id: id, + tag_id: 2, // nsfw + user_id: 1 + }) } `; - if(await lib.hasTag(id, 1)) { + if (await lib.hasTag(id, 1)) { await db` delete from "tags_assign" where tag_id = 1 @@ -196,9 +193,9 @@ export default async bot => { ]] }) }); - break; + break; case "b_delete": - if(id <= 1) + if (id <= 1) return; e.user = { @@ -218,33 +215,33 @@ export default async bot => { `; const level = getLevel(e.user).level; - if(f0ck.length === 0) { + if (f0ck.length === 0) { return await e.reply(`f0ck ${id}: f0ck not found`); } - - if( + + if ( (f0ck[0].username !== (e.user.nick || e.user.username) || - f0ck[0].userchannel !== e.channel || - f0ck[0].usernetwork !== e.network) && + f0ck[0].userchannel !== e.channel || + f0ck[0].usernetwork !== e.network) && level < 100 ) { return await e.reply(`f0ck ${id}: insufficient permissions`); } - if(~~(new Date() / 1e3) >= (f0ck[0].stamp + 600) && level < 100) { + if (~~(new Date() / 1e3) >= (f0ck[0].stamp + 600) && level < 100) { return await e.reply(`f0ck ${id}: too late lol`); } - await db`update "items" set active = 'false' where id = ${id}`; + await db`update "items" set active = 'false', is_deleted = true where id = ${id}`; - await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_=>{}); - await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_=>{}); - await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_=>{}); - await fs.unlink(`./public/t/${id}.webp`).catch(_=>{}); + await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { }); + await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { }); + await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_ => { }); + await fs.unlink(`./public/t/${id}.webp`).catch(_ => { }); - if(f0ck[0].mime.startsWith('audio')) { - await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_=>{}); - await fs.unlink(`./public/ca/${id}.webp`).catch(_=>{}); + if (f0ck[0].mime.startsWith('audio')) { + await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_ => { }); + await fs.unlink(`./public/ca/${id}.webp`).catch(_ => { }); } await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, { @@ -259,9 +256,9 @@ export default async bot => { ]] }) }); - break; + break; case "b_recover": - if(id <= 1) + if (id <= 1) return; e.user = { @@ -270,7 +267,7 @@ export default async bot => { username: e.raw.reply_to_message.from.username, account: e.raw.reply_to_message.from.id.toString() }; - + f0ck = await db` select dest, mime from "items" @@ -279,23 +276,23 @@ export default async bot => { active = 'false' limit 1 `; - - if(f0ck.length === 0) { + + if (f0ck.length === 0) { return await e.reply(`f0ck ${id}: f0ck not found`); } - - 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(_=>{}); + + 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(_ => { }); } - + await db`update "items" set active = 'true' where id = ${id}`; - + await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, { reply_markup: JSON.stringify({ inline_keyboard: [[ @@ -308,7 +305,7 @@ export default async bot => { ]] }) }); - break; + break; default: await e.reply('lol'); } diff --git a/src/inc/routes/admin.mjs b/src/inc/routes/admin.mjs index d535490..9a86ce0 100644 --- a/src/inc/routes/admin.mjs +++ b/src/inc/routes/admin.mjs @@ -138,7 +138,7 @@ export default (router, tpl) => { }); } - await db`update "items" set active = 'true' where id = ${id}`; + await db`update "items" set active = 'true', is_deleted = false where id = ${id}`; // Check if files need moving (if they are in deleted/) try { @@ -169,51 +169,56 @@ export default (router, tpl) => { const total = (await db`select count(*) as c from "items" where active = 'false'`)[0].c; const pages = Math.ceil(total / limit); - const _posts = await db` - select id, mime, username, dest - from "items" + // Fetch Pending (not deleted) + const pending = await db` + select i.id, i.mime, i.username, i.dest, array_agg(t.tag) as tags + from "items" i + left join "tags_assign" ta on ta.item_id = i.id + left join "tags" t on t.id = ta.tag_id where - active = 'false' - order by id desc - limit ${limit} offset ${offset} + i.active = 'false' and i.is_deleted = false + group by i.id + order by i.id desc `; - if (_posts.length === 0 && page > 1) { - // if page empty, maybe redirect to last page or page 1? - // Just render empty for now - } + // Fetch Trash (deleted) + const trash = await db` + select i.id, i.mime, i.username, i.dest, array_agg(t.tag) as tags + from "items" i + left join "tags_assign" ta on ta.item_id = i.id + left join "tags" t on t.id = ta.tag_id + where + i.active = 'false' and i.is_deleted = true + group by i.id + order by i.id desc + `; - if (_posts.length === 0) { - return res.reply({ - body: tpl.render('admin/approve', { posts: [], pages: 0, page: 1, tmp: null }, req) - }); - } - - 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 { + // Helper to process thumbnails + const processItems = async (items, isInTrash) => { + return Promise.all(items.map(async p => { + let thumb = ''; + const path = isInTrash ? 'deleted' : 'public'; try { - thumb = (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64'); - } catch { - thumb = ""; // No thumbnail? - } - } - return { - ...p, - thumbnail: thumb - }; - })); + thumb = (await fs.readFile(`./${path}/t/${p.id}.webp`)).toString('base64'); + } catch { } + return { + ...p, + thumbnail: thumb, + tags: p.tags.filter(t => t !== null) + }; + })); + }; + + const pendingProcessed = await processItems(pending, false); + const trashProcessed = await processItems(trash, true); res.reply({ body: tpl.render('admin/approve', { - posts, + pending: pendingProcessed, + trash: trashProcessed, page, pages, - stats: { total: posts.length }, + stats: { total: pending.length + trash.length }, tmp: null }, req) }); diff --git a/src/inc/trigger/delete.mjs b/src/inc/trigger/delete.mjs index eb22a9e..23fbcf5 100644 --- a/src/inc/trigger/delete.mjs +++ b/src/inc/trigger/delete.mjs @@ -11,11 +11,11 @@ export default async bot => { f: async e => { let deleted = []; - for(let id of e.args) { + for (let id of e.args) { id = +id; - if(id <= 1) + if (id <= 1) continue; - + const f0ck = await db` select dest, mime, username, userchannel, usernetwork from "items" @@ -26,36 +26,36 @@ export default async bot => { `; const level = getLevel(e.user).level; - if(f0ck.length === 0) { + if (f0ck.length === 0) { await e.reply(`f0ck ${id}: f0ck not found`); continue; } - - if( + + if ( (f0ck[0].username !== (e.user.nick || e.user.username) || - f0ck[0].userchannel !== e.channel || - f0ck[0].usernetwork !== e.network) && + f0ck[0].userchannel !== e.channel || + f0ck[0].usernetwork !== e.network) && level < 100 ) { await e.reply(`f0ck ${id}: insufficient permissions`); continue; } - if(~~(new Date() / 1e3) >= (f0ck[0].stamp + 600) && level < 100) { + if (~~(new Date() / 1e3) >= (f0ck[0].stamp + 600) && level < 100) { await e.reply(`f0ck ${id}: too late lol`); continue; } - await db`update "items" set active = 'false' where id = ${id}`; + await db`update "items" set active = 'false', is_deleted = true where id = ${id}`; - await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_=>{}); - await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_=>{}); - await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_=>{}); - await fs.unlink(`./public/t/${id}.webp`).catch(_=>{}); + await fs.copyFile(`./public/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { }); + await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { }); + await fs.unlink(`./public/b/${f0ck[0].dest}`).catch(_ => { }); + await fs.unlink(`./public/t/${id}.webp`).catch(_ => { }); - if(f0ck[0].mime.startsWith('audio')) { - await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_=>{}); - await fs.unlink(`./public/ca/${id}.webp`).catch(_=>{}); + if (f0ck[0].mime.startsWith('audio')) { + await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_ => { }); + await fs.unlink(`./public/ca/${id}.webp`).catch(_ => { }); } deleted.push(id); @@ -71,11 +71,11 @@ export default async bot => { f: async e => { let recovered = []; - for(let id of e.args) { + for (let id of e.args) { id = +id; - if(id <= 1) + if (id <= 1) continue; - + const f0ck = await db` select dest, mime from "items" @@ -85,19 +85,19 @@ export default async bot => { limit 1 `; - if(f0ck.length === 0) { + if (f0ck.length === 0) { await e.reply(`f0ck ${id}: f0ck not found`); continue; } - 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(_ => { }); } await db`update "items" set active = 'true' where id = ${id}`; diff --git a/src/index.mjs b/src/index.mjs index bc547d0..63218cf 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -101,7 +101,12 @@ process.on('unhandledRejection', err => { }, 'last_used', 'last_action', 'browser') } where id = ${+user[0].sess_id} - `; + `; + + if (req.session.admin) { + const pending = await db`select count(*) as c from "items" where active = false and is_deleted = false`; + req.session.pending_count = pending[0].c; + } req.session.theme = req.cookies.theme; req.session.fullscreen = req.cookies.fullscreen; diff --git a/views/admin/approve.html b/views/admin/approve.html index b4d6c2f..71b760a 100644 --- a/views/admin/approve.html +++ b/views/admin/approve.html @@ -3,18 +3,22 @@

APPROVAL QUEUE

Items here are pending approval.

- + + @if(pending.length > 0) +

Pending Uploads

+
+ - @each(posts as post) + @each(pending as post) + @endeach - @if(posts.length === 0) - - - - @endif
Preview ID Uploader TypeTags Action
{{ post.id }} {{ post.username }} {{ post.mime }} + @each(post.tags as tag) + {{ tag }} + @endeach + Approve Deny / @@ -31,13 +40,60 @@
No pending items.
+ @endif + + @if(trash.length > 0) +

Reference / Soft Deleted

+

These items are in the deleted folder but not purged from DB. Approving them will restore + them.

+ + + + + + + + + + + + + @each(trash as post) + + + + + + + + + @endeach + +
PreviewIDUploaderTypeTagsAction
+ @if(post.thumbnail) + + @else + [File Missing] + @endif + {{ post.id }}{{ post.username }}{{ post.mime }} + @each(post.tags as tag) + {{ tag }} + @endeach + + Restore + Purge +
+ @endif + + @if(pending.length === 0 && trash.length === 0) +
+

No pending items.

+

Go touch grass?

+
+ @endif +
@if(typeof pages !== 'undefined' && pages > 1)