Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c0f4f3397 | ||
|
|
322698cf74 | ||
|
|
f958bbff52 | ||
|
|
d903ce8b98 | ||
|
|
595118c2c8 | ||
|
|
0cc0e5aa02 | ||
|
|
1403d4a0c2 | ||
|
|
6fa95da04e | ||
|
|
19d9f82ade | ||
|
|
7baf50f9fa | ||
|
|
9d4f47698c | ||
|
|
89483df993 | ||
|
|
87e6e5355a | ||
|
|
7896e6983f | ||
|
|
debb14142e | ||
|
|
65692d67a8 | ||
|
|
bb184edbf9 | ||
|
|
559521feb2 | ||
|
|
27d474d9e3 | ||
|
|
f23b108f9f | ||
|
|
8e233947e2 | ||
|
|
7ebb730dd1 | ||
|
|
2be5b33183 | ||
|
|
1646fdba56 | ||
|
|
63e86e9be1 | ||
|
|
ee416a1d08 | ||
|
|
2ad318e7c5 | ||
|
|
16da3ac9d0 | ||
|
|
1b1867332b | ||
|
|
d8979b6b1a | ||
|
|
c9ca037063 | ||
|
|
111f06ed42 | ||
|
|
8397d4ed3f | ||
|
|
f2b14739e3 | ||
|
|
fc7d38e3f1 | ||
|
|
2229f32dd3 | ||
|
|
9c9309435d | ||
|
|
446e9149bd | ||
|
|
f488559e2e | ||
|
|
d691680682 | ||
|
|
f950726ce6 | ||
|
|
54f266ff3d | ||
|
|
a9871187ab | ||
|
|
43da214f73 | ||
|
|
c822a4f4e7 | ||
|
|
a8bb3e67f5 | ||
|
|
85912f4ba1 | ||
|
|
f3a1fde23d | ||
|
|
8085b0166c | ||
|
|
85578b179b | ||
|
|
1a3514effa | ||
|
|
a439683caf | ||
|
|
577d73af11 | ||
|
|
42f4e19897 | ||
|
|
0a5f57b5a9 | ||
|
|
03f2630090 | ||
|
|
6692f32c4b | ||
|
|
8af49b6ec1 | ||
|
|
9c25f89adc | ||
|
|
ee6fda8f06 | ||
|
|
e9c377dc87 | ||
|
|
f5e386593d | ||
|
|
1dd4b54b48 | ||
|
|
4de2652ffe | ||
| 7b1e0af0cb | |||
| 52533486a2 |
72
debug/fix_deleted.mjs
Normal file
72
debug/fix_deleted.mjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
|
||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
console.log("Starting migration...");
|
||||||
|
|
||||||
|
// 1. Ensure column exists
|
||||||
|
try {
|
||||||
|
await db`select is_deleted from items limit 1`;
|
||||||
|
console.log("Column 'is_deleted' already exists.");
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('column "is_deleted" does not exist')) {
|
||||||
|
console.log("Column 'is_deleted' missing. Adding it now...");
|
||||||
|
await db`ALTER TABLE items ADD COLUMN is_deleted BOOLEAN DEFAULT FALSE`;
|
||||||
|
console.log("Column added successfully.");
|
||||||
|
} else {
|
||||||
|
console.error("Unexpected error checking column:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Cleaning up...`);
|
||||||
|
|
||||||
|
// 2. Fix FK constraint: Check if this item is used as an avatar
|
||||||
|
try {
|
||||||
|
// Find a safe fallback avatar (active item)
|
||||||
|
const fallback = await db`select id from items where active = true limit 1`;
|
||||||
|
if (fallback.length > 0) {
|
||||||
|
const safeId = fallback[0].id;
|
||||||
|
const users = await db`update "user_options" set avatar = ${safeId} where avatar = ${item.id} returning user_id`;
|
||||||
|
if (users.length > 0) {
|
||||||
|
console.log(` > Reassigned avatar for ${users.length} users (from ${item.id} to ${safeId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (fkErr) {
|
||||||
|
console.error(` ! Error fixing avatar FK for ${item.id}:`, fkErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`delete from items where id = ${item.id}`;
|
||||||
|
brokenCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migration complete.`);
|
||||||
|
console.log(`Trash (soft-deleted): ${trashCount}`);
|
||||||
|
console.log(`Pending: ${pendingCount}`);
|
||||||
|
console.log(`Broken: ${brokenCount}`);
|
||||||
|
process.exit(0);
|
||||||
|
})();
|
||||||
22
debug/init_comments.mjs
Normal file
22
debug/init_comments.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import { promises as fs } from "fs";
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const migration = await fs.readFile("./migration_comments.sql", "utf-8");
|
||||||
|
console.log("Applying migration...");
|
||||||
|
// Split by semicolon to handle multiple statements if the driver requires it,
|
||||||
|
// but postgres.js usually handles simple files well or we can execute as one block
|
||||||
|
// if it's just DDL. However, postgres.js template literal usually prefers single statements
|
||||||
|
// or we can use `db.file` if available, or just execute the string.
|
||||||
|
|
||||||
|
// Simple approach: execute the whole string
|
||||||
|
await db.unsafe(migration);
|
||||||
|
|
||||||
|
console.log("Migration applied successfully.");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Migration failed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
34
debug/init_emojis.mjs
Normal file
34
debug/init_emojis.mjs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
console.log("Creating custom_emojis table...");
|
||||||
|
|
||||||
|
await db`
|
||||||
|
CREATE TABLE IF NOT EXISTS custom_emojis (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Seed with existing default emojis if table is empty
|
||||||
|
const count = await db`SELECT count(*) FROM custom_emojis`;
|
||||||
|
if (count[0].count == 0) {
|
||||||
|
console.log("Seeding default emojis...");
|
||||||
|
await db`
|
||||||
|
INSERT INTO custom_emojis (name, url) VALUES
|
||||||
|
('f0ck', '/s/img/f0ck.png')
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Done.");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
84
debug/recreate_hashes.mjs
Normal file
84
debug/recreate_hashes.mjs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import db from '../src/inc/sql.mjs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
console.log('Starting hash recreation (Production Mode - Streams)...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch only necessary columns
|
||||||
|
const items = await db`SELECT id, dest, checksum, size FROM items ORDER BY id ASC`;
|
||||||
|
console.log(`Found ${items.length} items. Processing...`);
|
||||||
|
|
||||||
|
let updated = 0;
|
||||||
|
let errors = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const [index, item] of items.entries()) {
|
||||||
|
const filePath = path.join('./public/b', item.dest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
// Silent error in logs for missing files to avoid spamming "thousands" of lines if many are missing
|
||||||
|
// Use verbose logging if needed, but here we'll just count them.
|
||||||
|
// Actually, precise logs are better for "production" to know what's wrong.
|
||||||
|
console.error(`[MISSING] File not found for item ${item.id}: ${filePath}`);
|
||||||
|
errors++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size without reading content
|
||||||
|
const stats = await fs.promises.stat(filePath);
|
||||||
|
const size = stats.size;
|
||||||
|
|
||||||
|
// Calculate hash using stream to ensure low memory usage
|
||||||
|
const hash = await new Promise((resolve, reject) => {
|
||||||
|
const hashStream = crypto.createHash('sha256');
|
||||||
|
const rs = fs.createReadStream(filePath);
|
||||||
|
|
||||||
|
rs.on('error', reject);
|
||||||
|
rs.on('data', chunk => hashStream.update(chunk));
|
||||||
|
rs.on('end', () => resolve(hashStream.digest('hex')));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hash !== item.checksum || size !== item.size) {
|
||||||
|
console.log(`[UPDATE] Item ${item.id} (${index + 1}/${items.length})`);
|
||||||
|
if (hash !== item.checksum) console.log(` - Hash: ${item.checksum} -> ${hash}`);
|
||||||
|
if (size !== item.size) console.log(` - Size: ${item.size} -> ${size}`);
|
||||||
|
|
||||||
|
await db`
|
||||||
|
UPDATE items
|
||||||
|
SET checksum = ${hash}, size = ${size}
|
||||||
|
WHERE id = ${item.id}
|
||||||
|
`;
|
||||||
|
updated++;
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log progress every 100 items
|
||||||
|
if ((index + 1) % 100 === 0) {
|
||||||
|
console.log(`Progress: ${index + 1}/${items.length} (Updated: ${updated}, Errors: ${errors})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[ERROR] Processing item ${item.id}:`, err);
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
|
console.log(`Total: ${items.length}`);
|
||||||
|
console.log(`Updated: ${updated}`);
|
||||||
|
console.log(`Skipped (No changes): ${skipped}`);
|
||||||
|
console.log(`Errors (Missing files): ${errors}`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fatal error:', err);
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
106
debug/verify_comments.mjs
Normal file
106
debug/verify_comments.mjs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
import http from "http";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
const HOST = "localhost";
|
||||||
|
const PORT = 3000;
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
const cfg = JSON.parse(await readFile("../config.json", "utf8"));
|
||||||
|
const serverPort = cfg.websrv.port;
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
// 1. Setup Data
|
||||||
|
console.log("Setting up test data...");
|
||||||
|
const user = await db`SELECT id FROM "user" LIMIT 1`;
|
||||||
|
const item = await db`SELECT id FROM "items" LIMIT 1`;
|
||||||
|
|
||||||
|
if (!user.length || !item.length) {
|
||||||
|
console.error("No user or item found.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = user[0].id;
|
||||||
|
const itemId = item[0].id;
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
const sessionKey = "testsession_" + Date.now();
|
||||||
|
const sessionHash = crypto.createHash('md5').update(sessionKey).digest("hex");
|
||||||
|
|
||||||
|
await db`DELETE FROM user_sessions WHERE user_id = ${userId}`; // Clear old sessions for clean test
|
||||||
|
await db`INSERT INTO user_sessions (user_id, session, browser, created_at, last_used, last_action)
|
||||||
|
VALUES (${userId}, ${sessionHash}, 'test-bot', ${Math.floor(Date.now() / 1000)}, ${Math.floor(Date.now() / 1000)}, 'test')`;
|
||||||
|
|
||||||
|
console.log(`User: ${userId}, Item: ${itemId}, Session: ${sessionKey}`);
|
||||||
|
|
||||||
|
// Helper for requests
|
||||||
|
const request = (method, path, body = null, cookie = null) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
hostname: HOST,
|
||||||
|
port: serverPort,
|
||||||
|
path: path,
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cookie': cookie ? `session=${cookie}` : ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = http.request(options, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => resolve({ statusCode: res.statusCode, body: data }));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(JSON.stringify(body));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Test GET (Empty)
|
||||||
|
console.log("Testing GET /api/comments/" + itemId);
|
||||||
|
let res = await request('GET', `/api/comments/${itemId}`);
|
||||||
|
console.log("GET Response:", res.body);
|
||||||
|
let json = JSON.parse(res.body);
|
||||||
|
if (!json.success) throw new Error("GET failed");
|
||||||
|
|
||||||
|
// 3. Test POST
|
||||||
|
console.log("Testing POST /api/comments");
|
||||||
|
res = await request('POST', '/api/comments', {
|
||||||
|
item_id: itemId,
|
||||||
|
content: "Hello World from Test Bot"
|
||||||
|
}, sessionKey);
|
||||||
|
console.log("POST Response:", res.body);
|
||||||
|
json = JSON.parse(res.body);
|
||||||
|
if (!json.success) throw new Error("POST failed");
|
||||||
|
const commentId = json.comment.id;
|
||||||
|
|
||||||
|
// 4. Test GET (With comment)
|
||||||
|
console.log("Testing GET /api/comments/" + itemId);
|
||||||
|
res = await request('GET', `/api/comments/${itemId}`);
|
||||||
|
json = JSON.parse(res.body);
|
||||||
|
if (json.comments.length === 0) throw new Error("Comment not found");
|
||||||
|
console.log("Found comments:", json.comments.length);
|
||||||
|
|
||||||
|
// 5. Test Subscribe
|
||||||
|
console.log("Testing POST /api/subscribe/" + itemId);
|
||||||
|
res = await request('POST', `/api/subscribe/${itemId}`, {}, sessionKey);
|
||||||
|
console.log("Subscribe Response:", res.body);
|
||||||
|
json = JSON.parse(res.body);
|
||||||
|
if (!json.success) throw new Error("Subscribe failed");
|
||||||
|
if (!json.subscribed) throw new Error("Expected subscribed=true");
|
||||||
|
|
||||||
|
console.log("Testing Unsubscribe...");
|
||||||
|
res = await request('POST', `/api/subscribe/${itemId}`, {}, sessionKey);
|
||||||
|
json = JSON.parse(res.body);
|
||||||
|
if (json.subscribed) throw new Error("Expected subscribed=false");
|
||||||
|
|
||||||
|
console.log("ALL TESTS PASSED");
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
runTest().catch(e => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
62
debug/verify_db.mjs
Normal file
62
debug/verify_db.mjs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import db from "../src/inc/sql.mjs";
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
console.log("Verifying Database Schema...");
|
||||||
|
|
||||||
|
// 1. Check Tables
|
||||||
|
try {
|
||||||
|
await db`SELECT 1 FROM comments LIMIT 1`;
|
||||||
|
console.log("✔ Table 'comments' exists.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("✘ Table 'comments' missing.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db`SELECT 1 FROM comment_subscriptions LIMIT 1`;
|
||||||
|
console.log("✔ Table 'comment_subscriptions' exists.");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("✘ Table 'comment_subscriptions' missing.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Insert Test Data
|
||||||
|
console.log("Testing Insert...");
|
||||||
|
const user = await db`SELECT id FROM "user" LIMIT 1`;
|
||||||
|
const item = await db`SELECT id FROM "items" LIMIT 1`;
|
||||||
|
|
||||||
|
if (!user.length || !item.length) {
|
||||||
|
console.log("⚠ No user/item to test insert. Skipping.");
|
||||||
|
} else {
|
||||||
|
const userId = user[0].id;
|
||||||
|
const itemId = item[0].id;
|
||||||
|
|
||||||
|
const comment = await db`
|
||||||
|
INSERT INTO comments (item_id, user_id, content)
|
||||||
|
VALUES (${itemId}, ${userId}, 'Test Comment DB Verify')
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
console.log("✔ Inserted comment ID:", comment[0].id);
|
||||||
|
|
||||||
|
const fetch = await db`SELECT content FROM comments WHERE id = ${comment[0].id}`;
|
||||||
|
if (fetch[0].content === 'Test Comment DB Verify') {
|
||||||
|
console.log("✔ Verified content.");
|
||||||
|
} else {
|
||||||
|
console.error("✘ Content mismatch.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
await db`DELETE FROM comments WHERE id = ${comment[0].id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("DB Schema Verification Passed.");
|
||||||
|
|
||||||
|
// 3. Optional: subscription test
|
||||||
|
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${user[0].id}, ${item[0].id}) ON CONFLICT DO NOTHING`;
|
||||||
|
console.log("✔ Subscription table write access confirmed.");
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
runTest().catch(console.error);
|
||||||
20
f0ck.sql
20
f0ck.sql
@@ -153,6 +153,7 @@ CREATE TABLE public.items (
|
|||||||
usernetwork character varying(40) NOT NULL,
|
usernetwork character varying(40) NOT NULL,
|
||||||
stamp integer NOT NULL,
|
stamp integer NOT NULL,
|
||||||
active boolean NOT NULL,
|
active boolean NOT NULL,
|
||||||
|
is_deleted boolean DEFAULT false NOT NULL,
|
||||||
thumb character varying(100)
|
thumb character varying(100)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -674,6 +675,25 @@ REVOKE USAGE ON SCHEMA public FROM PUBLIC;
|
|||||||
GRANT ALL ON SCHEMA public TO PUBLIC;
|
GRANT ALL ON SCHEMA public TO PUBLIC;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for default setup
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Default Avatar Item (referenced by user_options default)
|
||||||
|
INSERT INTO public.items (id, src, dest, mime, size, checksum, username, userchannel, usernetwork, stamp, active, is_deleted, thumb)
|
||||||
|
VALUES (56660, 'default', 'default.png', 'image/png', 0, 'default', 'system', 'system', 'system', 0, false, false, NULL)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Default User 'foo' (password: 'foo')
|
||||||
|
INSERT INTO public."user" (id, login, "user", password, admin)
|
||||||
|
VALUES (1, 'foo', 'foo', '$f0ck$de94c6c92c2333990f1d42efb199bcd6:7b636e3d009a7e002a4d8b4c393bfcb601d2042d5dddd9965f20904270f268f5edbf504aab8d8091a7faec8bbd84107a9e3a6823e9f20c5245b693d8efb9664c', true)
|
||||||
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
|
-- Default User Options for 'foo'
|
||||||
|
INSERT INTO public.user_options (user_id, mode, theme, avatar, fullscreen)
|
||||||
|
VALUES (1, 3, 'amoled', 1, 0)
|
||||||
|
ON CONFLICT (user_id) DO NOTHING;
|
||||||
|
|
||||||
--
|
--
|
||||||
-- PostgreSQL database dump complete
|
-- PostgreSQL database dump complete
|
||||||
--
|
--
|
||||||
|
|||||||
37
migration_comments.sql
Normal file
37
migration_comments.sql
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
-- Create comments table
|
||||||
|
CREATE TABLE IF NOT EXISTS comments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
parent_id INTEGER REFERENCES comments(id) ON DELETE SET NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_deleted BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
vote_score INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create notifications table
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
type VARCHAR(32) NOT NULL, -- 'reply', 'comment', 'mention'
|
||||||
|
reference_id INTEGER NOT NULL, -- ID of the comment
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
is_read BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create comment_subscriptions table (for subscribing to posts)
|
||||||
|
CREATE TABLE IF NOT EXISTS comment_subscriptions (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, item_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indexes for performance
|
||||||
|
CREATE INDEX idx_comments_item_id ON comments(item_id);
|
||||||
|
CREATE INDEX idx_comments_user_id ON comments(user_id);
|
||||||
|
CREATE INDEX idx_notifications_user_id ON notifications(user_id);
|
||||||
|
CREATE INDEX idx_notifications_unread ON notifications(user_id) WHERE is_read = FALSE;
|
||||||
@@ -10,7 +10,8 @@
|
|||||||
"autotagger": "node debug/autotagger.mjs",
|
"autotagger": "node debug/autotagger.mjs",
|
||||||
"thumbnailer": "node debug/thumbnailer.mjs",
|
"thumbnailer": "node debug/thumbnailer.mjs",
|
||||||
"test": "node debug/test.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",
|
"author": "Flummi",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
426
public/s/css/upload.css
Normal file
426
public/s/css/upload.css
Normal file
@@ -0,0 +1,426 @@
|
|||||||
|
/* Upload Page Styles */
|
||||||
|
.upload-container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-container h2 {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Guidelines */
|
||||||
|
.content-guidelines {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-guidelines summary {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
list-style: none;
|
||||||
|
/* Hide default triangle */
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-guidelines summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-guidelines summary::after {
|
||||||
|
content: '+';
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-guidelines[open] summary::after {
|
||||||
|
content: '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidelines-content {
|
||||||
|
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidelines-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidelines-dont h5 {
|
||||||
|
color: #ff6b6b;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidelines-do h5 {
|
||||||
|
color: #51cf66;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidelines-grid ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guidelines-grid li {
|
||||||
|
padding: 0.3rem 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Upload Form */
|
||||||
|
.upload-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop Zone */
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
pointer-events: none;
|
||||||
|
/* Let input handle clicks */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* File Preview (Stacked) */
|
||||||
|
.file-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
/* Stacked */
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview video {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 500px;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
outline: none;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-size {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove {
|
||||||
|
background: #ff6b6b;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.2s;
|
||||||
|
/* remove margin-top as it's now in a flex row */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-remove:hover {
|
||||||
|
background: #fa5252;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ratings */
|
||||||
|
.rating-options {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label {
|
||||||
|
display: block;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 0;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.sfw {
|
||||||
|
background: rgba(81, 207, 102, 0.1);
|
||||||
|
border-color: rgba(81, 207, 102, 0.3);
|
||||||
|
color: #51cf66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-label.nsfw {
|
||||||
|
background: rgba(255, 107, 107, 0.1);
|
||||||
|
border-color: rgba(255, 107, 107, 0.3);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option input:checked+.rating-label.sfw {
|
||||||
|
background: rgba(81, 207, 102, 0.2);
|
||||||
|
border-color: #51cf66;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-option input:checked+.rating-label.nsfw {
|
||||||
|
background: rgba(255, 107, 107, 0.2);
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag-input-container {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-chip button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1.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;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-suggestions {
|
||||||
|
/* (styles for dropdown remain similar, maybe cleaner shadow) */
|
||||||
|
position: absolute;
|
||||||
|
top: auto;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.5);
|
||||||
|
border-radius: 0;
|
||||||
|
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,
|
||||||
|
.tag-suggestion.active {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit Button */
|
||||||
|
.btn-upload {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress */
|
||||||
|
.upload-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-weight: bold;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.error {
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status.success {
|
||||||
|
color: #51cf66;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Required */
|
||||||
|
.login-required {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-login {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #000;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
@@ -216,7 +216,7 @@ video {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#main {
|
#main {
|
||||||
padding: 25px;
|
padding: 0px 25px 0px 25px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
@@ -262,7 +262,7 @@ video {
|
|||||||
background: #0000008a !important;
|
background: #0000008a !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination > a {
|
.pagination>a {
|
||||||
background: #232323b2;
|
background: #232323b2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,32 +285,33 @@ div.search {
|
|||||||
div.sbt {
|
div.sbt {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto;
|
grid-template-columns: 1fr auto;
|
||||||
align-content: center;
|
align-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sbtButton {
|
#sbtButton {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
#sbtInput {
|
#sbtInput {
|
||||||
background: #00000021;
|
background: #00000021;
|
||||||
box-shadow: -1px -1px 0px #252525;
|
box-shadow: -1px -1px 0px #252525;
|
||||||
border: inset 1px #0000001c;
|
border: inset 1px #0000001c;
|
||||||
padding: revert;
|
padding: revert;
|
||||||
box-shadow: inset 0px 0px 5px 1px #0000005e;
|
box-shadow: inset 0px 0px 5px 1px #0000005e;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-links {
|
.navigation-links {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-row: 1;
|
grid-row: 1;
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
grid-template-columns: auto auto 1fr;
|
grid-template-columns: auto auto 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navigation-links-guest, ol {
|
.navigation-links-guest,
|
||||||
margin: 5px;
|
ol {
|
||||||
margin-block-start: 0;
|
margin: 5px;
|
||||||
margin-block-end: 0;
|
margin-block-start: 0;
|
||||||
padding-inline-start: 0;
|
margin-block-end: 0;
|
||||||
|
padding-inline-start: 0;
|
||||||
}
|
}
|
||||||
@@ -184,13 +184,47 @@
|
|||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
const { postid, poster } = ctx;
|
const { postid, poster } = ctx;
|
||||||
|
|
||||||
if (!confirm(`Reason for deleting f0ckpost ${postid} by ${poster} (Weihnachten™)`))
|
const modal = document.getElementById('delete-item-modal');
|
||||||
return;
|
const idEl = document.getElementById('delete-item-id');
|
||||||
const res = await post("/api/v2/admin/deletepost", {
|
const posterEl = document.getElementById('delete-item-poster');
|
||||||
postid: postid
|
const confirmBtn = document.getElementById('delete-item-confirm');
|
||||||
});
|
const cancelBtn = document.getElementById('delete-item-cancel');
|
||||||
if (!res.success) {
|
|
||||||
alert(res.msg);
|
if (modal) {
|
||||||
|
idEl.textContent = postid;
|
||||||
|
posterEl.textContent = poster || 'unknown';
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
confirmBtn.onclick = null;
|
||||||
|
cancelBtn.onclick = null;
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = closeModal;
|
||||||
|
|
||||||
|
confirmBtn.onclick = async () => {
|
||||||
|
confirmBtn.textContent = 'Deleting...';
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
try {
|
||||||
|
const res = await post("/api/v2/admin/deletepost", {
|
||||||
|
postid: postid
|
||||||
|
});
|
||||||
|
if (!res.success) {
|
||||||
|
alert(res.msg);
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e); // Or e.message
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
498
public/s/js/comments.js
Normal file
498
public/s/js/comments.js
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
class CommentSystem {
|
||||||
|
constructor() {
|
||||||
|
this.container = document.getElementById('comments-container');
|
||||||
|
this.itemId = this.container ? this.container.dataset.itemId : null;
|
||||||
|
this.user = this.container ? this.container.dataset.user : null; // logged in user?
|
||||||
|
this.sort = 'new';
|
||||||
|
|
||||||
|
if (this.itemId) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadEmojis();
|
||||||
|
this.loadComments();
|
||||||
|
this.setupGlobalListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadEmojis() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/emojis');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.customEmojis = {};
|
||||||
|
data.emojis.forEach(e => {
|
||||||
|
this.customEmojis[e.name] = e.url;
|
||||||
|
});
|
||||||
|
console.log('Loaded Emojis:', this.customEmojis);
|
||||||
|
} else {
|
||||||
|
this.customEmojis = {};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load emojis", e);
|
||||||
|
this.customEmojis = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
renderEmoji(match, name) {
|
||||||
|
// console.log('Rendering Emoji:', name, this.customEmojis ? this.customEmojis[name] : 'No list');
|
||||||
|
if (this.customEmojis && this.customEmojis[name]) {
|
||||||
|
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||||
|
}
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadComments(scrollToId = null) {
|
||||||
|
if (!this.container) return;
|
||||||
|
if (!scrollToId) this.container.innerHTML = '<div class="loading">Loading comments...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.isAdmin = data.is_admin || false;
|
||||||
|
this.render(data.comments, data.user_id, data.is_subscribed);
|
||||||
|
|
||||||
|
// Priority: Explicit ID > Hash
|
||||||
|
if (scrollToId) {
|
||||||
|
this.scrollToComment(scrollToId);
|
||||||
|
} else if (window.location.hash && window.location.hash.startsWith('#c')) {
|
||||||
|
const hashId = window.location.hash.substring(2);
|
||||||
|
this.scrollToComment(hashId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.container.innerHTML = `<div class="error">Failed to load comments: ${data.message}</div>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.container.innerHTML = `<div class="error">Error loading comments: ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
scrollToComment(id) {
|
||||||
|
// Allow DOM reflow
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = document.getElementById('c' + id);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
el.style.transition = "background-color 0.5s";
|
||||||
|
el.style.backgroundColor = "rgba(255, 255, 0, 0.2)";
|
||||||
|
setTimeout(() => el.style.backgroundColor = "", 2000);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(comments, currentUserId, isSubscribed) {
|
||||||
|
// Build tree
|
||||||
|
const map = new Map();
|
||||||
|
const roots = [];
|
||||||
|
|
||||||
|
comments.forEach(c => {
|
||||||
|
c.children = [];
|
||||||
|
map.set(c.id, c);
|
||||||
|
});
|
||||||
|
|
||||||
|
comments.forEach(c => {
|
||||||
|
if (c.parent_id && map.has(c.parent_id)) {
|
||||||
|
map.get(c.parent_id).children.push(c);
|
||||||
|
} else {
|
||||||
|
roots.push(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const subText = isSubscribed ? 'Subscribed' : 'Subscribe';
|
||||||
|
const subClass = isSubscribed ? 'active' : '';
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<div class="comments-header">
|
||||||
|
<h3>Comments (${comments.length})</h3>
|
||||||
|
<div class="comments-controls">
|
||||||
|
<select id="comment-sort">
|
||||||
|
<option value="old" ${this.sort === 'old' ? 'selected' : ''}>Oldest</option>
|
||||||
|
<option value="new" ${this.sort === 'new' ? 'selected' : ''}>Newest</option>
|
||||||
|
</select>
|
||||||
|
${currentUserId ? `<button id="subscribe-btn" class="${subClass}">${subText}</button>` : ''}
|
||||||
|
<button id="refresh-comments">Refresh</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${currentUserId ? this.renderInput() : '<div class="login-placeholder"><a href="/login">Login</a> to comment</div>'}
|
||||||
|
<div class="comments-list">
|
||||||
|
${roots.map(c => this.renderComment(c, currentUserId)).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.container.innerHTML = html;
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCommentContent(content) {
|
||||||
|
if (typeof marked === 'undefined') {
|
||||||
|
console.warn('Marked.js not loaded, falling back to plain text');
|
||||||
|
return this.escapeHtml(content).replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Escape HTML, but preserve > for blockquotes
|
||||||
|
let safe = content
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
|
||||||
|
const renderer = new marked.Renderer();
|
||||||
|
renderer.blockquote = function (quote) {
|
||||||
|
// If quote is an object (latest marked), extract text. Otherwise use it as string.
|
||||||
|
let text = (typeof quote === 'string') ? quote : (quote.text || '');
|
||||||
|
let cleanQuote = text.replace(/<p>|<\/p>|\n/g, '');
|
||||||
|
return `<span class="greentext">> ${cleanQuote}</span><br>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
let md = marked.parse(safe, {
|
||||||
|
breaks: true,
|
||||||
|
renderer: renderer
|
||||||
|
});
|
||||||
|
|
||||||
|
return md.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Markdown error:', e);
|
||||||
|
return this.escapeHtml(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderComment(comment, currentUserId) {
|
||||||
|
const isDeleted = comment.is_deleted;
|
||||||
|
const content = isDeleted ? '<span class="deleted-msg">[deleted]</span>' : this.renderCommentContent(comment.content);
|
||||||
|
const date = new Date(comment.created_at).toLocaleString();
|
||||||
|
|
||||||
|
// Admin buttons
|
||||||
|
let adminButtons = '';
|
||||||
|
if (this.isAdmin && !isDeleted) {
|
||||||
|
adminButtons = `
|
||||||
|
<button class="admin-edit-btn" data-id="${comment.id}" data-content="${this.escapeHtml(comment.content)}">✏️</button>
|
||||||
|
<button class="admin-delete-btn" data-id="${comment.id}">🗑️</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="comment ${isDeleted ? 'deleted' : ''}" id="c${comment.id}">
|
||||||
|
<div class="comment-avatar">
|
||||||
|
<img src="${comment.avatar ? `/t/${comment.avatar}.webp` : '/s/img/default.png'}" alt="av">
|
||||||
|
</div>
|
||||||
|
<div class="comment-body">
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-author">${comment.username || 'System'}</span>
|
||||||
|
<span class="comment-time">${date}</span>
|
||||||
|
<a href="#c${comment.id}" class="comment-permalink">#${comment.id}</a>
|
||||||
|
${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}">Reply</button>` : ''}
|
||||||
|
${adminButtons}
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">${content}</div>
|
||||||
|
${comment.children.length > 0 ? `<div class="comment-children">${comment.children.map(c => this.renderComment(c, currentUserId)).join('')}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInput(parentId = null) {
|
||||||
|
return `
|
||||||
|
<div class="comment-input ${parentId ? 'reply-input' : 'main-input'}" ${parentId ? `data-parent="${parentId}"` : ''}>
|
||||||
|
<textarea placeholder="Write a comment..."></textarea>
|
||||||
|
<div class="input-actions">
|
||||||
|
<button class="submit-comment">Post</button>
|
||||||
|
${parentId ? '<button class="cancel-reply">Cancel</button>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Sorting
|
||||||
|
const sortSelect = this.container.querySelector('#comment-sort');
|
||||||
|
if (sortSelect) {
|
||||||
|
sortSelect.addEventListener('change', (e) => {
|
||||||
|
this.sort = e.target.value;
|
||||||
|
this.loadComments();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posting
|
||||||
|
this.container.querySelectorAll('.submit-comment').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => this.handleSubmit(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
this.container.querySelectorAll('.delete-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
if (!confirm('Delete this comment?')) return;
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const res = await fetch(`/api/comments/${id}/delete`, { method: 'POST' });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) this.loadComments();
|
||||||
|
else alert('Failed to delete: ' + (json.message || 'Error'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin Delete
|
||||||
|
this.container.querySelectorAll('.admin-delete-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async (e) => {
|
||||||
|
if (!confirm('Admin: Delete this comment?')) return;
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const res = await fetch(`/api/comments/${id}/delete`, { method: 'POST' });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) this.loadComments(id);
|
||||||
|
else alert('Failed to delete: ' + (json.message || 'Error'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Admin Edit
|
||||||
|
this.container.querySelectorAll('.admin-edit-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const currentContent = e.target.dataset.content;
|
||||||
|
const commentEl = document.getElementById('c' + id);
|
||||||
|
const contentEl = commentEl.querySelector('.comment-content');
|
||||||
|
|
||||||
|
// Replace content with textarea
|
||||||
|
const originalHtml = contentEl.innerHTML;
|
||||||
|
contentEl.innerHTML = `
|
||||||
|
<textarea class="edit-textarea">${currentContent}</textarea>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="save-edit-btn">Save</button>
|
||||||
|
<button class="cancel-edit-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
contentEl.querySelector('.cancel-edit-btn').addEventListener('click', () => {
|
||||||
|
contentEl.innerHTML = originalHtml;
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.querySelector('.save-edit-btn').addEventListener('click', async () => {
|
||||||
|
const newContent = contentEl.querySelector('.edit-textarea').value;
|
||||||
|
if (!newContent.trim()) return alert('Cannot be empty');
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('content', newContent);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/comments/${id}/edit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
this.loadComments(id);
|
||||||
|
} else {
|
||||||
|
alert('Failed to edit: ' + (json.message || 'Error'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reply
|
||||||
|
this.container.querySelectorAll('.reply-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const body = e.target.closest('.comment-body');
|
||||||
|
// Check if input already exists
|
||||||
|
if (body.querySelector('.reply-input')) return;
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.innerHTML = this.renderInput(id);
|
||||||
|
body.appendChild(div.firstElementChild);
|
||||||
|
|
||||||
|
// Bind new buttons
|
||||||
|
const newForm = body.querySelector('.reply-input');
|
||||||
|
newForm.querySelector('.submit-comment').addEventListener('click', (ev) => this.handleSubmit(ev));
|
||||||
|
newForm.querySelector('.cancel-reply').addEventListener('click', () => newForm.remove());
|
||||||
|
this.setupEmojiPicker(newForm);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main Input Emoji Picker
|
||||||
|
const mainInput = this.container.querySelector('.main-input');
|
||||||
|
if (mainInput) this.setupEmojiPicker(mainInput);
|
||||||
|
|
||||||
|
// Subscription
|
||||||
|
// Subscription
|
||||||
|
const subBtn = this.container.querySelector('#subscribe-btn');
|
||||||
|
if (subBtn) {
|
||||||
|
subBtn.addEventListener('click', async () => {
|
||||||
|
// Optimistic UI update
|
||||||
|
const isSubscribed = subBtn.textContent === 'Subscribed';
|
||||||
|
subBtn.textContent = 'Wait...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/subscribe/${this.itemId}`, { method: 'POST' });
|
||||||
|
const json = await res.json();
|
||||||
|
|
||||||
|
if (json.success) {
|
||||||
|
subBtn.textContent = json.subscribed ? 'Subscribed' : 'Subscribe';
|
||||||
|
subBtn.classList.toggle('active', json.subscribed);
|
||||||
|
} else {
|
||||||
|
// Revert
|
||||||
|
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
|
||||||
|
alert('Failed to toggle subscription');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
subBtn.textContent = isSubscribed ? 'Subscribed' : 'Subscribe';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
const refBtn = this.container.querySelector('#refresh-comments');
|
||||||
|
if (refBtn) {
|
||||||
|
refBtn.addEventListener('click', async () => {
|
||||||
|
this.loadComments();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permalinks
|
||||||
|
this.container.addEventListener('click', (e) => {
|
||||||
|
if (e.target.classList.contains('comment-permalink')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const hash = e.target.getAttribute('href'); // #c123
|
||||||
|
const id = hash.substring(2);
|
||||||
|
|
||||||
|
// Update URL without reload/hashchange trigger if possible, or just pushState
|
||||||
|
history.pushState(null, null, hash);
|
||||||
|
|
||||||
|
// Manually scroll
|
||||||
|
this.scrollToComment(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSubmit(e) {
|
||||||
|
const wrap = e.target.closest('.comment-input');
|
||||||
|
const text = wrap.querySelector('textarea').value;
|
||||||
|
const parentId = wrap.dataset.parent || null;
|
||||||
|
|
||||||
|
if (!text.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('item_id', this.itemId);
|
||||||
|
if (parentId) params.append('parent_id', parentId);
|
||||||
|
params.append('content', text);
|
||||||
|
|
||||||
|
const res = await fetch('/api/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params
|
||||||
|
});
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.success) {
|
||||||
|
// Refresh comments or append locally
|
||||||
|
this.loadComments(json.comment.id);
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + json.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Submit Error:', err);
|
||||||
|
alert('Failed to send comment: ' + err.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupGlobalListeners() {
|
||||||
|
window.addEventListener('hashchange', () => {
|
||||||
|
if (location.hash && location.hash.startsWith('#c')) {
|
||||||
|
const id = location.hash.substring(2);
|
||||||
|
this.scrollToComment(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
setupEmojiPicker(container) {
|
||||||
|
const textarea = container.querySelector('textarea');
|
||||||
|
if (container.querySelector('.emoji-trigger')) return;
|
||||||
|
|
||||||
|
const trigger = document.createElement('button');
|
||||||
|
trigger.innerText = '☺';
|
||||||
|
trigger.className = 'emoji-trigger';
|
||||||
|
|
||||||
|
const actions = container.querySelector('.input-actions');
|
||||||
|
if (actions) {
|
||||||
|
actions.prepend(trigger);
|
||||||
|
|
||||||
|
trigger.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
let picker = container.querySelector('.emoji-picker');
|
||||||
|
if (picker) {
|
||||||
|
picker.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
picker = document.createElement('div');
|
||||||
|
picker.className = 'emoji-picker';
|
||||||
|
|
||||||
|
if (this.customEmojis && Object.keys(this.customEmojis).length > 0) {
|
||||||
|
Object.keys(this.customEmojis).forEach(name => {
|
||||||
|
const url = this.customEmojis[name];
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = url;
|
||||||
|
img.title = `:${name}:`;
|
||||||
|
img.onclick = (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
textarea.value += ` :${name}: `;
|
||||||
|
textarea.focus();
|
||||||
|
};
|
||||||
|
picker.appendChild(img);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
picker.innerHTML = '<div style="padding:5px;color:white;font-size:0.8em;">No emojis found</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeHandler = (ev) => {
|
||||||
|
if (!picker.contains(ev.target) && ev.target !== trigger) {
|
||||||
|
picker.remove();
|
||||||
|
document.removeEventListener('click', closeHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||||
|
|
||||||
|
trigger.after(picker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance or initialization
|
||||||
|
window.commentSystem = new CommentSystem();
|
||||||
|
// Re-init on navigation (if using SPA-like/pjax or custom f0ck.js navigation)
|
||||||
|
document.addEventListener('f0ck:contentLoaded', () => { // Assuming custom event or we hook into it
|
||||||
|
// f0ck.js probably replaces content. We need to re-init.
|
||||||
|
window.commentSystem = new CommentSystem();
|
||||||
|
});
|
||||||
|
|
||||||
|
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
|
||||||
|
// Looking at f0ck.js, it seems to just replace innerHTML.
|
||||||
@@ -9,6 +9,125 @@ window.requestAnimFrame = (function () {
|
|||||||
(() => {
|
(() => {
|
||||||
let video;
|
let video;
|
||||||
|
|
||||||
|
// User & Visitor dropdown toggle
|
||||||
|
const userToggle = document.getElementById('nav-user-toggle');
|
||||||
|
const userMenu = document.getElementById('nav-user-menu');
|
||||||
|
const visitorToggle = document.getElementById('nav-visitor-toggle');
|
||||||
|
const visitorMenu = document.getElementById('nav-visitor-menu');
|
||||||
|
|
||||||
|
if (userToggle && userMenu) {
|
||||||
|
userToggle.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
userMenu.classList.toggle('show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visitorToggle && visitorMenu) {
|
||||||
|
visitorToggle.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
visitorMenu.classList.toggle('show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (userMenu && !userMenu.contains(e.target) && userToggle && !userToggle.contains(e.target)) {
|
||||||
|
userMenu.classList.remove('show');
|
||||||
|
}
|
||||||
|
if (visitorMenu && !visitorMenu.contains(e.target) && visitorToggle && !visitorToggle.contains(e.target)) {
|
||||||
|
visitorMenu.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login Modal Logic
|
||||||
|
const loginBtn = document.getElementById('nav-login-btn');
|
||||||
|
const loginModal = document.getElementById('login-modal');
|
||||||
|
const loginClose = document.getElementById('login-modal-close');
|
||||||
|
|
||||||
|
if (loginBtn && loginModal) {
|
||||||
|
loginBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
loginModal.style.display = 'flex';
|
||||||
|
// Close dropdown
|
||||||
|
if (visitorMenu) visitorMenu.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginClose) {
|
||||||
|
loginClose.addEventListener('click', () => {
|
||||||
|
loginModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loginModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === loginModal) {
|
||||||
|
loginModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle ESC key to close
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && loginModal.style.display === 'flex') {
|
||||||
|
loginModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Flash Message (login=success)
|
||||||
|
if (window.location.search.includes('login=success')) {
|
||||||
|
loginModal.style.display = 'flex';
|
||||||
|
const form = loginModal.querySelector('.login-form');
|
||||||
|
if (form && !form.querySelector('.flash-success')) {
|
||||||
|
const msg = document.createElement('div');
|
||||||
|
msg.className = 'flash-success';
|
||||||
|
msg.style.color = '#fff';
|
||||||
|
msg.style.background = 'var(--accent)'; // f0ck accent usually
|
||||||
|
msg.style.padding = '10px';
|
||||||
|
msg.style.borderRadius = '4px';
|
||||||
|
msg.style.marginBottom = '10px';
|
||||||
|
msg.style.textAlign = 'center';
|
||||||
|
msg.style.color = 'black'; // contrast
|
||||||
|
msg.style.fontWeight = 'bold';
|
||||||
|
msg.textContent = 'Success! You might login now.';
|
||||||
|
form.insertBefore(msg, form.firstChild); // Insert at top of form
|
||||||
|
|
||||||
|
// Clean URL
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.delete('login');
|
||||||
|
window.history.replaceState({}, '', url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register Modal Logic
|
||||||
|
const registerBtn = document.getElementById('nav-register-btn');
|
||||||
|
const registerModal = document.getElementById('register-modal');
|
||||||
|
const registerClose = document.getElementById('register-modal-close');
|
||||||
|
|
||||||
|
if (registerBtn && registerModal) {
|
||||||
|
registerBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
registerModal.style.display = 'flex';
|
||||||
|
// Close dropdown
|
||||||
|
if (visitorMenu) visitorMenu.classList.remove('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (registerClose) {
|
||||||
|
registerClose.addEventListener('click', () => {
|
||||||
|
registerModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
registerModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === registerModal) {
|
||||||
|
registerModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && registerModal.style.display === 'flex') {
|
||||||
|
registerModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize background preference
|
// Initialize background preference
|
||||||
if (localStorage.getItem('background') == undefined) {
|
if (localStorage.getItem('background') == undefined) {
|
||||||
localStorage.setItem('background', 'true');
|
localStorage.setItem('background', 'true');
|
||||||
@@ -29,12 +148,14 @@ window.requestAnimFrame = (function () {
|
|||||||
|
|
||||||
if (elem = document.querySelector("#my-video")) {
|
if (elem = document.querySelector("#my-video")) {
|
||||||
video = new v0ck(elem);
|
video = new v0ck(elem);
|
||||||
|
/* Listener moved to global keybindings
|
||||||
document.addEventListener("keydown", e => {
|
document.addEventListener("keydown", e => {
|
||||||
if (e.key === " " && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
if (e.key === " " && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
||||||
video[video.paused ? 'play' : 'pause']();
|
video[video.paused ? 'play' : 'pause']();
|
||||||
document.querySelector('.v0ck_overlay').classList[video.paused ? 'remove' : 'add']('v0ck_hidden');
|
document.querySelector('.v0ck_overlay').classList[video.paused ? 'remove' : 'add']('v0ck_hidden');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -188,20 +309,22 @@ window.requestAnimFrame = (function () {
|
|||||||
const navbar = document.querySelector("nav.navbar");
|
const navbar = document.querySelector("nav.navbar");
|
||||||
if (navbar) navbar.classList.add("pbwork");
|
if (navbar) navbar.classList.add("pbwork");
|
||||||
|
|
||||||
// Extract item ID from URL. Regex now handles query params, hashes, and trailing slashes.
|
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
|
||||||
const match = url.match(/\/(\d+)(?:\/|#|\?|$)/);
|
// Split path, filter numeric, pop last.
|
||||||
|
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
|
||||||
|
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
|
||||||
|
|
||||||
// Hide navbar pagination for Item View (matches SSR)
|
// Hide navbar pagination for Item View (matches SSR)
|
||||||
const navPag = document.querySelector('.pagination-container-fluid');
|
const navPag = document.querySelector('.pagination-container-fluid');
|
||||||
if (navPag) navPag.style.display = 'none';
|
if (navPag) navPag.style.display = 'none';
|
||||||
|
|
||||||
if (!match) {
|
if (numericSegments.length === 0) {
|
||||||
console.warn("loadItemAjax: No ID match found in URL", url);
|
console.warn("loadItemAjax: No ID match found in URL", url);
|
||||||
// fallback for weird/external links
|
// fallback for weird/external links
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const itemid = match[1];
|
const itemid = numericSegments.pop();
|
||||||
|
|
||||||
// <context-preservation>
|
// <context-preservation>
|
||||||
// Extract context from Target URL first
|
// Extract context from Target URL first
|
||||||
@@ -212,7 +335,7 @@ window.requestAnimFrame = (function () {
|
|||||||
const userMatch = url.match(/\/user\/([^/]+)/);
|
const userMatch = url.match(/\/user\/([^/]+)/);
|
||||||
if (userMatch) {
|
if (userMatch) {
|
||||||
user = decodeURIComponent(userMatch[1]);
|
user = decodeURIComponent(userMatch[1]);
|
||||||
if (url.includes(`/user/${userMatch[1]}/favs`)) isFavs = true;
|
if (url.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If missing and inheritContext is true, check Window Location
|
// If missing and inheritContext is true, check Window Location
|
||||||
@@ -225,7 +348,8 @@ window.requestAnimFrame = (function () {
|
|||||||
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
|
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
|
||||||
if (wUserMatch) {
|
if (wUserMatch) {
|
||||||
user = decodeURIComponent(wUserMatch[1]);
|
user = decodeURIComponent(wUserMatch[1]);
|
||||||
if (window.location.href.includes(`/user/${wUserMatch[1]}/favs`)) isFavs = true;
|
// Check for /favs (with or without trailing /, item id, or query params)
|
||||||
|
if (window.location.href.match(/\/user\/[^/]+\/favs(\/|$|\?)/)) isFavs = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,6 +396,11 @@ window.requestAnimFrame = (function () {
|
|||||||
const main = document.getElementById('main');
|
const main = document.getElementById('main');
|
||||||
main.innerHTML = '<div class="container"></div>';
|
main.innerHTML = '<div class="container"></div>';
|
||||||
container = main.querySelector('.container');
|
container = main.querySelector('.container');
|
||||||
|
} else if (!container && document.getElementById('main')) {
|
||||||
|
// Transition from User Profile or other pages without .container
|
||||||
|
const main = document.getElementById('main');
|
||||||
|
main.innerHTML = '<div class="container"></div>';
|
||||||
|
container = main.querySelector('.container');
|
||||||
} else if (container) {
|
} else if (container) {
|
||||||
// Check if we are on Tags Overview logic (which reuses .container)
|
// Check if we are on Tags Overview logic (which reuses .container)
|
||||||
const tagsOverview = container.querySelector('.tags');
|
const tagsOverview = container.querySelector('.tags');
|
||||||
@@ -282,9 +411,11 @@ window.requestAnimFrame = (function () {
|
|||||||
const oldContent = container.querySelector('.content');
|
const oldContent = container.querySelector('.content');
|
||||||
const oldMetadata = container.querySelector('.metadata');
|
const oldMetadata = container.querySelector('.metadata');
|
||||||
const oldHeader = container.querySelector('._204863');
|
const oldHeader = container.querySelector('._204863');
|
||||||
|
const oldComments = container.querySelector('#comments-container');
|
||||||
if (oldHeader) oldHeader.remove();
|
if (oldHeader) oldHeader.remove();
|
||||||
if (oldContent) oldContent.remove();
|
if (oldContent) oldContent.remove();
|
||||||
if (oldMetadata) oldMetadata.remove();
|
if (oldMetadata) oldMetadata.remove();
|
||||||
|
if (oldComments) oldComments.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,10 +432,10 @@ window.requestAnimFrame = (function () {
|
|||||||
let pushUrl = `/${itemid}`;
|
let pushUrl = `/${itemid}`;
|
||||||
// Logic from ajax.mjs context reconstruction:
|
// Logic from ajax.mjs context reconstruction:
|
||||||
if (user) {
|
if (user) {
|
||||||
pushUrl = `/user/${user}/${itemid}`;
|
pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`;
|
||||||
if (isFavs) pushUrl = `/user/${user}/favs/${itemid}`;
|
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`;
|
||||||
}
|
}
|
||||||
else if (tag) pushUrl = `/tag/${tag}/${itemid}`;
|
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag)}/${itemid}`;
|
||||||
|
|
||||||
// We overwrite proper URL even if the link clicked was "naked"
|
// We overwrite proper URL even if the link clicked was "naked"
|
||||||
history.pushState({}, '', pushUrl);
|
history.pushState({}, '', pushUrl);
|
||||||
@@ -316,6 +447,9 @@ window.requestAnimFrame = (function () {
|
|||||||
if (navbar) navbar.classList.remove("pbwork");
|
if (navbar) navbar.classList.remove("pbwork");
|
||||||
console.log("AJAX load complete");
|
console.log("AJAX load complete");
|
||||||
|
|
||||||
|
// Notify extensions
|
||||||
|
document.dispatchEvent(new Event('f0ck:contentLoaded'));
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("AJAX load failed:", err);
|
console.error("AJAX load failed:", err);
|
||||||
}
|
}
|
||||||
@@ -358,7 +492,12 @@ window.requestAnimFrame = (function () {
|
|||||||
if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1]));
|
if (wTagMatch) params.append('tag', decodeURIComponent(wTagMatch[1]));
|
||||||
|
|
||||||
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
|
const wUserMatch = window.location.href.match(/\/user\/([^/]+)/);
|
||||||
if (wUserMatch) params.append('user', decodeURIComponent(wUserMatch[1]));
|
if (wUserMatch) {
|
||||||
|
params.append('user', decodeURIComponent(wUserMatch[1]));
|
||||||
|
if (window.location.href.match(/\/favs(\/|$|\?)/)) {
|
||||||
|
params.append('fav', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ([...params].length > 0) {
|
if ([...params].length > 0) {
|
||||||
randomUrl += '?' + params.toString();
|
randomUrl += '?' + params.toString();
|
||||||
@@ -413,16 +552,66 @@ window.requestAnimFrame = (function () {
|
|||||||
canvas.classList.add('fader-out');
|
canvas.classList.add('fader-out');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (e.target.closest('.removetag')) {
|
||||||
|
e.preventDefault();
|
||||||
|
const removeBtn = e.target.closest('.removetag');
|
||||||
|
const tagLink = removeBtn.previousElementSibling;
|
||||||
|
|
||||||
|
if (tagLink) {
|
||||||
|
const tagName = tagLink.textContent.trim();
|
||||||
|
const idLink = document.querySelector('.id-link');
|
||||||
|
const id = idLink ? idLink.textContent.trim() : null;
|
||||||
|
|
||||||
|
if (id && tagName) {
|
||||||
|
const modal = document.getElementById('delete-tag-modal');
|
||||||
|
const nameEl = document.getElementById('delete-tag-name');
|
||||||
|
const confirmBtn = document.getElementById('delete-tag-confirm');
|
||||||
|
const cancelBtn = document.getElementById('delete-tag-cancel');
|
||||||
|
|
||||||
|
if (modal) {
|
||||||
|
nameEl.textContent = tagName;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
confirmBtn.onclick = null;
|
||||||
|
cancelBtn.onclick = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelBtn.onclick = closeModal;
|
||||||
|
|
||||||
|
confirmBtn.onclick = () => {
|
||||||
|
confirmBtn.textContent = 'Deleting...';
|
||||||
|
confirmBtn.disabled = true;
|
||||||
|
fetch(`/api/v2/admin/${id}/tags/${encodeURIComponent(tagName)}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
removeBtn.parentElement.remove();
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
alert('Error: ' + (data.msg || 'Unknown error'));
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to delete tag');
|
||||||
|
confirmBtn.textContent = 'Delete';
|
||||||
|
confirmBtn.disabled = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('popstate', (e) => {
|
window.addEventListener('popstate', (e) => {
|
||||||
if (window.location.href.match(/\/p\/\d+/) || window.location.href.match(/[?&]page=\d+/) || window.location.pathname === '/') {
|
if (window.location.href.match(/\/p\/\d+/) || window.location.href.match(/[?&]page=\d+/) || window.location.pathname === '/') {
|
||||||
// Ideally we should reload page or call loadPageAjax(currentUrl) if it supports it
|
|
||||||
// But if we are going BACK to index from item, we expect grid.
|
|
||||||
// loadItemAjax fails on index.
|
|
||||||
// loadPageAjax handles /p/N logic.
|
|
||||||
// If just slash, loadPageAjax might default to page 1.
|
|
||||||
loadPageAjax(window.location.href);
|
loadPageAjax(window.location.href);
|
||||||
} else {
|
} else {
|
||||||
loadItemAjax(window.location.href, true);
|
loadItemAjax(window.location.href, true);
|
||||||
@@ -437,7 +626,20 @@ window.requestAnimFrame = (function () {
|
|||||||
"ArrowRight": clickOnElementBinding("#prev"),
|
"ArrowRight": clickOnElementBinding("#prev"),
|
||||||
"d": clickOnElementBinding("#prev"),
|
"d": clickOnElementBinding("#prev"),
|
||||||
"r": clickOnElementBinding("#random, #nav-random"),
|
"r": clickOnElementBinding("#random, #nav-random"),
|
||||||
" ": clickOnElementBinding("#f0ck-image")
|
"l": () => {
|
||||||
|
const toggle = document.querySelector("#togglebg");
|
||||||
|
if (toggle) toggle.click();
|
||||||
|
},
|
||||||
|
" ": () => {
|
||||||
|
if (video && typeof video.play === 'function') { // Check if video wrapper exists/is valid
|
||||||
|
video[video.paused ? 'play' : 'pause']();
|
||||||
|
const overlay = document.querySelector('.v0ck_overlay');
|
||||||
|
if (overlay) overlay.classList[video.paused ? 'remove' : 'add']('v0ck_hidden');
|
||||||
|
} else {
|
||||||
|
const img = document.querySelector("#f0ck-image");
|
||||||
|
if (img) img.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", e => {
|
document.addEventListener("keydown", e => {
|
||||||
if (e.key in keybindings && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
if (e.key in keybindings && e.target.tagName !== "INPUT" && e.target.tagName !== "TEXTAREA") {
|
||||||
@@ -461,6 +663,7 @@ window.requestAnimFrame = (function () {
|
|||||||
// <wheeler>
|
// <wheeler>
|
||||||
const wheelEventListener = function (event) {
|
const wheelEventListener = function (event) {
|
||||||
if (event.target.closest('.media-object, .steuerung')) {
|
if (event.target.closest('.media-object, .steuerung')) {
|
||||||
|
event.preventDefault(); // Prevent default scroll
|
||||||
if (event.deltaY < 0) {
|
if (event.deltaY < 0) {
|
||||||
const el = document.getElementById('next');
|
const el = document.getElementById('next');
|
||||||
if (el && el.href && !el.href.endsWith('#')) el.click();
|
if (el && el.href && !el.href.endsWith('#')) el.click();
|
||||||
@@ -471,7 +674,7 @@ window.requestAnimFrame = (function () {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('wheel', wheelEventListener);
|
window.addEventListener('wheel', wheelEventListener, { passive: false });
|
||||||
// </wheeler>
|
// </wheeler>
|
||||||
|
|
||||||
|
|
||||||
@@ -490,7 +693,7 @@ window.requestAnimFrame = (function () {
|
|||||||
f0ckimagescroll.removeAttribute("style");
|
f0ckimagescroll.removeAttribute("style");
|
||||||
f0ckimage.removeAttribute("style");
|
f0ckimage.removeAttribute("style");
|
||||||
console.log("image is not expanded")
|
console.log("image is not expanded")
|
||||||
window.addEventListener('wheel', wheelEventListener);
|
window.addEventListener('wheel', wheelEventListener, { passive: false });
|
||||||
} else {
|
} else {
|
||||||
if (img.width > img.height) return;
|
if (img.width > img.height) return;
|
||||||
isImageExpanded = true;
|
isImageExpanded = true;
|
||||||
@@ -565,7 +768,8 @@ window.requestAnimFrame = (function () {
|
|||||||
|
|
||||||
if (data.success && data.html) {
|
if (data.success && data.html) {
|
||||||
// Append new thumbnails
|
// Append new thumbnails
|
||||||
postsContainer.insertAdjacentHTML('beforeend', data.html);
|
const currentPosts = document.querySelector("div.posts");
|
||||||
|
if (currentPosts) currentPosts.insertAdjacentHTML('beforeend', data.html);
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
infiniteState.currentPage = data.currentPage;
|
infiniteState.currentPage = data.currentPage;
|
||||||
@@ -599,6 +803,9 @@ window.requestAnimFrame = (function () {
|
|||||||
const PRELOAD_OFFSET = 500; // pixels before bottom to trigger load
|
const PRELOAD_OFFSET = 500; // pixels before bottom to trigger load
|
||||||
|
|
||||||
window.addEventListener("scroll", () => {
|
window.addEventListener("scroll", () => {
|
||||||
|
const currentContainer = document.querySelector("div.posts");
|
||||||
|
// Only run if .posts exists (index/grid view)
|
||||||
|
if (!currentContainer) return;
|
||||||
if (!document.querySelector('#main')) return;
|
if (!document.querySelector('#main')) return;
|
||||||
|
|
||||||
const scrollPosition = window.innerHeight + window.scrollY;
|
const scrollPosition = window.innerHeight + window.scrollY;
|
||||||
@@ -613,70 +820,7 @@ window.requestAnimFrame = (function () {
|
|||||||
}
|
}
|
||||||
// </infinite-scroll>
|
// </infinite-scroll>
|
||||||
|
|
||||||
// <swipe>
|
|
||||||
const swipeRT = {
|
|
||||||
xDown: null,
|
|
||||||
yDown: null,
|
|
||||||
xDiff: null,
|
|
||||||
yDiff: null,
|
|
||||||
timeDown: null,
|
|
||||||
startEl: null
|
|
||||||
};
|
|
||||||
const swipeOpt = {
|
|
||||||
treshold: 20, // 20px
|
|
||||||
timeout: 500 // 500ms
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('touchstart', e => {
|
|
||||||
swipeRT.startEl = e.target;
|
|
||||||
swipeRT.timeDown = Date.now();
|
|
||||||
swipeRT.xDown = e.touches[0].clientX;
|
|
||||||
swipeRT.yDown = e.touches[0].clientY;
|
|
||||||
swipeRT.xDiff = 0;
|
|
||||||
swipeRT.yDiff = 0;
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
document.addEventListener('touchmove', e => {
|
|
||||||
if (!swipeRT.xDown || !swipeRT.yDown)
|
|
||||||
return;
|
|
||||||
swipeRT.xDiff = swipeRT.xDown - e.touches[0].clientX;
|
|
||||||
swipeRT.yDiff = swipeRT.yDown - e.touches[0].clientY;
|
|
||||||
}, false);
|
|
||||||
|
|
||||||
document.addEventListener('touchend', e => {
|
|
||||||
if (swipeRT.startEl !== e.target)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const timeDiff = Date.now() - swipeRT.timeDown;
|
|
||||||
let elem;
|
|
||||||
|
|
||||||
if (Math.abs(swipeRT.xDiff) > Math.abs(swipeRT.yDiff)) {
|
|
||||||
if (Math.abs(swipeRT.xDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
|
|
||||||
if (swipeRT.xDiff > 0) // left
|
|
||||||
elem = document.querySelector(".pagination > .next:not(.disabled)");
|
|
||||||
else // right
|
|
||||||
elem = document.querySelector(".pagination > .prev:not(.disabled)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if (Math.abs(swipeRT.yDiff) > swipeOpt.treshold && timeDiff < swipeOpt.timeout) {
|
|
||||||
if (navbar = document.querySelector("nav.navbar") && document.querySelector("div.posts")) {
|
|
||||||
if (swipeRT.yDiff > 0 && (window.innerHeight + window.scrollY) >= document.body.offsetHeight) // up
|
|
||||||
elem = document.querySelector(".pagination > .next:not(.disabled)");
|
|
||||||
else if (swipeRT.yDiff <= 0 && window.scrollY <= 0 && document.querySelector("div.posts")) // down
|
|
||||||
elem = document.querySelector(".pagination > .prev:not(.disabled)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
swipeRT.xDown = null;
|
|
||||||
swipeRT.yDown = null;
|
|
||||||
swipeRT.timeDown = null;
|
|
||||||
|
|
||||||
if (elem)
|
|
||||||
changePage(elem);
|
|
||||||
}, false);
|
|
||||||
// </swipe>
|
|
||||||
|
|
||||||
// <visualizer>
|
// <visualizer>
|
||||||
if (audioElement = document.querySelector("audio")) {
|
if (audioElement = document.querySelector("audio")) {
|
||||||
@@ -745,35 +889,76 @@ window.requestAnimFrame = (function () {
|
|||||||
|
|
||||||
// <scroller>
|
// <scroller>
|
||||||
|
|
||||||
|
// <search-overlay>
|
||||||
|
const initSearch = () => {
|
||||||
|
if (!document.getElementById('search-overlay')) {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'search-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div id="search-close">×</div>
|
||||||
|
<input type="text" id="search-input" placeholder="Search Tags..." autocomplete="off">
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = document.getElementById('search-input');
|
||||||
|
const close = document.getElementById('search-close');
|
||||||
|
const btns = document.querySelectorAll('#nav-search-btn, #nav-search-btn-guest');
|
||||||
|
|
||||||
|
const toggleSearch = (show) => {
|
||||||
|
if (show) {
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
// Force reflow
|
||||||
|
overlay.offsetHeight;
|
||||||
|
overlay.classList.add('visible');
|
||||||
|
input.focus();
|
||||||
|
} else {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
btns.forEach(btn => btn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSearch(true);
|
||||||
|
}));
|
||||||
|
|
||||||
|
close.addEventListener('click', () => toggleSearch(false));
|
||||||
|
|
||||||
|
// Close on click outside (background)
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) toggleSearch(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ESC to close
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && overlay.classList.contains('visible')) {
|
||||||
|
toggleSearch(false);
|
||||||
|
}
|
||||||
|
// "k" to open
|
||||||
|
if (e.key === 'k' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA' && !overlay.classList.contains('visible')) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleSearch(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val) {
|
||||||
|
window.location.href = `/tag/${encodeURIComponent(val)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initSearch();
|
||||||
|
// </search-overlay>
|
||||||
|
|
||||||
// </scroller>
|
// </scroller>
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|
||||||
// disable default scroll event when mouse is on content div
|
|
||||||
// this is useful for items that have a lot of tags for example: 12536
|
|
||||||
const targetSelector = '.content';
|
|
||||||
let isMouseOver = true;
|
|
||||||
|
|
||||||
function isPageScrollable() {
|
|
||||||
return document.documentElement.scrollHeight > document.documentElement.clientHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWheel(e) {
|
|
||||||
if (isMouseOver && isPageScrollable()) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
const el = document.querySelector(targetSelector);
|
|
||||||
if (!el) return;
|
|
||||||
el.addEventListener('mouseenter', () => isMouseOver = true);
|
|
||||||
el.addEventListener('mouseleave', () => isMouseOver = false);
|
|
||||||
window.addEventListener('wheel', onWheel, { passive: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('load', init);
|
|
||||||
|
|
||||||
const sbtForm = document.getElementById('sbtForm');
|
const sbtForm = document.getElementById('sbtForm');
|
||||||
if (sbtForm) {
|
if (sbtForm) {
|
||||||
sbtForm.addEventListener('submit', (e) => {
|
sbtForm.addEventListener('submit', (e) => {
|
||||||
@@ -781,6 +966,105 @@ if (sbtForm) {
|
|||||||
const input = document.getElementById('sbtInput').value.trim();
|
const input = document.getElementById('sbtInput').value.trim();
|
||||||
if (input) {
|
if (input) {
|
||||||
window.location.href = `/tag/${encodeURIComponent(input)}`;
|
window.location.href = `/tag/${encodeURIComponent(input)}`;
|
||||||
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification System
|
||||||
|
class NotificationSystem {
|
||||||
|
constructor() {
|
||||||
|
this.bell = document.getElementById('nav-notif-btn');
|
||||||
|
this.dropdown = document.getElementById('notif-dropdown');
|
||||||
|
this.countBadge = this.bell ? this.bell.querySelector('.notif-count') : null;
|
||||||
|
this.list = this.dropdown ? this.dropdown.querySelector('.notif-list') : null;
|
||||||
|
this.markAllBtn = document.getElementById('mark-all-read');
|
||||||
|
|
||||||
|
if (this.bell && this.dropdown) {
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.bindEvents();
|
||||||
|
this.poll();
|
||||||
|
setInterval(() => this.poll(), 60000); // Poll every minute
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.bell.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dropdown.classList.toggle('visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!this.bell.contains(e.target) && !this.dropdown.contains(e.target)) {
|
||||||
|
this.dropdown.classList.remove('visible');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.markAllBtn) {
|
||||||
|
this.markAllBtn.addEventListener('click', async () => {
|
||||||
|
await fetch('/api/notifications/read', { method: 'POST' });
|
||||||
|
this.markAllReadUI();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async poll() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/notifications');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.updateUI(data.notifications);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Notification poll error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUI(notifications) {
|
||||||
|
const unreadCount = notifications.filter(n => !n.is_read).length;
|
||||||
|
|
||||||
|
if (unreadCount > 0) {
|
||||||
|
this.countBadge.textContent = unreadCount;
|
||||||
|
this.countBadge.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
this.countBadge.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (notifications.length === 0) {
|
||||||
|
this.list.innerHTML = '<div class="notif-empty">No new notifications</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.list.innerHTML = notifications.map(n => this.renderItem(n)).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem(n) {
|
||||||
|
const typeText = n.type === 'comment_reply' ? 'replied to your comment' : 'Start';
|
||||||
|
const cid = n.comment_id || n.reference_id;
|
||||||
|
const link = `/${n.item_id}#c${cid}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" onclick="window.location.href='${link}'; return false;">
|
||||||
|
<div>
|
||||||
|
<strong>${n.from_user}</strong> ${typeText}
|
||||||
|
</div>
|
||||||
|
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
markAllReadUI() {
|
||||||
|
this.countBadge.style.display = 'none';
|
||||||
|
this.list.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init Notifications
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
new NotificationSystem();
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
69
public/s/js/marked.min.js
vendored
Normal file
69
public/s/js/marked.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -18,51 +18,53 @@ const Cookie = {
|
|||||||
const acttheme = Cookie.get('theme') ?? "w0bm";
|
const acttheme = Cookie.get('theme') ?? "w0bm";
|
||||||
const themecontainer = document.querySelector("li#themes > ul.dropdown-menu");
|
const themecontainer = document.querySelector("li#themes > ul.dropdown-menu");
|
||||||
|
|
||||||
if (!themecontainer) return; // Theme menu not present on this page
|
if (themecontainer) {
|
||||||
|
|
||||||
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
|
|
||||||
if (acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme))
|
|
||||||
document.documentElement.setAttribute("theme", acttheme);
|
|
||||||
[...themecontainer.querySelectorAll("li > a")].forEach(t => t.addEventListener("click", e => {
|
|
||||||
e.preventDefault();
|
|
||||||
const _theme = e.target.innerText.toLowerCase();
|
|
||||||
document.documentElement.setAttribute("theme", _theme);
|
|
||||||
document.querySelector("#themes > a").setAttribute("content", _theme);
|
|
||||||
Cookie.set("theme", _theme, { path: "/", days: 360 });
|
|
||||||
return false;
|
|
||||||
}));
|
|
||||||
|
|
||||||
document.addEventListener("keydown", e => {
|
|
||||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
|
||||||
return;
|
|
||||||
const acttheme = Cookie.get('theme') ?? "w0bm";
|
|
||||||
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
|
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
|
||||||
const k = e.key;
|
if (acttheme !== document.documentElement.getAttribute("theme") && themes.includes(acttheme))
|
||||||
if (k === "t") {
|
document.documentElement.setAttribute("theme", acttheme);
|
||||||
e.preventDefault();
|
// [...themecontainer.querySelectorAll("li > a")].forEach(t => t.addEventListener("click", e => {
|
||||||
let i = themes.indexOf(acttheme);
|
// e.preventDefault();
|
||||||
if (++i >= themes.length)
|
// const _theme = e.target.innerText.toLowerCase();
|
||||||
i = 0;
|
// document.documentElement.setAttribute("theme", _theme);
|
||||||
document.documentElement.setAttribute("theme", themes[i]);
|
// document.querySelector("#themes > a").setAttribute("content", _theme);
|
||||||
document.querySelector("#themes > a").setAttribute("content", themes[i]);
|
// Cookie.set("theme", _theme, { path: "/", days: 360 });
|
||||||
Cookie.set("theme", themes[i], { path: "/", days: 360 });
|
// return false;
|
||||||
}
|
// }));
|
||||||
});
|
|
||||||
|
|
||||||
if (tbuttonfull = document.querySelector('svg#a_tfull')) {
|
document.addEventListener("keydown", e => {
|
||||||
tbuttonfull.addEventListener('click', e => {
|
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA")
|
||||||
let f = Cookie.get('fullscreen');
|
return;
|
||||||
if (f == 1) {
|
const acttheme = Cookie.get('theme') ?? "w0bm";
|
||||||
Cookie.set('fullscreen', 0);
|
const themes = [...themecontainer.querySelectorAll("li > a")].map(t => t.innerText.toLowerCase());
|
||||||
document.querySelector('html').setAttribute('res', '');
|
const k = e.key;
|
||||||
tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-maximize"></use>`;
|
// if (k === "t") {
|
||||||
}
|
// e.preventDefault();
|
||||||
else {
|
// let i = themes.indexOf(acttheme);
|
||||||
Cookie.set('fullscreen', 1);
|
// if (++i >= themes.length)
|
||||||
document.querySelector('html').setAttribute('res', 'fullscreen');
|
// i = 0;
|
||||||
tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-minimize"></use>`;
|
// document.documentElement.setAttribute("theme", themes[i]);
|
||||||
}
|
// document.querySelector("#themes > a").setAttribute("content", themes[i]);
|
||||||
return true;
|
// Cookie.set("theme", themes[i], { path: "/", days: 360 });
|
||||||
|
// }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fullscreen toggle - runs regardless of theme menu presence
|
||||||
|
document.addEventListener('click', e => {
|
||||||
|
const tbuttonfull = e.target.closest('svg#a_tfull');
|
||||||
|
if (!tbuttonfull) return;
|
||||||
|
|
||||||
|
let f = Cookie.get('fullscreen');
|
||||||
|
if (f == 1) {
|
||||||
|
Cookie.set('fullscreen', 0);
|
||||||
|
document.documentElement.setAttribute('res', '');
|
||||||
|
tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-maximize"></use>`;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Cookie.set('fullscreen', 1);
|
||||||
|
document.documentElement.setAttribute('res', 'fullscreen');
|
||||||
|
tbuttonfull.innerHTML = `<use href="/s/img/iconset.svg#window-minimize"></use>`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
351
public/s/js/upload.js
Normal file
351
public/s/js/upload.js
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
(() => {
|
||||||
|
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');
|
||||||
|
// Note: prompt is now a label, but accessible via class
|
||||||
|
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 = 'Upload (Select file first)';
|
||||||
|
} 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'];
|
||||||
|
// Check extensions as fallback
|
||||||
|
const ext = file.name.split('.').pop().toLowerCase();
|
||||||
|
const validExts = ['mp4', 'webm'];
|
||||||
|
|
||||||
|
if (!validTypes.includes(file.type) && !validExts.includes(ext)) {
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Hide input so it doesn't intercept clicks on preview/remove button
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
|
||||||
|
filePreview.style.display = 'flex';
|
||||||
|
statusDiv.textContent = '';
|
||||||
|
statusDiv.className = 'upload-status';
|
||||||
|
|
||||||
|
// Video Preview
|
||||||
|
const itemPreview = filePreview.querySelector('.item-preview') || document.createElement('div');
|
||||||
|
itemPreview.className = 'item-preview';
|
||||||
|
itemPreview.style.marginRight = '15px';
|
||||||
|
|
||||||
|
// Clear previous
|
||||||
|
const existingVid = filePreview.querySelector('video');
|
||||||
|
if (existingVid) existingVid.remove();
|
||||||
|
|
||||||
|
const vid = document.createElement('video');
|
||||||
|
vid.src = URL.createObjectURL(file);
|
||||||
|
vid.controls = true; // User might want to scrub to check if it's the right video
|
||||||
|
vid.autoplay = true;
|
||||||
|
vid.muted = true;
|
||||||
|
vid.loop = true;
|
||||||
|
// Styles handled by CSS now for "Big" preview
|
||||||
|
|
||||||
|
filePreview.prepend(vid);
|
||||||
|
|
||||||
|
updateSubmitButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
const preventDefaults = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach drag events only to dropZone now (Input is hidden)
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => dropZone.classList.add('dragover'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => dropZone.classList.remove('dragover'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
const files = dt.files;
|
||||||
|
handleFile(files[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Native change listener on hidden input
|
||||||
|
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';
|
||||||
|
fileInput.style.display = 'block'; // Restore input visibility
|
||||||
|
filePreview.style.display = 'none';
|
||||||
|
// Clear preview video
|
||||||
|
const vid = filePreview.querySelector('video');
|
||||||
|
if (vid) vid.remove();
|
||||||
|
|
||||||
|
updateSubmitButton();
|
||||||
|
});
|
||||||
|
|
||||||
|
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">×</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();
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentFocus = -1;
|
||||||
|
|
||||||
|
const addActive = (x) => {
|
||||||
|
if (!x) return false;
|
||||||
|
removeActive(x);
|
||||||
|
if (currentFocus >= x.length) currentFocus = 0;
|
||||||
|
if (currentFocus < 0) currentFocus = (x.length - 1);
|
||||||
|
x[currentFocus].classList.add("active");
|
||||||
|
// Scroll to view
|
||||||
|
x[currentFocus].scrollIntoView({ block: 'nearest' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeActive = (x) => {
|
||||||
|
for (let i = 0; i < x.length; i++) {
|
||||||
|
x[i].classList.remove("active");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tagInput.addEventListener('keydown', (e) => {
|
||||||
|
const x = tagSuggestions.getElementsByClassName("tag-suggestion");
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
currentFocus++;
|
||||||
|
addActive(x);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
currentFocus--;
|
||||||
|
addActive(x);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentFocus > -1) {
|
||||||
|
if (x) x[currentFocus].click();
|
||||||
|
} else {
|
||||||
|
addTag(tagInput.value);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
tagSuggestions.classList.remove('show');
|
||||||
|
currentFocus = -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let debounceTimer;
|
||||||
|
tagInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
const query = tagInput.value.trim();
|
||||||
|
currentFocus = -1; // Reset focus on new input
|
||||||
|
|
||||||
|
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);
|
||||||
|
tagInput.focus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} 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'; // label is actually flex/block via CSS
|
||||||
|
filePreview.style.display = 'none';
|
||||||
|
const vid = filePreview.querySelector('video');
|
||||||
|
if (vid) vid.remove();
|
||||||
|
} 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();
|
||||||
|
})();
|
||||||
@@ -30,22 +30,21 @@ export default async bot => {
|
|||||||
f: async e => {
|
f: async e => {
|
||||||
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${e.message}`);
|
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;
|
let f0ck;
|
||||||
id = +id;
|
id = +id;
|
||||||
|
|
||||||
if(cmd.startsWith('b_settag_')) {
|
if (cmd.startsWith('b_settag_')) {
|
||||||
const tagid = +cmd.replace('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
|
// insert
|
||||||
await db`
|
await db`
|
||||||
insert into "tags_assign" ${
|
insert into "tags_assign" ${db({
|
||||||
db({
|
item_id: id,
|
||||||
item_id: id,
|
tag_id: tagid,
|
||||||
tag_id: tagid,
|
user_id: 1
|
||||||
user_id: 1
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -71,9 +70,9 @@ export default async bot => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
switch(cmd) {
|
switch (cmd) {
|
||||||
case "b_tags":
|
case "b_tags":
|
||||||
if(!id)
|
if (!id)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const keyboard = await tagkeyboard(id);
|
const keyboard = await tagkeyboard(id);
|
||||||
@@ -87,9 +86,9 @@ export default async bot => {
|
|||||||
]]
|
]]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "b_back":
|
case "b_back":
|
||||||
if(!id)
|
if (!id)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
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":
|
case "b_sfw":
|
||||||
|
|
||||||
if(!id)
|
if (!id)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if(!await lib.hasTag(id, 1)) {
|
if (!await lib.hasTag(id, 1)) {
|
||||||
// insert
|
// insert
|
||||||
await db`
|
await db`
|
||||||
insert into "tags_assign" ${
|
insert into "tags_assign" ${db({
|
||||||
db({
|
item_id: id,
|
||||||
item_id: id,
|
tag_id: 1, // sfw
|
||||||
tag_id: 1, // sfw
|
user_id: 1
|
||||||
user_id: 1
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
if(await lib.hasTag(id, 2)) {
|
if (await lib.hasTag(id, 2)) {
|
||||||
await db`
|
await db`
|
||||||
delete from "tags_assign"
|
delete from "tags_assign"
|
||||||
where tag_id = 2
|
where tag_id = 2
|
||||||
@@ -151,23 +149,22 @@ export default async bot => {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "b_nsfw":
|
case "b_nsfw":
|
||||||
if(!id)
|
if (!id)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if(!await lib.hasTag(id, 2)) {
|
if (!await lib.hasTag(id, 2)) {
|
||||||
// insert
|
// insert
|
||||||
await db`
|
await db`
|
||||||
insert into "tags_assign" ${
|
insert into "tags_assign" ${db({
|
||||||
db({
|
item_id: id,
|
||||||
item_id: id,
|
tag_id: 2, // nsfw
|
||||||
tag_id: 2, // nsfw
|
user_id: 1
|
||||||
user_id: 1
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
if(await lib.hasTag(id, 1)) {
|
if (await lib.hasTag(id, 1)) {
|
||||||
await db`
|
await db`
|
||||||
delete from "tags_assign"
|
delete from "tags_assign"
|
||||||
where tag_id = 1
|
where tag_id = 1
|
||||||
@@ -196,9 +193,9 @@ export default async bot => {
|
|||||||
]]
|
]]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "b_delete":
|
case "b_delete":
|
||||||
if(id <= 1)
|
if (id <= 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
e.user = {
|
e.user = {
|
||||||
@@ -218,33 +215,33 @@ export default async bot => {
|
|||||||
`;
|
`;
|
||||||
const level = getLevel(e.user).level;
|
const level = getLevel(e.user).level;
|
||||||
|
|
||||||
if(f0ck.length === 0) {
|
if (f0ck.length === 0) {
|
||||||
return await e.reply(`f0ck ${id}: f0ck not found`);
|
return await e.reply(`f0ck ${id}: f0ck not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if(
|
if (
|
||||||
(f0ck[0].username !== (e.user.nick || e.user.username) ||
|
(f0ck[0].username !== (e.user.nick || e.user.username) ||
|
||||||
f0ck[0].userchannel !== e.channel ||
|
f0ck[0].userchannel !== e.channel ||
|
||||||
f0ck[0].usernetwork !== e.network) &&
|
f0ck[0].usernetwork !== e.network) &&
|
||||||
level < 100
|
level < 100
|
||||||
) {
|
) {
|
||||||
return await e.reply(`f0ck ${id}: insufficient permissions`);
|
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`);
|
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/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).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/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.unlink(`./public/t/${id}.webp`).catch(_=>{});
|
await fs.unlink(`./public/t/${id}.webp`).catch(_ => { });
|
||||||
|
|
||||||
if(f0ck[0].mime.startsWith('audio')) {
|
if (f0ck[0].mime.startsWith('audio')) {
|
||||||
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_=>{});
|
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_ => { });
|
||||||
await fs.unlink(`./public/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, {
|
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":
|
case "b_recover":
|
||||||
if(id <= 1)
|
if (id <= 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
e.user = {
|
e.user = {
|
||||||
@@ -280,18 +277,18 @@ export default async bot => {
|
|||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if(f0ck.length === 0) {
|
if (f0ck.length === 0) {
|
||||||
return await e.reply(`f0ck ${id}: f0ck not found`);
|
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/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).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/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.unlink(`./deleted/t/${id}.webp`).catch(_=>{});
|
await fs.unlink(`./deleted/t/${id}.webp`).catch(_ => { });
|
||||||
|
|
||||||
if(f0ck[0].mime.startsWith('audio')) {
|
if (f0ck[0].mime.startsWith('audio')) {
|
||||||
await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_=>{});
|
await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_ => { });
|
||||||
await fs.unlink(`./deleted/ca/${id}.webp`).catch(_=>{});
|
await fs.unlink(`./deleted/ca/${id}.webp`).catch(_ => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db`update "items" set active = 'true' where id = ${id}`;
|
await db`update "items" set active = 'true' where id = ${id}`;
|
||||||
@@ -308,7 +305,7 @@ export default async bot => {
|
|||||||
]]
|
]]
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
await e.reply('lol');
|
await e.reply('lol');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,12 @@ export default new class {
|
|||||||
const deleted = +(await db`
|
const deleted = +(await db`
|
||||||
select count(*) as total
|
select count(*) as total
|
||||||
from "items"
|
from "items"
|
||||||
where active = false
|
where active = false and is_deleted = true
|
||||||
|
`)[0].total;
|
||||||
|
const pending = +(await db`
|
||||||
|
select count(*) as total
|
||||||
|
from "items"
|
||||||
|
where active = false and is_deleted = false
|
||||||
`)[0].total;
|
`)[0].total;
|
||||||
const lastf0ck = +(await db`
|
const lastf0ck = +(await db`
|
||||||
select max(id) as id
|
select max(id) as id
|
||||||
@@ -120,7 +125,8 @@ export default new class {
|
|||||||
untagged,
|
untagged,
|
||||||
total: tagged + untagged,
|
total: tagged + untagged,
|
||||||
deleted,
|
deleted,
|
||||||
untracked: lastf0ck - (tagged + untagged + deleted),
|
pending,
|
||||||
|
untracked: lastf0ck - (tagged + untagged + deleted + pending),
|
||||||
sfw,
|
sfw,
|
||||||
nsfw,
|
nsfw,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
getf0cks: async (o = { user, tag, mime, page, mode, fav, session, limit }) => {
|
getf0cks: async (o = { user, tag, mime, page, mode, fav, session, limit }) => {
|
||||||
const user = o.user ? decodeURI(o.user) : null;
|
const user = o.user ? decodeURI(o.user) : null;
|
||||||
const tag = lib.parseTag(o.tag ?? null);
|
const tag = lib.parseTag(o.tag ?? null);
|
||||||
const mime = o.mime ?? null;
|
const mime = o.mime ?? null;
|
||||||
const page = +(o.page ?? 1);
|
const page = +(o.page ?? 1);
|
||||||
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
||||||
const eps = o.limit ?? cfg.websrv.eps;
|
const eps = o.limit ?? cfg.websrv.eps;
|
||||||
|
|
||||||
const tmp = { user, tag, mime, smime, page, mode: o.mode };
|
const tmp = { user, tag, mime, smime, page, mode: o.mode };
|
||||||
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
|
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
|
||||||
@@ -27,17 +27,17 @@ export default {
|
|||||||
left join favorites on favorites.item_id = items.id
|
left join favorites on favorites.item_id = items.id
|
||||||
left join "user" on "user".id = favorites.user_id
|
left join "user" on "user".id = favorites.user_id
|
||||||
where
|
where
|
||||||
${ db.unsafe(modequery) }
|
${db.unsafe(modequery)}
|
||||||
and items.active = 'true'
|
and items.active = 'true'
|
||||||
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` }
|
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||||
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` }
|
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
|
||||||
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` }
|
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
|
||||||
${ mime ? db`and items.mime ilike ${smime}` : db`` }
|
${mime ? db`and items.mime ilike ${smime}` : db``}
|
||||||
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` }
|
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||||
group by items.id, tags.tag
|
group by items.id, tags.tag
|
||||||
`)?.length || 0;
|
`)?.length || 0;
|
||||||
|
|
||||||
if(!total || total === 0) {
|
if (!total || total === 0) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "404 - no f0cks given"
|
message: "404 - no f0cks given"
|
||||||
@@ -61,13 +61,13 @@ export default {
|
|||||||
left join "user" on "user".id = favorites.user_id
|
left join "user" on "user".id = favorites.user_id
|
||||||
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
|
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
|
||||||
where
|
where
|
||||||
${ db.unsafe(modequery) }
|
${db.unsafe(modequery)}
|
||||||
and items.active = 'true'
|
and items.active = 'true'
|
||||||
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` }
|
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||||
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` }
|
${o.fav ? db`and "user".user ilike ${'%' + user + '%'}` : db``}
|
||||||
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` }
|
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
|
||||||
${ mime ? db`and items.mime ilike ${smime}` : db`` }
|
${mime ? db`and items.mime ilike ${smime}` : db``}
|
||||||
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` }
|
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||||
group by items.id, tags.tag, ta.tag_id
|
group by items.id, tags.tag, ta.tag_id
|
||||||
order by items.id desc
|
order by items.id desc
|
||||||
offset ${offset}
|
offset ${offset}
|
||||||
@@ -75,7 +75,7 @@ export default {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const cheat = [];
|
const cheat = [];
|
||||||
for(let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
|
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
|
||||||
cheat.push(i);
|
cheat.push(i);
|
||||||
|
|
||||||
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: 'p/' });
|
const link = lib.genLink({ user, tag, mime, type: o.fav ? 'favs' : 'f0cks', path: 'p/' });
|
||||||
@@ -96,17 +96,17 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
getf0ck: async (o = ({ user, tag, mime, itemid, mode, session })) => {
|
getf0ck: async (o = ({ user, tag, mime, itemid, mode, session })) => {
|
||||||
const user = o.user ? decodeURI(o.user) : null;
|
const user = o.user ? decodeURI(o.user) : null;
|
||||||
const tag = lib.parseTag(o.tag ?? null);
|
const tag = lib.parseTag(o.tag ?? null);
|
||||||
const mime = (o.mime ?? "");
|
const mime = (o.mime ?? "");
|
||||||
const itemid = +(o.itemid ?? 404);
|
const itemid = +(o.itemid ?? 404);
|
||||||
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
const smime = cfg.allowedMimes.includes(mime) ? mime + "/%" : mime === "" ? "%" : "%";
|
||||||
|
|
||||||
const tmp = { user, tag, mime, smime, itemid };
|
const tmp = { user, tag, mime, smime, itemid };
|
||||||
|
|
||||||
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
|
const modequery = mime == "audio" ? lib.getMode(0) : lib.getMode(o.mode ?? 0);
|
||||||
|
|
||||||
if(itemid === 404) {
|
if (itemid === 404) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "404 - f0ck not found"
|
message: "404 - f0ck not found"
|
||||||
@@ -119,25 +119,32 @@ export default {
|
|||||||
from items
|
from items
|
||||||
left join tags_assign on tags_assign.item_id = items.id
|
left join tags_assign on tags_assign.item_id = items.id
|
||||||
left join tags on tags.id = tags_assign.tag_id
|
left join tags on tags.id = tags_assign.tag_id
|
||||||
left join favorites on favorites.item_id = items.id
|
${o.fav
|
||||||
left join "user" on "user".id = favorites.user_id
|
? db`inner join favorites on favorites.item_id = items.id inner join "user" on "user".id = favorites.user_id`
|
||||||
|
: db`left join favorites on favorites.item_id = items.id left join "user" on "user".id = favorites.user_id`
|
||||||
|
}
|
||||||
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
|
left join tags_assign ta on ta.item_id = items.id and (ta.tag_id = 1 or ta.tag_id = 2)
|
||||||
where
|
where
|
||||||
${ db.unsafe(modequery) }
|
${db.unsafe(modequery)}
|
||||||
and items.active = 'true'
|
and items.active = 'true'
|
||||||
${ tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db`` }
|
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||||
${ o.fav ? db`and "user".user ilike ${'%'+user+'%'}` : db`` }
|
${o.fav ? db`and "user"."user" ilike ${user}` : db``}
|
||||||
${ !o.fav && user ? db`and items.username ilike ${'%'+user+'%'}` : db`` }
|
${!o.fav && user ? db`and items.username ilike ${'%' + user + '%'}` : db``}
|
||||||
${ mime ? db`and items.mime ilike ${smime}` : db`` }
|
${mime ? db`and items.mime ilike ${smime}` : db``}
|
||||||
${ !o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db`` }
|
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||||
group by items.id, tags.tag, ta.tag_id
|
group by items.id, tags.tag, ta.tag_id
|
||||||
order by items.id desc
|
order by items.id desc
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
console.log('[GETF0CK DEBUG] Query params:', { user, itemid, fav: o.fav });
|
||||||
|
console.log('[GETF0CK DEBUG] Items found:', items.length, 'Item IDs:', items.slice(0, 10).map(i => i.id));
|
||||||
|
|
||||||
const item = items.findIndex(i => i.id === itemid);
|
const item = items.findIndex(i => i.id === itemid);
|
||||||
const actitem = items[item];
|
const actitem = items[item];
|
||||||
|
|
||||||
if(!actitem) { // sfw-check!
|
console.log('[GETF0CK DEBUG] findIndex result:', item, 'actitem exists:', !!actitem);
|
||||||
|
|
||||||
|
if (!actitem) { // sfw-check!
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "Sorry, this post is currently not visible."
|
message: "Sorry, this post is currently not visible."
|
||||||
@@ -158,7 +165,7 @@ export default {
|
|||||||
let coverart = true;
|
let coverart = true;
|
||||||
try {
|
try {
|
||||||
await fs.promises.access(`./public${cfg.websrv.paths.coverarts}/${actitem.id}.webp`);
|
await fs.promises.access(`./public${cfg.websrv.paths.coverarts}/${actitem.id}.webp`);
|
||||||
} catch(err) {
|
} catch (err) {
|
||||||
coverart = false;
|
coverart = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +173,7 @@ export default {
|
|||||||
success: true,
|
success: true,
|
||||||
user: {
|
user: {
|
||||||
name: actitem.username,
|
name: actitem.username,
|
||||||
channel: actitem.usernetwork == "Telegram" && actitem.userchannel !== "w0bm.com" ? "anonymous" : actitem.userchannel,
|
channel: actitem.usernetwork == "Telegram" && actitem.userchannel !== cfg.websrv.domain ? "anonymous" : actitem.userchannel,
|
||||||
network: actitem.usernetwork
|
network: actitem.usernetwork
|
||||||
},
|
},
|
||||||
item: {
|
item: {
|
||||||
@@ -175,7 +182,7 @@ export default {
|
|||||||
long: actitem.src,
|
long: actitem.src,
|
||||||
short: url.parse(actitem.src).hostname,
|
short: url.parse(actitem.src).hostname,
|
||||||
},
|
},
|
||||||
thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}.png`,
|
thumbnail: `${cfg.websrv.paths.thumbnails}/${actitem.id}.webp`,
|
||||||
coverart: coverart ? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp` : '/s/img/music.webp',
|
coverart: coverart ? `${cfg.websrv.paths.coverarts}/${actitem.id}.webp` : '/s/img/music.webp',
|
||||||
dest: `${cfg.websrv.paths.images}/${actitem.dest}`,
|
dest: `${cfg.websrv.paths.images}/${actitem.dest}`,
|
||||||
mime: actitem.mime,
|
mime: actitem.mime,
|
||||||
@@ -187,7 +194,7 @@ export default {
|
|||||||
favorites: favorites,
|
favorites: favorites,
|
||||||
tags: tags
|
tags: tags
|
||||||
},
|
},
|
||||||
title: `${actitem.id} - w0bm.com`,
|
title: `${actitem.id} - ${cfg.websrv.domain}`,
|
||||||
pagination: {
|
pagination: {
|
||||||
end: items[items.length - 1]?.id,
|
end: items[items.length - 1]?.id,
|
||||||
start: items[0]?.id,
|
start: items[0]?.id,
|
||||||
@@ -201,7 +208,7 @@ export default {
|
|||||||
tmp
|
tmp
|
||||||
};
|
};
|
||||||
return data;
|
return data;
|
||||||
},getRandom: async (o = ({ user, tag, mime, mode, fav, session })) => {
|
}, getRandom: async (o = ({ user, tag, mime, mode, fav, session })) => {
|
||||||
const user = o.user ? decodeURI(o.user) : null;
|
const user = o.user ? decodeURI(o.user) : null;
|
||||||
const tag = lib.parseTag(o.tag ?? null);
|
const tag = lib.parseTag(o.tag ?? null);
|
||||||
const mime = (o.mime ?? "");
|
const mime = (o.mime ?? "");
|
||||||
@@ -219,10 +226,15 @@ export default {
|
|||||||
from favorites
|
from favorites
|
||||||
inner join items on favorites.item_id = items.id
|
inner join items on favorites.item_id = items.id
|
||||||
inner join "user" on "user".id = favorites.user_id
|
inner join "user" on "user".id = favorites.user_id
|
||||||
|
left join tags_assign on tags_assign.item_id = items.id
|
||||||
|
left join tags on tags.id = tags_assign.tag_id
|
||||||
where
|
where
|
||||||
"user".user ilike ${'%' + user + '%'}
|
${db.unsafe(modequery)}
|
||||||
|
and "user".user ilike ${'%' + user + '%'}
|
||||||
and items.active = 'true'
|
and items.active = 'true'
|
||||||
${mime ? db`and items.mime ilike ${smime}` : db``}
|
${mime ? db`and items.mime ilike ${smime}` : db``}
|
||||||
|
${!o.session && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||||
|
group by items.id
|
||||||
order by random()
|
order by random()
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
@@ -262,4 +274,4 @@ export default {
|
|||||||
itemid: item[0].id
|
itemid: item[0].id
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import db from "../sql.mjs";
|
|||||||
import lib from "../lib.mjs";
|
import lib from "../lib.mjs";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
|
import cfg from "../config.mjs";
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||||
if(req.cookies.session) {
|
if (req.cookies.session) {
|
||||||
return res.reply({
|
return res.reply({
|
||||||
body: tpl.render('error', {
|
body: tpl.render('error', {
|
||||||
message: "you're already logged in lol",
|
message: "you're already logged in lol",
|
||||||
@@ -25,9 +26,9 @@ export default (router, tpl) => {
|
|||||||
where "login" = ${req.post.username.toLowerCase()}
|
where "login" = ${req.post.username.toLowerCase()}
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
if(user.length === 0)
|
if (user.length === 0)
|
||||||
return res.reply({ body: "user doesn't exist or wrong password" });
|
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" });
|
return res.reply({ body: "user doesn't exist or wrong password" });
|
||||||
const stamp = ~~(Date.now() / 1e3);
|
const stamp = ~~(Date.now() / 1e3);
|
||||||
|
|
||||||
@@ -49,8 +50,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')
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ export default (router, tpl) => {
|
|||||||
from "user_sessions"
|
from "user_sessions"
|
||||||
where id = ${+req.session.sess_id}
|
where id = ${+req.session.sess_id}
|
||||||
`;
|
`;
|
||||||
if(usersession.length === 0)
|
if (usersession.length === 0)
|
||||||
return res.reply({ body: "nope 2" });
|
return res.reply({ body: "nope 2" });
|
||||||
|
|
||||||
await db`
|
await db`
|
||||||
@@ -121,79 +121,237 @@ 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', is_deleted = false 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 page = +req.url.qs.page || 1;
|
||||||
// body: `f0ck ${id} recovered. <a href="/admin/recover">back</a>`
|
const limit = 50;
|
||||||
// });
|
const offset = (page - 1) * limit;
|
||||||
// }
|
|
||||||
|
|
||||||
// const _posts = await db`
|
const total = (await db`select count(*) as c from "items" where active = 'false'`)[0].c;
|
||||||
// select id, mime, username
|
const pages = Math.ceil(total / limit);
|
||||||
// from "items"
|
|
||||||
// where
|
|
||||||
// active = 'false'
|
|
||||||
// order by id desc
|
|
||||||
// `;
|
|
||||||
|
|
||||||
// if(_posts.length === 0) {
|
// Fetch Pending (not deleted)
|
||||||
// return res.reply({
|
const pending = await db`
|
||||||
// body: 'blah'
|
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 = false
|
||||||
|
group by i.id
|
||||||
|
order by i.id desc
|
||||||
|
`;
|
||||||
|
|
||||||
// const posts = await Promise.all(_posts.map(async p => ({
|
// Fetch Trash (deleted)
|
||||||
// ...p,
|
const trash = await db`
|
||||||
// thumbnail: (await fs.readFile(`./deleted/t/${p.id}.webp`)).toString('base64')
|
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
|
||||||
|
`;
|
||||||
|
|
||||||
// res.reply({
|
// Helper to process thumbnails
|
||||||
// body: tpl.render('admin/recover', {
|
const processItems = async (items, isInTrash) => {
|
||||||
// posts,
|
return Promise.all(items.map(async p => {
|
||||||
// tmp: null
|
let thumb = '';
|
||||||
// }, req)
|
const path = isInTrash ? 'deleted' : 'public';
|
||||||
// });
|
try {
|
||||||
// });
|
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', {
|
||||||
|
pending: pendingProcessed,
|
||||||
|
trash: trashProcessed,
|
||||||
|
page,
|
||||||
|
pages,
|
||||||
|
stats: { total: pending.length + trash.length },
|
||||||
|
tmp: null
|
||||||
|
}, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteItem = async (id) => {
|
||||||
|
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 {
|
||||||
|
// Fix FK constraint: Check if this item is used as an avatar
|
||||||
|
try {
|
||||||
|
const fallback = await db`select id from items where active = true limit 1`;
|
||||||
|
if (fallback.length > 0) {
|
||||||
|
const safeId = fallback[0].id;
|
||||||
|
await db`update "user_options" set avatar = ${safeId} where avatar = ${id}`;
|
||||||
|
}
|
||||||
|
} catch (fkErr) {
|
||||||
|
console.error('[ADMIN DENY FK FIX ERROR]', fkErr);
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
|
return true;
|
||||||
|
} catch (dbErr) {
|
||||||
|
console.error('[ADMIN DENY DB ERROR]', dbErr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[ADMIN DENY] Item not found in DB');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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}`);
|
||||||
|
await deleteItem(id);
|
||||||
|
return res.reply({ success: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[ADMIN DENY] No ID provided');
|
||||||
|
return res.reply({ success: false, msg: "No ID provided" });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(/^\/admin\/deny-multi\/?/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ids = req.post.ids;
|
||||||
|
if (!Array.isArray(ids)) throw new Error('ids must be an array');
|
||||||
|
|
||||||
|
console.log(`[ADMIN DENY MULTI] Denying ${ids.length} items`);
|
||||||
|
for (const id of ids) {
|
||||||
|
await deleteItem(+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.reply({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[ADMIN DENY MULTI ERROR]', err);
|
||||||
|
return res.reply({ success: false, msg: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token Routes
|
||||||
|
router.get(/^\/admin\/tokens\/?$/, lib.auth, async (req, res) => {
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render("admin/tokens", { session: req.session, tmp: null }, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get(/^\/api\/v2\/admin\/tokens\/?$/, lib.auth, async (req, res) => {
|
||||||
|
const tokens = await db`
|
||||||
|
select invite_tokens.*, "user".user as used_by_name
|
||||||
|
from invite_tokens
|
||||||
|
left join "user" on "user".id = invite_tokens.used_by
|
||||||
|
order by created_at desc
|
||||||
|
`;
|
||||||
|
if (res.json) {
|
||||||
|
return res.json({ success: true, tokens });
|
||||||
|
}
|
||||||
|
// Fallback if res.json is not available
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, tokens }));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(/^\/api\/v2\/admin\/tokens\/create\/?$/, lib.auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const secret = cfg.main.invite_secret || 'defaultsecret';
|
||||||
|
const token = lib.md5(lib.createID() + secret).substring(0, 10).toUpperCase(); // Short readable token
|
||||||
|
await db`
|
||||||
|
insert into invite_tokens (token, created_at, created_by)
|
||||||
|
values (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id})
|
||||||
|
`;
|
||||||
|
if (res.json) return res.json({ success: true, token });
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token }));
|
||||||
|
} catch (err) {
|
||||||
|
if (res.json) return res.json({ success: false, msg: err.message });
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(/^\/api\/v2\/admin\/tokens\/delete\/?$/, lib.auth, async (req, res) => {
|
||||||
|
if (!req.post.id) {
|
||||||
|
if (res.json) return res.json({ success: false });
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
|
||||||
|
}
|
||||||
|
await db`delete from invite_tokens where id = ${req.post.id}`;
|
||||||
|
if (res.json) return res.json({ success: true });
|
||||||
|
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let contextUrl = `/${req.params.itemid}`;
|
let contextUrl = `/${req.params.itemid}`;
|
||||||
if (query.tag) contextUrl = `/tag/${query.tag}/${req.params.itemid}`;
|
if (query.tag) contextUrl = `/tag/${encodeURIComponent(query.tag)}/${req.params.itemid}`;
|
||||||
if (query.user) contextUrl = `/user/${query.user}/${req.params.itemid}`; // User filter takes precedence if both? usually mutually exclusive
|
if (query.user) {
|
||||||
|
contextUrl = query.fav === 'true'
|
||||||
|
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
|
||||||
|
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[AJAX DEBUG] Params:', { itemid: req.params.itemid, user: query.user, fav: query.fav, contextUrl });
|
||||||
|
|
||||||
const data = await f0cklib.getf0ck({
|
const data = await f0cklib.getf0ck({
|
||||||
itemid: req.params.itemid,
|
itemid: req.params.itemid,
|
||||||
@@ -27,6 +33,8 @@ export default (router, tpl) => {
|
|||||||
fav: query.fav === 'true'
|
fav: query.fav === 'true'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[AJAX DEBUG] getf0ck result:', { success: data.success, message: data.message });
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
return res.reply({
|
return res.reply({
|
||||||
code: 404,
|
code: 404,
|
||||||
@@ -46,8 +54,9 @@ export default (router, tpl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inject missing variables normally provided by req or middleware
|
// Inject missing variables normally provided by req or middleware
|
||||||
data.url = { pathname: `/${req.params.itemid}` }; // Template expects url.pathname
|
data.url = { pathname: contextUrl }; // Template expects url.pathname
|
||||||
data.fullscreen = req.cookies.fullscreen || 0; // Index.mjs uses req.cookies.fullscreen
|
data.fullscreen = req.cookies.fullscreen || 0; // Index.mjs uses req.cookies.fullscreen
|
||||||
|
data.hidePagination = true;
|
||||||
|
|
||||||
// Render both the item content and the pagination
|
// Render both the item content and the pagination
|
||||||
const itemHtml = tpl.render('ajax-item', data);
|
const itemHtml = tpl.render('ajax-item', data);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { promises as fs } from "fs";
|
import { promises as fs } from "fs";
|
||||||
import db from '../../sql.mjs';
|
import db from '../../sql.mjs';
|
||||||
import lib from '../../lib.mjs';
|
import lib from '../../lib.mjs';
|
||||||
|
import cfg from '../../config.mjs';
|
||||||
import search from '../../routeinc/search.mjs';
|
import search from '../../routeinc/search.mjs';
|
||||||
|
|
||||||
const allowedMimes = ["audio", "image", "video", "%"];
|
const allowedMimes = ["audio", "image", "video", "%"];
|
||||||
|
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||||
|
|
||||||
export default router => {
|
export default router => {
|
||||||
router.group(/^\/api\/v2/, group => {
|
router.group(/^\/api\/v2/, group => {
|
||||||
group.get(/$/, (req, res) => {
|
group.get(/$/, (req, res) => {
|
||||||
@@ -11,20 +14,33 @@ export default router => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => {
|
group.get(/\/random(\/user\/.+|\/image|\/video|\/audio)?$/, async (req, res) => {
|
||||||
const user = req.url.split[3] === "user" ? req.url.split[4] : "%";
|
const pathUser = req.url.split[3] === "user" ? req.url.split[4] : null;
|
||||||
const mime = (allowedMimes.filter(n => req.url.split[3]?.startsWith(n))[0] ? req.url.split[3] : "") + "%";
|
const user = req.url.qs.user || pathUser || "%";
|
||||||
|
|
||||||
|
const pathMime = allowedMimes.filter(n => req.url.split[3]?.startsWith(n))[0] ? req.url.split[3] : "";
|
||||||
|
const mime = (req.url.qs.mime || pathMime) + "%";
|
||||||
|
|
||||||
const tag = req.url.qs.tag || null;
|
const tag = req.url.qs.tag || null;
|
||||||
|
const isFav = req.url.qs.fav === 'true';
|
||||||
|
const hasSession = !!req.session;
|
||||||
|
const modequery = mime.startsWith("audio") ? lib.getMode(0) : lib.getMode(req.session?.mode ?? 0);
|
||||||
|
|
||||||
const rows = await db`
|
const rows = await db`
|
||||||
select "items".*
|
select "items".*
|
||||||
from "items"
|
from "items"
|
||||||
|
${isFav
|
||||||
|
? db`join "favorites" on "favorites".item_id = "items".id join "user" as fu on fu.id = "favorites".user_id`
|
||||||
|
: db``
|
||||||
|
}
|
||||||
left join tags_assign on tags_assign.item_id = items.id
|
left join tags_assign on tags_assign.item_id = items.id
|
||||||
left join tags on tags.id = tags_assign.tag_id
|
left join tags on tags.id = tags_assign.tag_id
|
||||||
where
|
where
|
||||||
|
${db.unsafe(modequery)} and
|
||||||
mime ilike ${mime} and
|
mime ilike ${mime} and
|
||||||
username ilike ${user} and
|
|
||||||
active = 'true'
|
active = 'true'
|
||||||
${tag ? db`and tags.normalized ilike ${'%' + tag + '%'}` : db``}
|
${isFav ? db`and fu."user" = ${user}` : db`and items.username ilike ${user}`}
|
||||||
|
${tag ? db`and tags.normalized ilike '%' || slugify(${tag}) || '%'` : db``}
|
||||||
|
${!hasSession && globalfilter ? db`and items.id not in (select item_id from tags_assign where item_id = items.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||||
order by random()
|
order by random()
|
||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
@@ -251,7 +267,7 @@ export default router => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { });
|
await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).catch(_ => { });
|
||||||
|
|||||||
260
src/inc/routes/apiv2/upload.mjs
Normal file
260
src/inc/routes/apiv2/upload.mjs
Normal 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;
|
||||||
|
};
|
||||||
214
src/inc/routes/comments.mjs
Normal file
214
src/inc/routes/comments.mjs
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import db from "../sql.mjs";
|
||||||
|
import f0cklib from "../routeinc/f0cklib.mjs"; // Assuming this exists or we need to check imports
|
||||||
|
|
||||||
|
export default (router, tpl) => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Fetch comments for an item
|
||||||
|
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
|
||||||
|
const itemId = req.params.itemid;
|
||||||
|
const sort = req.url.qs?.sort || 'new'; // 'new' or 'old'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comments = await db`
|
||||||
|
SELECT
|
||||||
|
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||||
|
u.user as username, u.id as user_id, uo.avatar,
|
||||||
|
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
|
||||||
|
FROM comments c
|
||||||
|
JOIN "user" u ON c.user_id = u.id
|
||||||
|
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||||
|
WHERE c.item_id = ${itemId}
|
||||||
|
ORDER BY c.created_at ${db.unsafe(sort === 'new' ? 'DESC' : 'ASC')}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let is_subscribed = false;
|
||||||
|
if (req.session) {
|
||||||
|
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
|
||||||
|
if (sub.length > 0) is_subscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform for frontend if needed, or send as is
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
comments,
|
||||||
|
is_subscribed,
|
||||||
|
user_id: req.session ? req.session.user : null,
|
||||||
|
is_admin: req.session ? req.session.admin : false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.reply({
|
||||||
|
code: 500,
|
||||||
|
body: JSON.stringify({ success: false, message: "Database error" })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Post a comment
|
||||||
|
router.post('/api/comments', async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
|
||||||
|
|
||||||
|
console.log("DEBUG: POST /api/comments");
|
||||||
|
|
||||||
|
// Use standard framework parsing
|
||||||
|
const body = req.post || {};
|
||||||
|
const item_id = parseInt(body.item_id, 10);
|
||||||
|
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
|
||||||
|
const content = body.content;
|
||||||
|
|
||||||
|
console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
|
||||||
|
|
||||||
|
if (!content || !content.trim()) {
|
||||||
|
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newComment = await db`
|
||||||
|
INSERT INTO comments ${db({
|
||||||
|
item_id,
|
||||||
|
user_id: req.session.id,
|
||||||
|
parent_id: parent_id || null,
|
||||||
|
content: content
|
||||||
|
})}
|
||||||
|
RETURNING id, created_at
|
||||||
|
`;
|
||||||
|
|
||||||
|
const commentId = parseInt(newComment[0].id, 10);
|
||||||
|
|
||||||
|
// Notify Subscribers (excluding the author)
|
||||||
|
// 1. Get subscribers of the item
|
||||||
|
// 2. If it's a reply, notify parent author? (Optional, complex logic. Let's stick to item subscription for now + Parent author)
|
||||||
|
|
||||||
|
// Logic: Notify users who subscribed to this item OR are the parent author.
|
||||||
|
// Exclude current user.
|
||||||
|
|
||||||
|
// 1. Get subscribers
|
||||||
|
const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id}`;
|
||||||
|
|
||||||
|
// 2. Get parent author
|
||||||
|
let parentAuthor = [];
|
||||||
|
if (parent_id) {
|
||||||
|
parentAuthor = await db`SELECT user_id FROM comments WHERE id = ${parent_id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Collect unique recipients
|
||||||
|
const recipients = new Set();
|
||||||
|
subscribers.forEach(s => recipients.add(s.user_id));
|
||||||
|
parentAuthor.forEach(p => recipients.add(p.user_id));
|
||||||
|
|
||||||
|
// Remove self
|
||||||
|
recipients.delete(req.session.id);
|
||||||
|
|
||||||
|
// 4. Batch insert
|
||||||
|
if (recipients.size > 0) {
|
||||||
|
const notificationsToAdd = Array.from(recipients).map(uid => ({
|
||||||
|
user_id: uid,
|
||||||
|
type: 'comment_reply',
|
||||||
|
item_id: item_id,
|
||||||
|
reference_id: commentId
|
||||||
|
}));
|
||||||
|
|
||||||
|
await db`INSERT INTO notifications ${db(notificationsToAdd)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, comment: newComment[0] })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.reply({
|
||||||
|
code: 500,
|
||||||
|
body: JSON.stringify({ success: false, message: "Database error" })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe toggle
|
||||||
|
router.post(/\/api\/subscribe\/(?<itemid>\d+)/, async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
const itemId = req.params.itemid;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await db`
|
||||||
|
SELECT 1 FROM comment_subscriptions
|
||||||
|
WHERE user_id = ${req.session.id} AND item_id = ${itemId}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let subscribed = false;
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db`DELETE FROM comment_subscriptions WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
|
||||||
|
} else {
|
||||||
|
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemId})`;
|
||||||
|
subscribed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, subscribed })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete comment
|
||||||
|
router.post(/\/api\/comments\/(?<id>\d+)\/delete/, async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
const commentId = req.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comment = await db`SELECT user_id FROM comments WHERE id = ${commentId}`;
|
||||||
|
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
||||||
|
|
||||||
|
if (!req.session.admin && comment[0].user_id !== req.session.id) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`;
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit comment (admin only)
|
||||||
|
router.post(/\/api\/comments\/(?<id>\d+)\/edit/, async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
if (!req.session.admin) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Admin only" }) });
|
||||||
|
|
||||||
|
const commentId = req.params.id;
|
||||||
|
const body = req.post || {};
|
||||||
|
const content = body.content;
|
||||||
|
|
||||||
|
if (!content || !content.trim()) {
|
||||||
|
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comment = await db`SELECT id FROM comments WHERE id = ${commentId}`;
|
||||||
|
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
||||||
|
|
||||||
|
await db`UPDATE comments SET content = ${content}, updated_at = NOW() WHERE id = ${commentId}`;
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
85
src/inc/routes/emojis.mjs
Normal file
85
src/inc/routes/emojis.mjs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import db from "../sql.mjs";
|
||||||
|
|
||||||
|
import lib from "../lib.mjs";
|
||||||
|
|
||||||
|
export default (router, tpl) => {
|
||||||
|
|
||||||
|
// Admin View
|
||||||
|
router.get(/^\/admin\/emojis\/?$/, lib.auth, async (req, res) => {
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render("admin/emojis", { session: req.session, tmp: null }, req)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// List all emojis (Public)
|
||||||
|
router.get('/api/v2/emojis', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`;
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, emojis })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add emoji (Admin only)
|
||||||
|
router.post('/api/v2/admin/emojis', async (req, res) => {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.post || {};
|
||||||
|
const name = body.name ? body.name.trim().toLowerCase() : '';
|
||||||
|
const url = body.url ? body.url.trim() : '';
|
||||||
|
|
||||||
|
if (!name || !url) {
|
||||||
|
return res.reply({ body: JSON.stringify({ success: false, message: "Name and URL required" }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic name validation (alphanumeric)
|
||||||
|
if (!/^[a-z0-9_]+$/.test(name)) {
|
||||||
|
return res.reply({ body: JSON.stringify({ success: false, message: "Invalid name. Use lowercase a-z, 0-9, _ only." }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newEmoji = await db`
|
||||||
|
INSERT INTO custom_emojis (name, url) VALUES (${name}, ${url})
|
||||||
|
RETURNING id, name, url
|
||||||
|
`;
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, emoji: newEmoji[0] })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e.code === '23505') { // Unique violation
|
||||||
|
return res.reply({ body: JSON.stringify({ success: false, message: "Emoji name already exists" }) });
|
||||||
|
}
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete emoji (Admin only)
|
||||||
|
router.post(/\/api\/v2\/admin\/emojis\/(?<id>\d+)\/delete/, async (req, res) => {
|
||||||
|
if (!req.session || !req.session.admin) {
|
||||||
|
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||||
|
}
|
||||||
|
const id = req.params.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db`DELETE FROM custom_emojis WHERE id = ${id}`;
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true })
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import lib from "../lib.mjs";
|
|||||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||||
|
|
||||||
const auth = async (req, res, next) => {
|
const auth = async (req, res, next) => {
|
||||||
if(!req.session)
|
if (!req.session)
|
||||||
return res.redirect("/login");
|
return res.redirect("/login");
|
||||||
return next();
|
return next();
|
||||||
};
|
};
|
||||||
@@ -21,7 +21,7 @@ export default (router, tpl) => {
|
|||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if(!query.length) {
|
if (!query.length) {
|
||||||
return res.reply({
|
return res.reply({
|
||||||
code: 404,
|
code: 404,
|
||||||
body: tpl.render('error', {
|
body: tpl.render('error', {
|
||||||
@@ -44,11 +44,11 @@ export default (router, tpl) => {
|
|||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
limit: 99999999
|
limit: 99999999
|
||||||
});
|
});
|
||||||
if('items' in f0cks) {
|
if ('items' in f0cks) {
|
||||||
count.f0cks = f0cks.items.length;
|
count.f0cks = f0cks.items.length;
|
||||||
f0cks.items = f0cks.items.slice(0, 50);
|
f0cks.items = f0cks.items.slice(0, 50);
|
||||||
}
|
}
|
||||||
} catch(err) {
|
} catch (err) {
|
||||||
f0cks = false;
|
f0cks = false;
|
||||||
count.f0cks = 0;
|
count.f0cks = 0;
|
||||||
}
|
}
|
||||||
@@ -60,11 +60,11 @@ export default (router, tpl) => {
|
|||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
limit: 99999999
|
limit: 99999999
|
||||||
});
|
});
|
||||||
if('items' in favs) {
|
if ('items' in favs) {
|
||||||
count.favs = favs.items.length;
|
count.favs = favs.items.length;
|
||||||
favs.items = favs.items.slice(0, 50);
|
favs.items = favs.items.slice(0, 50);
|
||||||
}
|
}
|
||||||
} catch(err) {
|
} catch (err) {
|
||||||
favs = false;
|
favs = false;
|
||||||
count.favs = 0;
|
count.favs = 0;
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ export default (router, tpl) => {
|
|||||||
session: !!req.session,
|
session: !!req.session,
|
||||||
url: req.url.pathname
|
url: req.url.pathname
|
||||||
});
|
});
|
||||||
if(!data.success) {
|
if (!data.success) {
|
||||||
return res.reply({
|
return res.reply({
|
||||||
code: 404,
|
code: 404,
|
||||||
body: tpl.render('error', {
|
body: tpl.render('error', {
|
||||||
@@ -103,6 +103,10 @@ export default (router, tpl) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'item') {
|
||||||
|
data.hidePagination = true;
|
||||||
|
}
|
||||||
|
|
||||||
return res.reply({ body: tpl.render(mode, data, req) });
|
return res.reply({ body: tpl.render(mode, data, req) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,10 +127,10 @@ export default (router, tpl) => {
|
|||||||
let referertmp = req.headers.referer;
|
let referertmp = req.headers.referer;
|
||||||
let referer = "";
|
let referer = "";
|
||||||
|
|
||||||
if(referertmp?.match(/f0ck\.me/))
|
if (referertmp?.match(/f0ck\.me/))
|
||||||
referer = referertmp.split("/").slice(3).join("/");
|
referer = referertmp.split("/").slice(3).join("/");
|
||||||
|
|
||||||
if(cfg.allowedModes[mode]) {
|
if (cfg.allowedModes[mode]) {
|
||||||
const blah = {
|
const blah = {
|
||||||
user_id: req.session.id,
|
user_id: req.session.id,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
@@ -134,8 +138,7 @@ export default (router, tpl) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await db`
|
await db`
|
||||||
insert into "user_options" ${
|
insert into "user_options" ${db(blah, 'user_id', 'mode', 'theme')
|
||||||
db(blah, 'user_id', 'mode', 'theme')
|
|
||||||
}
|
}
|
||||||
on conflict ("user_id") do update set
|
on conflict ("user_id") do update set
|
||||||
mode = excluded.mode,
|
mode = excluded.mode,
|
||||||
|
|||||||
62
src/inc/routes/notifications.mjs
Normal file
62
src/inc/routes/notifications.mjs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import db from "../sql.mjs";
|
||||||
|
|
||||||
|
export default (router, tpl) => {
|
||||||
|
|
||||||
|
// Get unread notifications
|
||||||
|
router.get('/api/notifications', async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const notifications = await db`
|
||||||
|
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read,
|
||||||
|
u.user as from_user, u.id as from_user_id
|
||||||
|
FROM notifications n
|
||||||
|
JOIN comments c ON n.reference_id = c.id
|
||||||
|
JOIN "user" u ON c.user_id = u.id
|
||||||
|
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||||
|
ORDER BY n.created_at DESC
|
||||||
|
LIMIT 20
|
||||||
|
`;
|
||||||
|
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true, notifications })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark all as read
|
||||||
|
router.post('/api/notifications/read', async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark single as read (optional, for clicking)
|
||||||
|
router.post(/\/api\/notifications\/(?<id>\d+)\/read/, async (req, res) => {
|
||||||
|
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||||
|
const id = req.params.id;
|
||||||
|
try {
|
||||||
|
await db`UPDATE notifications SET is_read = true WHERE id = ${id} AND user_id = ${req.session.id}`;
|
||||||
|
return res.reply({
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ success: true })
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
79
src/inc/routes/register.mjs
Normal file
79
src/inc/routes/register.mjs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import db from "../sql.mjs";
|
||||||
|
import lib from "../lib.mjs";
|
||||||
|
|
||||||
|
export default (router, tpl) => {
|
||||||
|
router.get(/^\/register(\/)?$/, async (req, res) => {
|
||||||
|
if (req.cookies.session) {
|
||||||
|
return res.writeHead(302, { "Location": "/" }).end();
|
||||||
|
}
|
||||||
|
res.reply({
|
||||||
|
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck" })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post(/^\/register(\/)?$/, async (req, res) => {
|
||||||
|
const { username, password, password_confirm, token } = req.post;
|
||||||
|
|
||||||
|
const renderError = (msg) => {
|
||||||
|
return res.reply({
|
||||||
|
body: tpl.render("register", { theme: req.cookies.theme ?? "f0ck", error: msg })
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!username || !password || !token) return renderError("All fields are required");
|
||||||
|
if (password !== password_confirm) return renderError("Passwords do not match");
|
||||||
|
if (username.length < 3) return renderError("Username too short");
|
||||||
|
|
||||||
|
// Password complexity check
|
||||||
|
if (password.length < 20) return renderError("Password must be at least 20 characters long");
|
||||||
|
|
||||||
|
// Check token
|
||||||
|
const tokenRow = await db`
|
||||||
|
select * from invite_tokens where token = ${token} and is_used = false
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (tokenRow.length === 0) {
|
||||||
|
return renderError("Invalid or used invite token");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user existence
|
||||||
|
const existing = await db`select id from "user" where "login" = ${username.toLowerCase()}`;
|
||||||
|
if (existing.length > 0) return renderError("Username taken");
|
||||||
|
|
||||||
|
// Create User
|
||||||
|
const hash = await lib.hash(password);
|
||||||
|
const ts = ~~(Date.now() / 1e3);
|
||||||
|
|
||||||
|
// Note: Creating user. Assuming columns based on typical structure.
|
||||||
|
// Need to check 'user' table columns to be safe, but usually: login, password, user (display name), created_at, admin
|
||||||
|
// I'll assume 'user' is display name and 'login' is lowercase
|
||||||
|
|
||||||
|
const newUser = await db`
|
||||||
|
insert into "user" ("login", "password", "user", "created_at", "admin")
|
||||||
|
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false)
|
||||||
|
returning id
|
||||||
|
`;
|
||||||
|
const userId = newUser[0].id;
|
||||||
|
|
||||||
|
// Mark token used
|
||||||
|
await db`
|
||||||
|
update invite_tokens
|
||||||
|
set is_used = true, used_by = ${userId}
|
||||||
|
where id = ${tokenRow[0].id}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get a valid avatar ID (default to 1)
|
||||||
|
const avatarRow = await db`select id from items where id = 1`;
|
||||||
|
const avatarId = avatarRow.length > 0 ? 1 : (await db`select id from items limit 1`)[0].id;
|
||||||
|
|
||||||
|
await db`
|
||||||
|
insert into user_options (user_id, mode, theme, fullscreen, avatar)
|
||||||
|
values (${userId}, 3, 'amoled', 0, ${avatarId})
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Redirect to home with login success message
|
||||||
|
return res.writeHead(302, { "Location": "/?login=success" }).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
};
|
||||||
@@ -2,11 +2,26 @@ import crypto from 'crypto';
|
|||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
router.get(/^\/tag_image\/(?<tag>.+)$/, async (req, res) => {
|
router.get(/^\/tag_image\/(?<tag>.+)$/, async (req, res) => {
|
||||||
const tag = req.params.tag;
|
const tag = decodeURIComponent(req.params.tag);
|
||||||
|
|
||||||
// Create a deterministic hash from the tag
|
// Create a deterministic hash from the tag
|
||||||
const hash = crypto.createHash('md5').update(tag).digest('hex');
|
const hash = crypto.createHash('md5').update(tag).digest('hex');
|
||||||
|
|
||||||
|
// Escape character for SVG
|
||||||
|
const escapeXml = (unsafe) => {
|
||||||
|
return unsafe.replace(/[<>&'"]/g, (c) => {
|
||||||
|
switch (c) {
|
||||||
|
case '<': return '<';
|
||||||
|
case '>': return '>';
|
||||||
|
case '&': return '&';
|
||||||
|
case '\'': return ''';
|
||||||
|
case '"': return '"';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayTag = escapeXml(tag);
|
||||||
|
|
||||||
// Generate colors from hash
|
// Generate colors from hash
|
||||||
const c1 = '#' + hash.substring(0, 6);
|
const c1 = '#' + hash.substring(0, 6);
|
||||||
const c2 = '#' + hash.substring(6, 12);
|
const c2 = '#' + hash.substring(6, 12);
|
||||||
@@ -27,7 +42,7 @@ export default (router, tpl) => {
|
|||||||
<rect width="300" height="150" fill="url(#grad)" />
|
<rect width="300" height="150" fill="url(#grad)" />
|
||||||
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
|
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
|
||||||
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
|
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
|
||||||
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#fff" fill-opacity="0.9" font-weight="bold">${tag}</text>
|
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#fff" fill-opacity="0.9" font-weight="bold">${displayTag}</text>
|
||||||
</svg>
|
</svg>
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,53 @@
|
|||||||
import db from "../../inc/sql.mjs";
|
import db from "../../inc/sql.mjs";
|
||||||
import cfg from "../../inc/config.mjs";
|
import cfg from "../../inc/config.mjs";
|
||||||
|
|
||||||
|
const TAGS_PER_PAGE = 500;
|
||||||
|
|
||||||
export default (router, tpl) => {
|
export default (router, tpl) => {
|
||||||
|
// API endpoint for lazy loading tags
|
||||||
|
router.get(/^\/api\/tags$/, async (req, res) => {
|
||||||
|
const page = Math.max(1, +(req.url.qs?.page ?? 1));
|
||||||
|
const offset = (page - 1) * TAGS_PER_PAGE;
|
||||||
|
const isLoggedIn = !!req.session;
|
||||||
|
|
||||||
|
const nsfp = cfg.nsfp.map(n => `${n}`);
|
||||||
|
|
||||||
|
let tags;
|
||||||
|
if (isLoggedIn) {
|
||||||
|
tags = await db`
|
||||||
|
SELECT t.id, t.tag, COUNT(DISTINCT ta.item_id) AS total_items
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
|
GROUP BY t.id, t.tag
|
||||||
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
|
ORDER BY total_items DESC
|
||||||
|
OFFSET ${offset}
|
||||||
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
tags = await db`
|
||||||
|
SELECT t.id, t.tag, COUNT(DISTINCT ta.item_id) AS total_items
|
||||||
|
FROM tags t
|
||||||
|
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
|
WHERE t.id not in (${db.unsafe(nsfp)})
|
||||||
|
GROUP BY t.id, t.tag
|
||||||
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
|
ORDER BY total_items DESC
|
||||||
|
OFFSET ${offset}
|
||||||
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
tags,
|
||||||
|
page,
|
||||||
|
hasMore: tags.length === TAGS_PER_PAGE
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Main tags page - only load first page
|
||||||
router.get(/^\/tags$/, async (req, res) => {
|
router.get(/^\/tags$/, async (req, res) => {
|
||||||
|
|
||||||
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
|
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
|
||||||
|
|
||||||
const nsfp = cfg.nsfp.map(n => `${n}`);
|
const nsfp = cfg.nsfp.map(n => `${n}`);
|
||||||
|
|
||||||
const toptags = await db`
|
const toptags = await db`
|
||||||
@@ -14,9 +56,9 @@ export default (router, tpl) => {
|
|||||||
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
WHERE t.id not in (${db.unsafe(nsfp)})
|
WHERE t.id not in (${db.unsafe(nsfp)})
|
||||||
GROUP BY t.id, t.tag
|
GROUP BY t.id, t.tag
|
||||||
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
ORDER BY total_items DESC
|
ORDER BY total_items DESC
|
||||||
LIMIT 500
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const toptags_regged = await db`
|
const toptags_regged = await db`
|
||||||
@@ -24,19 +66,26 @@ export default (router, tpl) => {
|
|||||||
FROM tags t
|
FROM tags t
|
||||||
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
LEFT JOIN tags_assign ta ON t.id = ta.tag_id
|
||||||
GROUP BY t.id, t.tag
|
GROUP BY t.id, t.tag
|
||||||
|
HAVING COUNT(DISTINCT ta.item_id) >= 1
|
||||||
ORDER BY total_items DESC
|
ORDER BY total_items DESC
|
||||||
LIMIT 500
|
LIMIT ${TAGS_PER_PAGE}
|
||||||
;
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
res.setHeader('Surrogate-Control', 'no-store');
|
||||||
|
|
||||||
res.reply({
|
res.reply({
|
||||||
body: tpl.render('tags', {
|
body: tpl.render('tags', {
|
||||||
toptags,
|
toptags,
|
||||||
toptags_regged,
|
toptags_regged,
|
||||||
phrase,
|
phrase,
|
||||||
tmp: null
|
tmp: null,
|
||||||
|
hidePagination: true
|
||||||
}, req)
|
}, req)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ export default async bot => {
|
|||||||
f: async e => {
|
f: async e => {
|
||||||
let deleted = [];
|
let deleted = [];
|
||||||
|
|
||||||
for(let id of e.args) {
|
for (let id of e.args) {
|
||||||
id = +id;
|
id = +id;
|
||||||
if(id <= 1)
|
if (id <= 1)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const f0ck = await db`
|
const f0ck = await db`
|
||||||
@@ -26,36 +26,36 @@ export default async bot => {
|
|||||||
`;
|
`;
|
||||||
const level = getLevel(e.user).level;
|
const level = getLevel(e.user).level;
|
||||||
|
|
||||||
if(f0ck.length === 0) {
|
if (f0ck.length === 0) {
|
||||||
await e.reply(`f0ck ${id}: f0ck not found`);
|
await e.reply(`f0ck ${id}: f0ck not found`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if(
|
if (
|
||||||
(f0ck[0].username !== (e.user.nick || e.user.username) ||
|
(f0ck[0].username !== (e.user.nick || e.user.username) ||
|
||||||
f0ck[0].userchannel !== e.channel ||
|
f0ck[0].userchannel !== e.channel ||
|
||||||
f0ck[0].usernetwork !== e.network) &&
|
f0ck[0].usernetwork !== e.network) &&
|
||||||
level < 100
|
level < 100
|
||||||
) {
|
) {
|
||||||
await e.reply(`f0ck ${id}: insufficient permissions`);
|
await e.reply(`f0ck ${id}: insufficient permissions`);
|
||||||
continue;
|
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`);
|
await e.reply(`f0ck ${id}: too late lol`);
|
||||||
continue;
|
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/b/${f0ck[0].dest}`, `./deleted/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.copyFile(`./public/t/${id}.webp`, `./deleted/t/${id}.webp`).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/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.unlink(`./public/t/${id}.webp`).catch(_=>{});
|
await fs.unlink(`./public/t/${id}.webp`).catch(_ => { });
|
||||||
|
|
||||||
if(f0ck[0].mime.startsWith('audio')) {
|
if (f0ck[0].mime.startsWith('audio')) {
|
||||||
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_=>{});
|
await fs.copyFile(`./public/ca/${id}.webp`, `./deleted/ca/${id}.webp`).catch(_ => { });
|
||||||
await fs.unlink(`./public/ca/${id}.webp`).catch(_=>{});
|
await fs.unlink(`./public/ca/${id}.webp`).catch(_ => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted.push(id);
|
deleted.push(id);
|
||||||
@@ -71,9 +71,9 @@ export default async bot => {
|
|||||||
f: async e => {
|
f: async e => {
|
||||||
let recovered = [];
|
let recovered = [];
|
||||||
|
|
||||||
for(let id of e.args) {
|
for (let id of e.args) {
|
||||||
id = +id;
|
id = +id;
|
||||||
if(id <= 1)
|
if (id <= 1)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const f0ck = await db`
|
const f0ck = await db`
|
||||||
@@ -85,19 +85,19 @@ export default async bot => {
|
|||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if(f0ck.length === 0) {
|
if (f0ck.length === 0) {
|
||||||
await e.reply(`f0ck ${id}: f0ck not found`);
|
await e.reply(`f0ck ${id}: f0ck not found`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.copyFile(`./deleted/b/${f0ck[0].dest}`, `./public/b/${f0ck[0].dest}`).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.copyFile(`./deleted/t/${id}.webp`, `./public/t/${id}.webp`).catch(_ => { });
|
||||||
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_=>{});
|
await fs.unlink(`./deleted/b/${f0ck[0].dest}`).catch(_ => { });
|
||||||
await fs.unlink(`./deleted/t/${id}.webp`).catch(_=>{});
|
await fs.unlink(`./deleted/t/${id}.webp`).catch(_ => { });
|
||||||
|
|
||||||
if(f0ck[0].mime.startsWith('audio')) {
|
if (f0ck[0].mime.startsWith('audio')) {
|
||||||
await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_=>{});
|
await fs.copyFile(`./deleted/ca/${id}.webp`, `./public/ca/${id}.webp`).catch(_ => { });
|
||||||
await fs.unlink(`./deleted/ca/${id}.webp`).catch(_=>{});
|
await fs.unlink(`./deleted/ca/${id}.webp`).catch(_ => { });
|
||||||
}
|
}
|
||||||
|
|
||||||
await db`update "items" set active = 'true' where id = ${id}`;
|
await db`update "items" set active = 'true' where id = ${id}`;
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
@@ -19,7 +21,7 @@ process.on('unhandledRejection', err => {
|
|||||||
this.level = args.level || 0;
|
this.level = args.level || 0;
|
||||||
this.name = args.name;
|
this.name = args.name;
|
||||||
this.active = args.hasOwnProperty("active") ? args.active : true;
|
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;
|
this.f = args.f;
|
||||||
},
|
},
|
||||||
bot: await new cuffeo(cfg.clients)
|
bot: await new cuffeo(cfg.clients)
|
||||||
@@ -27,7 +29,7 @@ process.on('unhandledRejection', err => {
|
|||||||
|
|
||||||
console.time("loading");
|
console.time("loading");
|
||||||
const modules = {
|
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"))
|
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}`);
|
console.timeLog("loading", `${dir}/${mod}`);
|
||||||
return res;
|
return res;
|
||||||
}))).flat(2)
|
}))).flat(2)
|
||||||
})))).reduce((a, b) => ({...a, ...b}));
|
})))).reduce((a, b) => ({ ...a, ...b }));
|
||||||
|
|
||||||
blah.events.forEach(event => {
|
blah.events.forEach(event => {
|
||||||
console.timeLog("loading", `registering event > ${event.name}`);
|
console.timeLog("loading", `registering event > ${event.name}`);
|
||||||
@@ -61,15 +63,16 @@ 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;
|
||||||
if(req.url.pathname.match(/^\/(s|b|t|ca)\//))
|
if (req.url.pathname.match(/^\/(s|b|t|ca)\//))
|
||||||
return;
|
return;
|
||||||
req.theme = req.cookies.theme || 'amoled';
|
req.theme = 'amoled';
|
||||||
req.fullscreen = req.cookies.fullscreen || 0;
|
req.fullscreen = req.cookies.fullscreen || 0;
|
||||||
|
|
||||||
if(req.cookies.session) {
|
if (req.cookies.session) {
|
||||||
const user = await db`
|
const user = await db`
|
||||||
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
|
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
|
||||||
from "user_sessions"
|
from "user_sessions"
|
||||||
@@ -79,7 +82,7 @@ process.on('unhandledRejection', err => {
|
|||||||
limit 1
|
limit 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if(user.length === 0) {
|
if (user.length === 0) {
|
||||||
return res.writeHead(307, { // delete session
|
return res.writeHead(307, { // delete session
|
||||||
"Cache-Control": "no-cache, public",
|
"Cache-Control": "no-cache, public",
|
||||||
"Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
"Set-Cookie": "session=; Path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT",
|
||||||
@@ -91,28 +94,31 @@ 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']
|
}, 'last_used', 'last_action', 'browser')
|
||||||
}, 'last_used', 'last_action', 'browser')
|
|
||||||
}
|
}
|
||||||
where id = ${+user[0].sess_id}
|
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.theme = req.cookies.theme;
|
||||||
req.session.fullscreen = req.cookies.fullscreen;
|
req.session.fullscreen = req.cookies.fullscreen;
|
||||||
|
|
||||||
// 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',
|
fullscreen: req.session.fullscreen || 0
|
||||||
fullscreen: req.session.fullscreen || 0
|
}, 'user_id', 'mode', 'theme', 'fullscreen')
|
||||||
}, 'user_id', 'mode', 'theme', 'fullscreen')
|
|
||||||
}
|
}
|
||||||
on conflict ("user_id") do update set
|
on conflict ("user_id") do update set
|
||||||
mode = excluded.mode,
|
mode = excluded.mode,
|
||||||
@@ -123,13 +129,23 @@ 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;
|
||||||
tpl.globals = {
|
tpl.globals = {
|
||||||
lul: cfg.websrv.lul,
|
lul: cfg.websrv.lul,
|
||||||
themes: cfg.websrv.themes,
|
themes: cfg.websrv.themes,
|
||||||
modes: cfg.allowedModes
|
modes: cfg.allowedModes,
|
||||||
|
domain: cfg.websrv.domain || 'w0bm.com'
|
||||||
};
|
};
|
||||||
router.use(tpl);
|
router.use(tpl);
|
||||||
|
|
||||||
|
|||||||
254
src/upload_handler.mjs
Normal file
254
src/upload_handler.mjs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { promises as fs } from "fs";
|
||||||
|
import db from "./inc/sql.mjs";
|
||||||
|
import lib from "./inc/lib.mjs";
|
||||||
|
import cfg from "./inc/config.mjs";
|
||||||
|
import queue from "./inc/queue.mjs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
// Native multipart form data parser
|
||||||
|
const parseMultipart = (buffer, boundary) => {
|
||||||
|
const parts = {};
|
||||||
|
const boundaryBuffer = Buffer.from(`--${boundary}`);
|
||||||
|
const segments = [];
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let idx;
|
||||||
|
|
||||||
|
while ((idx = buffer.indexOf(boundaryBuffer, start)) !== -1) {
|
||||||
|
if (start !== 0) {
|
||||||
|
segments.push(buffer.slice(start, idx - 2)); // -2 for \r\n before boundary
|
||||||
|
}
|
||||||
|
start = idx + boundaryBuffer.length + 2; // +2 for \r\n after boundary
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const headerEnd = segment.indexOf('\r\n\r\n');
|
||||||
|
if (headerEnd === -1) continue;
|
||||||
|
|
||||||
|
const headers = segment.slice(0, headerEnd).toString();
|
||||||
|
const body = segment.slice(headerEnd + 4);
|
||||||
|
|
||||||
|
const nameMatch = headers.match(/name="([^"]+)"/);
|
||||||
|
const filenameMatch = headers.match(/filename="([^"]+)"/);
|
||||||
|
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||||
|
|
||||||
|
if (nameMatch) {
|
||||||
|
const name = nameMatch[1];
|
||||||
|
if (filenameMatch) {
|
||||||
|
parts[name] = {
|
||||||
|
filename: filenameMatch[1],
|
||||||
|
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
|
||||||
|
data: body
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
parts[name] = body.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect request body as buffer
|
||||||
|
const collectBody = (req) => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', chunk => chunks.push(chunk));
|
||||||
|
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||||
|
req.on('error', reject);
|
||||||
|
|
||||||
|
// Ensure stream flows
|
||||||
|
if (req.isPaused()) req.resume();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper for JSON response
|
||||||
|
const sendJson = (res, data, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const handleUpload = async (req, res) => {
|
||||||
|
console.log('[UPLOAD HANDLER] Started');
|
||||||
|
|
||||||
|
// Manual Session Lookup (because flummpress middleware might not have finished)
|
||||||
|
// We assume req.cookies is populated by framework or we need to parse it?
|
||||||
|
// index.mjs accesses req.cookies directly, so we assume it works.
|
||||||
|
|
||||||
|
let user = [];
|
||||||
|
if (req.cookies && req.cookies.session) {
|
||||||
|
user = await db`
|
||||||
|
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_options".*
|
||||||
|
from "user_sessions"
|
||||||
|
left join "user" on "user".id = "user_sessions".user_id
|
||||||
|
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||||
|
where "user_sessions".session = ${lib.md5(req.cookies.session)}
|
||||||
|
limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
console.log('[UPLOAD HANDLER] Unauthorized - No valid session found');
|
||||||
|
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock req.session for consistency if needed by other logic, though we use 'user[0]' here
|
||||||
|
req.session = user[0];
|
||||||
|
console.log('[UPLOAD HANDLER] Authorized:', req.session.user);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const contentType = req.headers['content-type'] || '';
|
||||||
|
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||||
|
|
||||||
|
if (!boundaryMatch) {
|
||||||
|
console.log('[UPLOAD HANDLER] No boundary');
|
||||||
|
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[UPLOAD HANDLER] Collecting body...');
|
||||||
|
const body = await collectBody(req);
|
||||||
|
console.log('[UPLOAD HANDLER] Body collected, size:', body.length);
|
||||||
|
|
||||||
|
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
const file = parts.file;
|
||||||
|
const rating = parts.rating;
|
||||||
|
const tagsRaw = parts.tags;
|
||||||
|
|
||||||
|
if (!file || !file.data) {
|
||||||
|
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rating || !['sfw', 'nsfw'].includes(rating)) {
|
||||||
|
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw) is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0) : [];
|
||||||
|
if (tags.length < 3) {
|
||||||
|
return sendJson(res, { success: false, msg: 'At least 3 tags are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate MIME type
|
||||||
|
const allowedMimes = ['video/mp4', 'video/webm'];
|
||||||
|
let mime = file.contentType;
|
||||||
|
|
||||||
|
if (!allowedMimes.includes(mime)) {
|
||||||
|
return sendJson(res, { success: false, msg: `Invalid file type. Only mp4 and webm allowed. Got: ${mime}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size
|
||||||
|
const maxfilesize = cfg.main.maxfilesize;
|
||||||
|
const size = file.data.length;
|
||||||
|
|
||||||
|
if (size > maxfilesize) {
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate UUID
|
||||||
|
const uuid = await queue.genuuid();
|
||||||
|
const ext = mime === 'video/mp4' ? 'mp4' : 'webm';
|
||||||
|
const filename = `${uuid}.${ext}`;
|
||||||
|
const tmpPath = `./tmp/${filename}`;
|
||||||
|
const destPath = `./public/b/${filename}`;
|
||||||
|
|
||||||
|
// Ensure directories exist
|
||||||
|
await fs.mkdir('./tmp', { recursive: true });
|
||||||
|
await fs.mkdir('./public/b', { recursive: true });
|
||||||
|
|
||||||
|
// Save temporarily
|
||||||
|
await fs.writeFile(tmpPath, file.data);
|
||||||
|
|
||||||
|
// Verify MIME
|
||||||
|
const actualMime = (await queue.exec(`file --mime-type -b ${tmpPath}`)).stdout.trim();
|
||||||
|
if (!allowedMimes.includes(actualMime)) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const checksum = (await queue.exec(`sha256sum ${tmpPath}`)).stdout.trim().split(" ")[0];
|
||||||
|
|
||||||
|
// Check repost
|
||||||
|
const repost = await queue.checkrepostsum(checksum);
|
||||||
|
if (repost) {
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
return sendJson(res, {
|
||||||
|
success: false,
|
||||||
|
msg: `This file already exists`,
|
||||||
|
repost: repost
|
||||||
|
}, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to public
|
||||||
|
await fs.copyFile(tmpPath, destPath);
|
||||||
|
await fs.unlink(tmpPath).catch(() => { });
|
||||||
|
|
||||||
|
// Insert
|
||||||
|
await db`
|
||||||
|
insert into items ${db({
|
||||||
|
src: '',
|
||||||
|
dest: filename,
|
||||||
|
mime: actualMime,
|
||||||
|
size: size,
|
||||||
|
checksum: checksum,
|
||||||
|
username: req.session.user,
|
||||||
|
userchannel: 'web',
|
||||||
|
usernetwork: 'web',
|
||||||
|
stamp: ~~(Date.now() / 1000),
|
||||||
|
active: false
|
||||||
|
}, 'src', 'dest', 'mime', 'size', 'checksum', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const itemid = await queue.getItemID(filename);
|
||||||
|
|
||||||
|
// Thumbnail
|
||||||
|
try {
|
||||||
|
await queue.genThumbnail(filename, actualMime, itemid, '');
|
||||||
|
} catch (err) {
|
||||||
|
await queue.exec(`magick ./mugge.png ./public/t/${itemid}.webp`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
const ratingTagId = rating === 'sfw' ? 1 : 2;
|
||||||
|
await db`
|
||||||
|
insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })}
|
||||||
|
`;
|
||||||
|
|
||||||
|
for (const tagName of tags) {
|
||||||
|
let tagRow = await db`
|
||||||
|
select id from tags where normalized = slugify(${tagName}) limit 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
let tagId;
|
||||||
|
if (tagRow.length === 0) {
|
||||||
|
await db`
|
||||||
|
insert into tags ${db({ tag: tagName }, 'tag')}
|
||||||
|
`;
|
||||||
|
tagRow = await db`
|
||||||
|
select id from tags where normalized = slugify(${tagName}) limit 1
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
tagId = tagRow[0].id;
|
||||||
|
|
||||||
|
await db`
|
||||||
|
insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: req.session.id })}
|
||||||
|
on conflict do nothing
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sendJson(res, {
|
||||||
|
success: true,
|
||||||
|
msg: 'Upload successful! Your upload is pending admin approval.',
|
||||||
|
itemid: itemid
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UPLOAD HANDLER ERROR]', err);
|
||||||
|
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="about">
|
<div class="about">
|
||||||
<p>Welcome stranger!</p>
|
<p>Welcome stranger!</p>
|
||||||
<p>bringing you some of the greatest webms from the past, the present and the future!</p>
|
<p>bringing you some of the greatest webms from the past, the present and the future!</p>
|
||||||
<p>Enjoy your stay.</p>
|
<p>How to use it?</p>
|
||||||
<img style="width: 200px" src="/s/img/cockfag.png" alt="cockfag">
|
<p>shortcuts</p>
|
||||||
<p>If you have any questions you can reach out via Mail.</p>
|
<ul>
|
||||||
<p>mail: admin@w0bm.com</p>
|
<li>k = search</li>
|
||||||
<p>Please also make yourself familiar with the <a href="/terms">Terms Of Service</a></p>
|
<li>r = random</li>
|
||||||
</div>
|
<li>p = toggle safe for rating</li>
|
||||||
|
<li>i = open tag input</li>
|
||||||
|
<li>x = del</li>
|
||||||
|
<li>scroll up/down inside video or inside the controls triggers next or prev</li>
|
||||||
|
<li>Arrow keys trigger next or prev</li>
|
||||||
|
</ul>
|
||||||
|
<p>If you have any questions you can reach out via Mail.</p>
|
||||||
|
<p>mail: admin@w0bm.com</p>
|
||||||
|
<p>Please also make yourself familiar with the <a href="/terms">Terms Of Service</a></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@include(snippets/footer)
|
@include(snippets/footer)
|
||||||
@@ -6,15 +6,18 @@
|
|||||||
<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 }}
|
||||||
@endif</p>
|
| nsfw: {{ totals.nsfw }}
|
||||||
|
@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>
|
||||||
|
<li><a href="/admin/tokens">Invite Tokens</a></li>
|
||||||
|
<li><a href="/admin/emojis">Emoji Manager</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
191
views/admin/approve.html
Normal file
191
views/admin/approve.html
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
<div id="main">
|
||||||
|
<div class="container">
|
||||||
|
<h1>APPROVAL QUEUE</h1>
|
||||||
|
<p>Items here are pending approval.</p>
|
||||||
|
|
||||||
|
@if(pending.length > 0)
|
||||||
|
<h2>Pending Uploads</h2>
|
||||||
|
<table class="table" style="width: 100%; margin-bottom: 30px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>Preview</td>
|
||||||
|
<td>ID</td>
|
||||||
|
<td>Uploader</td>
|
||||||
|
<td>Type</td>
|
||||||
|
<td>Tags</td>
|
||||||
|
<td>Action</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@each(pending 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>
|
||||||
|
@each(post.tags as tag)
|
||||||
|
<span class="badge badge-secondary" style="margin-right: 5px;">{{ tag }}</span>
|
||||||
|
@endeach
|
||||||
|
</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 btn-deny-async">Deny /
|
||||||
|
Delete</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<h2 style="color: #ff6b6b; margin-top: 40px;">Reference / Soft Deleted</h2>
|
||||||
|
<p class="text-muted">These items are in the deleted folder but not purged from DB. Approving them will restore
|
||||||
|
them.</p>
|
||||||
|
|
||||||
|
@if(trash.length > 0)
|
||||||
|
<table class="table" style="width: 100%; opacity: 0.8;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>Preview</td>
|
||||||
|
<td>ID</td>
|
||||||
|
<td>Uploader</td>
|
||||||
|
<td>Type</td>
|
||||||
|
<td>Tags</td>
|
||||||
|
<td>Action</td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@each(trash as post)
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
@if(post.thumbnail)
|
||||||
|
<img src="data:image/webp;base64,{{ post.thumbnail }}" style="max-height: 150px; opacity: 0.6;">
|
||||||
|
@else
|
||||||
|
<span style="color:red;">[File Missing]</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>{{ post.id }}</td>
|
||||||
|
<td>{{ post.username }}</td>
|
||||||
|
<td>{{ post.mime }}</td>
|
||||||
|
<td>
|
||||||
|
@each(post.tags as tag)
|
||||||
|
<span class="badge badge-secondary" style="margin-right: 5px;">{{ tag }}</span>
|
||||||
|
@endeach
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="/admin/approve/?id={{ post.id }}" class="badge badge-warning">Restore</a>
|
||||||
|
<a href="/admin/deny/?id={{ post.id }}" class="badge badge-danger btn-deny-async">Purge</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@endeach
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
@else
|
||||||
|
<p style="padding: 20px; border: 1px dashed #444; color: #888;">Trash is empty.</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(pending.length === 0 && trash.length === 0)
|
||||||
|
<div style="text-align: center; padding: 50px;">
|
||||||
|
<h3>No pending items.</h3>
|
||||||
|
<p>Go touch grass?</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<br>
|
||||||
|
@if(typeof pages !== 'undefined' && pages > 1)
|
||||||
|
<div class="pagination" style="display: flex; gap: 10px; align-items: center; justify-content: center;">
|
||||||
|
@if(page > 1)
|
||||||
|
<a href="/admin/approve?page={{ page - 1 }}" class="badge badge-secondary">« Prev</a>
|
||||||
|
@endif
|
||||||
|
<span>Page {{ page }} of {{ pages }}</span>
|
||||||
|
@if(page < pages) <a href="/admin/approve?page={{ page + 1 }}" class="badge badge-secondary">Next
|
||||||
|
»</a>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<br>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Custom Modal -->
|
||||||
|
<div id="custom-modal"
|
||||||
|
style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000;">
|
||||||
|
<div
|
||||||
|
style="background: #222; color: #fff; padding: 20px; border-radius: 8px; max-width: 400px; text-align: center; border: 1px solid #444;">
|
||||||
|
<h3 id="modal-title" style="margin-top: 0;">Confirm Action</h3>
|
||||||
|
<p id="modal-text">Are you sure?</p>
|
||||||
|
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
|
||||||
|
<button id="modal-cancel" class="badge badge-secondary"
|
||||||
|
style="border: none; padding: 10px 20px; cursor: pointer;">Cancel</button>
|
||||||
|
<button id="modal-confirm" class="badge badge-danger"
|
||||||
|
style="border: none; padding: 10px 20px; cursor: pointer;">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const modal = document.getElementById('custom-modal');
|
||||||
|
const modalTitle = document.getElementById('modal-title');
|
||||||
|
const modalText = document.getElementById('modal-text');
|
||||||
|
const btnConfirm = document.getElementById('modal-confirm');
|
||||||
|
const btnCancel = document.getElementById('modal-cancel');
|
||||||
|
|
||||||
|
let pendingAction = null;
|
||||||
|
|
||||||
|
const showModal = (title, text, action) => {
|
||||||
|
modalTitle.innerText = title;
|
||||||
|
modalText.innerText = text;
|
||||||
|
pendingAction = action;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
btnConfirm.onclick = async () => {
|
||||||
|
if (!pendingAction) return;
|
||||||
|
btnConfirm.disabled = true;
|
||||||
|
btnConfirm.innerText = 'Processing...';
|
||||||
|
try {
|
||||||
|
await pendingAction();
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
btnConfirm.disabled = false;
|
||||||
|
btnConfirm.innerText = 'Confirm';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modal.style.display = 'none';
|
||||||
|
pendingAction = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (btnCancel) btnCancel.onclick = closeModal;
|
||||||
|
|
||||||
|
// Single Deny
|
||||||
|
document.querySelectorAll('.btn-deny-async').forEach(btn => {
|
||||||
|
btn.addEventListener('click', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = btn.getAttribute('href');
|
||||||
|
const row = btn.closest('tr');
|
||||||
|
|
||||||
|
showModal('Deny Item', 'Permanently delete this item?', async () => {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) {
|
||||||
|
row.style.opacity = '0';
|
||||||
|
setTimeout(() => row.remove(), 300);
|
||||||
|
} else {
|
||||||
|
throw new Error('Request failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@include(snippets/footer)
|
||||||
96
views/admin/emojis.html
Normal file
96
views/admin/emojis.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
|
||||||
|
<div class="container" style="padding-top: 20px;">
|
||||||
|
<h2>Custom Emojis</h2>
|
||||||
|
|
||||||
|
<div class="upload-form"
|
||||||
|
style="margin-bottom: 20px; text-align: left; background: var(--dropdown-bg); padding: 15px; border: 1px solid var(--nav-border-color);">
|
||||||
|
<h4>Add New Emoji</h4>
|
||||||
|
<input type="text" id="emoji-name" placeholder="Name (e.g. pingu)"
|
||||||
|
style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white);">
|
||||||
|
<input type="text" id="emoji-url" placeholder="URL (e.g. /s/img/pingu.gif)"
|
||||||
|
style="background: var(--bg); border: 1px solid var(--black); padding: 5px; color: var(--white); width: 300px;">
|
||||||
|
<button id="add-emoji" class="btn-upload"
|
||||||
|
style="width: auto; padding: 5px 15px; border: 1px solid var(--nav-border-color); background: var(--bg); color: var(--white); cursor: pointer;">Add</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-form" style="overflow-x: auto;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; color: var(--white);">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid var(--nav-border-color); text-align: left;">
|
||||||
|
<th style="padding: 10px;">Preview</th>
|
||||||
|
<th style="padding: 10px;">Name</th>
|
||||||
|
<th style="padding: 10px;">URL</th>
|
||||||
|
<th style="padding: 10px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="emoji-list">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const loadEmojis = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/emojis');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
const tbody = document.getElementById('emoji-list');
|
||||||
|
tbody.innerHTML = data.emojis.map(e =>
|
||||||
|
'<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">' +
|
||||||
|
'<td style="padding: 10px;"><img src="' + e.url + '" style="height: 30px; object-fit: contain;"></td>' +
|
||||||
|
'<td style="padding: 10px; font-family: monospace; font-size: 1.1em; color: var(--accent);">:' + e.name + ':</td>' +
|
||||||
|
'<td style="padding: 10px; opacity: 0.7;">' + e.url + '</td>' +
|
||||||
|
'<td style="padding: 10px;">' +
|
||||||
|
'<button onclick="deleteEmoji(' + e.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em; background: #c00; color: white; border: none; cursor: pointer;">Delete</button>' +
|
||||||
|
'</td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const addEmoji = async () => {
|
||||||
|
const name = document.getElementById('emoji-name').value;
|
||||||
|
const url = document.getElementById('emoji-url').value;
|
||||||
|
if (!name || !url) return alert('Fill both fields');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/admin/emojis', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, // Body parsing middleware uses this or JSON? Typically form-encoded in this stack
|
||||||
|
body: new URLSearchParams({ name, url })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('emoji-name').value = '';
|
||||||
|
document.getElementById('emoji-url').value = '';
|
||||||
|
loadEmojis();
|
||||||
|
} else {
|
||||||
|
alert('Failed: ' + data.message);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteEmoji = async (id) => {
|
||||||
|
if (!confirm('Delete this emoji?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/admin/emojis/' + id + '/delete', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadEmojis();
|
||||||
|
} else {
|
||||||
|
alert('Failed');
|
||||||
|
}
|
||||||
|
} catch (e) { alert(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('add-emoji').addEventListener('click', addEmoji);
|
||||||
|
loadEmojis();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@include(snippets/footer)
|
||||||
89
views/admin/tokens.html
Normal file
89
views/admin/tokens.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
@include(snippets/header)
|
||||||
|
|
||||||
|
<div class="container" style="padding-top: 20px;">
|
||||||
|
<h2>Invite Tokens</h2>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 20px; text-align: right;">
|
||||||
|
<button id="generate-token" class="btn-upload" style="width: auto; padding: 10px 20px;">Generate New
|
||||||
|
Token</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-form" style="overflow-x: auto;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; color: var(--white);">
|
||||||
|
<thead>
|
||||||
|
<tr style="border-bottom: 1px solid var(--nav-border-color); text-align: left;">
|
||||||
|
<th style="padding: 10px;">Token</th>
|
||||||
|
<th style="padding: 10px;">Status</th>
|
||||||
|
<th style="padding: 10px;">Used By</th>
|
||||||
|
<th style="padding: 10px;">Created</th>
|
||||||
|
<th style="padding: 10px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="token-list">
|
||||||
|
<!-- Populated by JS -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const loadTokens = async () => {
|
||||||
|
try {
|
||||||
|
console.log('Loading tokens...');
|
||||||
|
const res = await fetch('/api/v2/admin/tokens');
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('Tokens data:', data);
|
||||||
|
if (data.success) {
|
||||||
|
const tbody = document.getElementById('token-list');
|
||||||
|
tbody.innerHTML = data.tokens.map(t =>
|
||||||
|
'<tr style="border-bottom: 1px solid rgba(255,255,255,0.05);">' +
|
||||||
|
'<td style="padding: 10px; font-family: monospace; font-size: 1.1em; color: var(--accent);">' + t.token + '</td>' +
|
||||||
|
'<td style="padding: 10px;">' +
|
||||||
|
(t.is_used ? '<span style="color: #ff6b6b">Used</span>' : '<span style="color: #51cf66">Available</span>') +
|
||||||
|
'</td>' +
|
||||||
|
'<td style="padding: 10px;">' + (t.used_by_name || '-') + '</td>' +
|
||||||
|
'<td style="padding: 10px;">' + new Date(parseInt(t.created_at) * 1000).toLocaleString() + '</td>' +
|
||||||
|
'<td style="padding: 10px;">' +
|
||||||
|
(!t.is_used ? '<button onclick="deleteToken(' + t.id + ')" class="btn-remove" style="padding: 5px 10px; font-size: 0.8em;">Delete</button>' : '') +
|
||||||
|
'</td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateToken = async () => {
|
||||||
|
console.log('Generating...');
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v2/admin/tokens/create', { method: 'POST' });
|
||||||
|
const data = await res.json();
|
||||||
|
console.log('Gen result:', data);
|
||||||
|
if (data.success) {
|
||||||
|
loadTokens();
|
||||||
|
} else {
|
||||||
|
alert('Failed: ' + data.msg);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteToken = async (id) => {
|
||||||
|
if (!confirm('Delete this token?')) return;
|
||||||
|
const res = await fetch('/api/v2/admin/tokens/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
loadTokens();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('generate-token').addEventListener('click', generateToken);
|
||||||
|
loadTokens();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@include(snippets/footer)
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
<div class="pagewrapper">
|
<div class="pagewrapper">
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="index-container">
|
<div class="index-container">
|
||||||
@if(tmp.user)<h2>user: <a href="/user/{{ tmp.user.toLowerCase() }}">{!! tmp.user.toLowerCase() !!}</a>@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif
|
@if(tmp.user)<h2>user: <a href="/user/{{ tmp.user.toLowerCase() }}">{{ tmp.user.toLowerCase() }}</a>@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif
|
||||||
@if(tmp.tag)<h2>tag: @if(session)<a href="/search?tag={!! tmp.tag.toLowerCase() !!}" target="_blank">{!! tmp.tag.toLowerCase() !!}</a>@else{!! tmp.tag.toLowerCase() !!}@endif@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif
|
@if(tmp.tag)<h2>tag: @if(session)<a href="/search?tag={{ tmp.tag.toLowerCase() }}" target="_blank">{{ tmp.tag.toLowerCase() }}</a>@else{{ tmp.tag.toLowerCase() }}@endif@if(tmp.mime) ({{ tmp.mime }}s)@else (all)@endif</h2>@endif
|
||||||
<div class="posts">
|
<div class="posts">
|
||||||
@each(items as item)
|
@each(items as item)
|
||||||
<a href="{{ link.main }}{{ item.id }}" data-mime="{{ item.mime }}" data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}" style="background-image: url('/t/{{ item.id }}.webp')"><p></p></a>
|
<a href="{{ link.main }}{{ item.id }}" data-mime="{{ item.mime }}" data-mode="{{ item.tag_id ? ['','sfw','nsfw'][item.tag_id] : 'null' }}" style="background-image: url('/t/{{ item.id }}.webp')"><p></p></a>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
@if(item.mime.startsWith("video"))
|
@if(item.mime.startsWith("video"))
|
||||||
<div class="embed-responsive embed-responsive-16by9">
|
<div class="embed-responsive embed-responsive-16by9">
|
||||||
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}"
|
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}"
|
||||||
preload="auto" autoplay controls loop playsinline></video>
|
preload="metadata" controls loop playsinline></video>
|
||||||
</div>
|
</div>
|
||||||
@elseif(item.mime.startsWith("audio"))
|
@elseif(item.mime.startsWith("audio"))
|
||||||
<div class="embed-responsive embed-responsive-16by9"
|
<div class="embed-responsive embed-responsive-16by9"
|
||||||
@@ -95,15 +95,17 @@
|
|||||||
<span class="badge badge-dark">
|
<span class="badge badge-dark">
|
||||||
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a>
|
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a>
|
||||||
@if(session)
|
@if(session)
|
||||||
(<a id="a_username" href="/user/{{ user.name.toLowerCase() }}/f0cks@if(tmp.mime)/{{ tmp.mime }}@endif">{{ user.name }}</a>)
|
(<a id="a_username"
|
||||||
|
href="/user/{{ user.name.toLowerCase() }}/f0cks@if(tmp.mime)/{{ tmp.mime }}@endif">{{user.name }}</a>)
|
||||||
@endif
|
@endif
|
||||||
</span>
|
</span>
|
||||||
<span class="badge badge-dark"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{ item.timestamp.timeago }}</time></span>
|
<span class="badge badge-dark"><time class="timeago"
|
||||||
|
tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></span>
|
||||||
<span class="badge badge-dark" id="tags">
|
<span class="badge badge-dark" id="tags">
|
||||||
@if(typeof item.tags !== "undefined")
|
@if(typeof item.tags !== "undefined")
|
||||||
@each(item.tags as tag)
|
@each(item.tags as tag)
|
||||||
<span @if(session)tooltip="{{ tag.user }}" @endif class="badge {{ tag.badge }} mr-2">
|
<span @if(session)tooltip="{{ tag.user }}" @endif class="badge {{ tag.badge }} mr-2">
|
||||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(session.admin) <a class="removetag"
|
<a href="/tag/{{ tag.normalized }}">{{ tag.tag }}</a>@if(session.admin) <a class="removetag"
|
||||||
href="#">×</a>@endif
|
href="#">×</a>@endif
|
||||||
</span>
|
</span>
|
||||||
@endeach
|
@endeach
|
||||||
@@ -121,6 +123,6 @@
|
|||||||
style="height: 32px; width: 32px" /></a>
|
style="height: 32px; width: 32px" /></a>
|
||||||
@endeach
|
@endeach
|
||||||
@endif
|
@endif
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="comments-container" data-item-id="{{ item.id }}" @if(session)data-user="{{ session.user }}" @endif></div>
|
||||||
35
views/register.html
Normal file
35
views/register.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<!doctype f0ck>
|
||||||
|
<html theme="amoled">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>register</title>
|
||||||
|
<link href="/s/css/f0ck.css" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body type="login">
|
||||||
|
<form class="login-form" method="post" action="/register">
|
||||||
|
<h2 style="text-align: center; margin-bottom: 20px;">Register</h2>
|
||||||
|
@if(typeof error !== 'undefined')
|
||||||
|
<div style="color: #ff6b6b; margin-bottom: 10px; text-align: center;">{{ error }}</div>
|
||||||
|
@endif
|
||||||
|
<input type="text" name="username" placeholder="username" autocomplete="off" required />
|
||||||
|
<input type="password" name="password" placeholder="password" autocomplete="off" required minlength="20"
|
||||||
|
title="Must be at least 20 characters long." />
|
||||||
|
<input type="password" name="password_confirm" placeholder="confirm password" autocomplete="off" /><br>
|
||||||
|
<input type="text" name="token" placeholder="invite token" autocomplete="off" /><br>
|
||||||
|
<p style="text-align: left; font-size: 0.9em; margin: 10px 0; color: #fff;">
|
||||||
|
<input type="checkbox" id="tos-page" name="tos" required />
|
||||||
|
<label for="tos-page">I have read and accept the <a href="/terms" target="_blank"
|
||||||
|
style="color: var(--accent); text-decoration: underline;">Terms of Service</a> and I am at least 18
|
||||||
|
years old</label>
|
||||||
|
</p>
|
||||||
|
<input type="submit" value="Register" />
|
||||||
|
<div style="margin-top: 15px; text-align: center;">
|
||||||
|
<a href="/login" style="color: var(--accent); text-decoration: none;">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<span>search</span>
|
<span>search</span>
|
||||||
</div>
|
</div>
|
||||||
<form action="/search" class="admin-search">
|
<form action="/search" class="admin-search">
|
||||||
<input type="text" name="tag" value="{!! searchstring || '' !!}" /><button type="submit">🔍</button>
|
<input type="text" name="tag" value="{{ searchstring || '' }}" /><button type="submit">🔍</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="results">
|
<div class="results">
|
||||||
@if(result)
|
@if(result)
|
||||||
@@ -22,16 +22,19 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@each(result as line)
|
@each(result as line)
|
||||||
<tr>
|
<tr>
|
||||||
<td style="width: 128px;"><a href="/tag/{!! line.tag !!}/{{ line.id }}" target="_blank"><img src="/t/{{ line.id }}.webp" /></a></td>
|
<td style="width: 128px;"><a href="/tag/{{ line.tag }}/{{ line.id }}" target="_blank"><img
|
||||||
<td><span class="mview_desc">ID:</span><a href="/tag/{!! line.tag !!}/{{ line.id }}" target="_blank">{{ line.id }}</a></td>
|
src="/t/{{ line.id }}.webp" /></a></td>
|
||||||
<td><span class="mview_desc">Tag:</span><a href="/tag/{!! line.tag !!}">{!! line.tag !!}</a></td>
|
<td><span class="mview_desc">ID:</span><a href="/tag/{{ line.tag }}/{{ line.id }}" target="_blank">{{
|
||||||
|
line.id }}</a></td>
|
||||||
|
<td><span class="mview_desc">Tag:</span><a href="/tag/{{ line.tag }}">{{ line.tag }}</a></td>
|
||||||
<td><span class="mview_desc">Mime:</span>{{ line.mime }}</td>
|
<td><span class="mview_desc">Mime:</span>{{ line.mime }}</td>
|
||||||
<td><span class="mview_desc">User:</span><a href="/user/{!! line.username !!}/f0cks/{{ line.id }}">{!! line.username !!}</a></td>
|
<td><span class="mview_desc">User:</span><a href="/user/{{ line.username }}/f0cks/{{ line.id }}">{{
|
||||||
|
line.username }}</a></td>
|
||||||
<td><span class="mview_desc">Score:</span>{{ line.score?.toFixed(2) }}</td>
|
<td><span class="mview_desc">Score:</span>{{ line.score?.toFixed(2) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@endeach
|
@endeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@@ -2,19 +2,15 @@
|
|||||||
<div class="settings">
|
<div class="settings">
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
<h2>Site settings</h2>
|
<h2>Site settings</h2>
|
||||||
<div class="themes">
|
|
||||||
<h3>Themes</h3>
|
<div class="modes">
|
||||||
@each(themes as t)
|
<h3>Modes</h3>
|
||||||
<a href="/theme/{{ t }}">{{ t }}</a>
|
<span>Current: {{ modes[session.mode] ?? 'sfw' }}</span>
|
||||||
@endeach
|
<a class="dropdown-item" href="/mode/0">sfw</a>
|
||||||
</div>
|
<a class="dropdown-item" href="/mode/1">nsfw</a>
|
||||||
<div class="modes">
|
<a class="dropdown-item" href="/mode/2">untagged</a>
|
||||||
<h3>Modes</h3>
|
<a class="dropdown-item" href="/mode/3">all</a>
|
||||||
<span>Current: {{ modes[session.mode] ?? 'sfw' }}</span>
|
</div>
|
||||||
@for(let i = 0; i < modes.length; i++)
|
|
||||||
<a class="dropdown-item" href="/mode/{{ i }}">{{ modes[i] }}</a>
|
|
||||||
@endfor
|
|
||||||
</div>
|
|
||||||
<h2>Account</h2>
|
<h2>Account</h2>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -28,10 +24,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>username</td>
|
<td>username</td>
|
||||||
<td>{!! session.user !!}</td>
|
<td>{{ session.user }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>@if(session.avatar)<a href="/{{ session.avatar }}"><img id="img_avatar" src="/t/{{ session.avatar }}.webp"></a>@endif</td>
|
<td>@if(session.avatar)<a href="/{{ session.avatar }}"><img id="img_avatar"
|
||||||
|
src="/t/{{ session.avatar }}.webp"></a>@endif</td>
|
||||||
<td><input type="text" class="input" name="i_avatar" value="{{ session.avatar }}"></td>
|
<td><input type="text" class="input" name="i_avatar" value="{{ session.avatar }}"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -39,7 +36,7 @@
|
|||||||
<td><input type="text" class="input" name="i_mail" placeholder="hashed" disabled></td>
|
<td><input type="text" class="input" name="i_mail" placeholder="hashed" disabled></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><input type="submit" id="s_avatar" value="save"></td>
|
<td><input type="submit" id="s_avatar" value="save"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -55,7 +52,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@each(sessions as sess)
|
@each(sessions as sess)
|
||||||
<tr@if(sess.id === session.sess_id) style="background-color: rgb(0, 89, 0)"@endif>
|
<tr@if(sess.id===session.sess_id) style="background-color: rgb(0, 89, 0)" @endif>
|
||||||
<td>{{ sess.kmsi ? '⚓' : '' }}</td>
|
<td>{{ sess.kmsi ? '⚓' : '' }}</td>
|
||||||
<td tooltip="{{ sess.browser }}" flow="right">
|
<td tooltip="{{ sess.browser }}" flow="right">
|
||||||
<p>{{ sess.id }}</p>
|
<p>{{ sess.id }}</p>
|
||||||
@@ -66,8 +63,8 @@
|
|||||||
<p>created_at: {{ new Date(sess.created_at * 1e3).toLocaleString("de-DE") }}</p>
|
<p>created_at: {{ new Date(sess.created_at * 1e3).toLocaleString("de-DE") }}</p>
|
||||||
</td>
|
</td>
|
||||||
<td><a href="{{ sess.last_action }}" target="_blank">{{ sess.last_action }}</a></td>
|
<td><a href="{{ sess.last_action }}" target="_blank">{{ sess.last_action }}</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
@endeach
|
@endeach
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
<script async src="/s/js/theme.js?v=@mtime(/public/s/js/theme.js)"></script>
|
<div id="delete-tag-modal" class="modal-overlay" style="display:none;">
|
||||||
<script src="/s/js/v0ck.js?v=@mtime(/public/s/js/v0ck.js)"></script>
|
<div class="modal-content">
|
||||||
<script src="/s/js/f0ck.js?v=@mtime(/public/s/js/f0ck.js)"></script>
|
<h3>Delete Tag?</h3>
|
||||||
@if(session && session.admin)
|
<p>Are you sure you want to delete the tag <strong id="delete-tag-name"></strong>?</p>
|
||||||
<script src="/s/js/admin.js?v=@mtime(/public/s/js/admin.js)"></script>
|
<div class="modal-actions">
|
||||||
@elseif(session && !session.admin)
|
<button id="delete-tag-confirm" class="btn-danger">Delete</button>
|
||||||
<script src="/s/js/user.js?v=@mtime(/public/s/js/user.js)"></script>
|
<button id="delete-tag-cancel" class="btn-secondary">Cancel</button>
|
||||||
@endif
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="delete-item-modal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Delete Item?</h3>
|
||||||
|
<p>Are you sure you want to delete item <strong id="delete-item-id"></strong> by <strong
|
||||||
|
id="delete-item-poster"></strong>?</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="delete-item-confirm" class="btn-danger">Delete</button>
|
||||||
|
<button id="delete-item-cancel" class="btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script async src="/s/js/theme.js?v=@mtime(/public/s/js/theme.js)"></script>
|
||||||
|
<script src="/s/js/v0ck.js?v=@mtime(/public/s/js/v0ck.js)"></script>
|
||||||
|
<script src="/s/js/f0ck.js?v=@mtime(/public/s/js/f0ck.js)"></script>
|
||||||
|
<script src="/s/js/comments.js?v=@mtime(/public/s/js/comments.js)"></script>
|
||||||
|
@if(session && session.admin)
|
||||||
|
<script src="/s/js/admin.js?v=@mtime(/public/s/js/admin.js)"></script>
|
||||||
|
@elseif(session && !session.admin)
|
||||||
|
<script src="/s/js/user.js?v=@mtime(/public/s/js/user.js)"></script>
|
||||||
|
@endif
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -7,10 +7,21 @@
|
|||||||
<link rel="icon" type="image/gif" href="/s/img/favicon.png" />
|
<link rel="icon" type="image/gif" href="/s/img/favicon.png" />
|
||||||
<link rel="stylesheet" href="/s/css/f0ck.css?v=@mtime(/public/s/css/f0ck.css)">
|
<link rel="stylesheet" href="/s/css/f0ck.css?v=@mtime(/public/s/css/f0ck.css)">
|
||||||
<link rel="stylesheet" href="/s/css/w0bm.css?v=@mtime(/public/s/css/w0bm.css)">
|
<link rel="stylesheet" href="/s/css/w0bm.css?v=@mtime(/public/s/css/w0bm.css)">
|
||||||
|
<script src="/s/js/marked.min.js"></script>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@if(typeof item !== 'undefined')
|
@if(typeof item !== 'undefined')
|
||||||
<link rel="canonical" href="https://w0bm.com/{{ item.id }}" />@endif
|
<link rel="canonical" href="https://{{ domain }}/{{ item.id }}" />
|
||||||
|
<meta property="og:site_name" content="f0bm" />
|
||||||
|
<meta property="og:title" content="f0bm - {{ item.id }}" />
|
||||||
|
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||||
|
<meta property="og:image" content="https://{{ domain }}{{ item.thumbnail }}" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:title" content="f0bm - {{ item.id }}" />
|
||||||
|
<meta property="twitter:image" content="https://{{ domain }}{{ item.thumbnail }}" />
|
||||||
|
<meta property="twitter:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||||
|
@endif
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -2,17 +2,67 @@
|
|||||||
<!-- logged in -->
|
<!-- logged in -->
|
||||||
<nav class="navbar navbar-expand-lg">
|
<nav class="navbar navbar-expand-lg">
|
||||||
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
|
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
|
||||||
<div class="navigation-links-guest">
|
<div class="nav-left-group">
|
||||||
<ol>
|
<div class="nav-user-dropdown">
|
||||||
|
<button class="nav-user-btn" id="nav-user-toggle">
|
||||||
|
{{ session.user }} ▾
|
||||||
|
</button>
|
||||||
|
<div class="nav-user-menu" id="nav-user-menu">
|
||||||
|
<a href="/user/{{ session.user.toLowerCase() }}">profile</a>
|
||||||
|
<a href="/user/{{ session.user.toLowerCase() }}/favs">favs</a>
|
||||||
|
<a href="/upload">upload</a>
|
||||||
|
@if(session.admin)
|
||||||
|
<a href="/admin">Admin
|
||||||
|
@if(typeof session.pending_count !== 'undefined' && session.pending_count > 0)
|
||||||
|
<span class="notification-dot" title="{{ session.pending_count }} Pending"
|
||||||
|
onclick="event.preventDefault(); window.location.href='/admin/approve';"></span>
|
||||||
|
@endif
|
||||||
|
</a>
|
||||||
|
@endif
|
||||||
|
<a href="/settings">settings</a>
|
||||||
|
<a href="/about">about</a>
|
||||||
|
<div class="nav-user-divider"></div>
|
||||||
|
<a href="/logout">logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
<a href="/tags">tags</a>
|
<a href="/tags">tags</a>
|
||||||
<a href="/about">about</a>
|
|
||||||
@if(!/^\/\d$/.test(url.pathname))
|
@if(!/^\/\d$/.test(url.pathname))
|
||||||
<a href="/random" id="nav-random">rand</a>
|
<a href="/random" id="nav-random" title="Random"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
|
||||||
|
fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z" />
|
||||||
|
<path
|
||||||
|
d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z" />
|
||||||
|
</svg></a>
|
||||||
@endif
|
@endif
|
||||||
</ol>
|
<div id="nav-notifications" class="nav-item-rel">
|
||||||
|
<a href="#" id="nav-notif-btn" title="Notifications">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M8 16a2 2 0 0 0 2-2H6a2 2 0 0 0 2 2zm.995-14.901a1 1 0 1 0-1.99 0A5.002 5.002 0 0 0 3 6c0 1.098-.5 6-2 7h14c-1.5-1-2-5.902-2-7 0-2.42-1.72-4.44-4.005-4.901z" />
|
||||||
|
</svg>
|
||||||
|
<span class="notif-count" style="display:none">0</span>
|
||||||
|
</a>
|
||||||
|
<div id="notif-dropdown" class="notif-dropdown">
|
||||||
|
<div class="notif-header">
|
||||||
|
<span>Notifications</span>
|
||||||
|
<button id="mark-all-read">Mark all read</button>
|
||||||
|
</div>
|
||||||
|
<div class="notif-list">
|
||||||
|
<div class="notif-empty">No new notifications</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="#" id="nav-search-btn" title="Search"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
|
||||||
|
fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
|
||||||
|
</svg></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- show pagination only for tags and main page -->
|
<!-- show pagination only for tags and main page -->
|
||||||
@if(!/^\/\d+$/.test(url.pathname))
|
@if(typeof hidePagination === 'undefined' || !hidePagination)
|
||||||
<div class="collapse navbar-collapse show" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse show" id="navbarSupportedContent">
|
||||||
<div class="pagination-container-fluid">
|
<div class="pagination-container-fluid">
|
||||||
<div class="pagination-wrapper">
|
<div class="pagination-wrapper">
|
||||||
@@ -26,17 +76,38 @@
|
|||||||
<!-- not logged in -->
|
<!-- not logged in -->
|
||||||
<nav class="navbar navbar-expand-lg">
|
<nav class="navbar navbar-expand-lg">
|
||||||
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
|
<a class="navbar-brand" href="/"><span class="f0ck" width="" height="">w0bm.com</span></a>
|
||||||
<div class="navigation-links-guest">
|
<div class="nav-left-group">
|
||||||
<ol>
|
<div class="nav-user-dropdown">
|
||||||
|
<button class="nav-user-btn" id="nav-visitor-toggle">
|
||||||
|
guest ▾
|
||||||
|
</button>
|
||||||
|
<div class="nav-user-menu" id="nav-visitor-menu">
|
||||||
|
<a href="#" id="nav-login-btn">Login</a>
|
||||||
|
<a href="#" id="nav-register-btn">Register</a>
|
||||||
|
<div class="nav-user-divider"></div>
|
||||||
|
<a href="/about">about</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="nav-links">
|
||||||
<a href="/tags">tags</a>
|
<a href="/tags">tags</a>
|
||||||
<a href="/about">about</a>
|
|
||||||
@if(!/^\/\d$/.test(url.pathname))
|
@if(!/^\/\d$/.test(url.pathname))
|
||||||
<a href="/random" id="nav-random">rand</a>
|
<a href="/random" id="nav-random" title="Random"><svg xmlns="http://www.w3.org/2000/svg" width="13" height="13"
|
||||||
|
fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M0 3.5A.5.5 0 0 1 .5 3H1c2.202 0 3.827 1.24 4.874 2.418.49.552.865 1.102 1.126 1.532.26-.43.636-.98 1.126-1.532C9.173 4.24 10.798 3 13 3v1c-1.798 0-3.173 1.01-4.126 2.082A9.624 9.624 0 0 0 7.556 8a9.624 9.624 0 0 0 1.317 1.918C9.828 10.99 11.204 12 13 12v1c-2.202 0-3.827-1.24-4.874-2.418A10.595 10.595 0 0 1 7 9.05c-.26.43-.636.98-1.126 1.532C4.827 11.76 3.202 13 1 13H.5a.5.5 0 0 1 0-1H1c1.798 0 3.173-1.01 4.126-2.082A9.624 9.624 0 0 0 6.444 8a9.624 9.624 0 0 0-1.317-1.918C4.172 5.01 2.796 4 1 4H.5a.5.5 0 0 1-.5-.5z" />
|
||||||
|
<path
|
||||||
|
d="M13 5.466V1.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192zm0 9v-3.932a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384l-2.36 1.966a.25.25 0 0 1-.41-.192z" />
|
||||||
|
</svg></a>
|
||||||
@endif
|
@endif
|
||||||
</ol>
|
<a href="#" id="nav-search-btn-guest" title="Search"><svg xmlns="http://www.w3.org/2000/svg" width="13"
|
||||||
|
height="13" fill="currentColor" viewBox="0 0 16 16">
|
||||||
|
<path
|
||||||
|
d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.1zM12 6.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z" />
|
||||||
|
</svg></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- show pagination only for tags and main page -->
|
<!-- show pagination only for tags and main page -->
|
||||||
@if(!/^\/\d+$/.test(url.pathname))
|
@if(typeof hidePagination === 'undefined' || !hidePagination)
|
||||||
<div class="collapse navbar-collapse show" id="navbarSupportedContent">
|
<div class="collapse navbar-collapse show" id="navbarSupportedContent">
|
||||||
<div class="pagination-container-fluid">
|
<div class="pagination-container-fluid">
|
||||||
<div class="pagination-wrapper">
|
<div class="pagination-wrapper">
|
||||||
@@ -46,5 +117,42 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<!-- Login Modal -->
|
||||||
|
<div id="login-modal" style="display: none;">
|
||||||
|
<div class="login-modal-content">
|
||||||
|
<button id="login-modal-close">×</button>
|
||||||
|
<form class="login-form" method="post" action="/login">
|
||||||
|
<img class="login-image" src="/s/img/w0bm_mosh_banner_by_marderchen.gif" alt="Login Banner">
|
||||||
|
<input type="text" name="username" placeholder="Username" autocomplete="off" required />
|
||||||
|
<input type="password" name="password" placeholder="Password" autocomplete="off" required />
|
||||||
|
<p style="text-align: left; font-size: 0.9em; margin: 0;"><input type="checkbox" id="kmsi-modal" name="kmsi" />
|
||||||
|
<label for="kmsi-modal">Stay signed in</label>
|
||||||
|
</p>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Register Modal -->
|
||||||
|
<div id="register-modal" style="display: none;">
|
||||||
|
<div class="login-modal-content">
|
||||||
|
<button id="register-modal-close">×</button>
|
||||||
|
<form class="login-form" method="post" action="/register">
|
||||||
|
<h2 style="text-align: center; margin-bottom: 20px;">Register</h2>
|
||||||
|
<input type="text" name="username" placeholder="username" autocomplete="off" required />
|
||||||
|
<input type="password" name="password" placeholder="password" autocomplete="off" required minlength="20"
|
||||||
|
title="Must be at least 20 characters long." />
|
||||||
|
<input type="password" name="password_confirm" placeholder="confirm password" autocomplete="off" required />
|
||||||
|
<input type="text" name="token" placeholder="invite token" autocomplete="off" required />
|
||||||
|
<p style="text-align: left; font-size: 0.9em; margin: 0; color: #fff;">
|
||||||
|
<input type="checkbox" id="tos-modal" name="tos" required />
|
||||||
|
<label for="tos-modal">I have read and accept the <a href="/terms" target="_blank"
|
||||||
|
style="color: var(--accent); text-decoration: underline;">Terms of Service</a> and I am at least 18 years
|
||||||
|
old</label>
|
||||||
|
</p>
|
||||||
|
<button type="submit">Create Account</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -2,27 +2,27 @@
|
|||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h3 style="text-align: center;">☯</h3>
|
<h3 style="text-align: center;">☯</h3>
|
||||||
<div class="tags-grid">
|
<div class="tags-grid" id="tags-container">
|
||||||
@if(session)
|
@if(session)
|
||||||
@each(toptags_regged as toptag)
|
@each(toptags_regged as toptag)
|
||||||
<a href="/tag/{!! toptag.tag !!}" class="tag-card">
|
<a href="/tag/{{ toptag.tag }}" class="tag-card">
|
||||||
<div class="tag-card-image">
|
<div class="tag-card-image">
|
||||||
<img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
|
<img src="/tag_image/{{ toptag.tag }}" loading="lazy" alt="{{ toptag.tag }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-card-content">
|
<div class="tag-card-content">
|
||||||
<span class="tag-name">#{!! toptag.tag !!}</span>
|
<span class="tag-name">#{{ toptag.tag }}</span>
|
||||||
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@endeach
|
@endeach
|
||||||
@else
|
@else
|
||||||
@each(toptags as toptag)
|
@each(toptags as toptag)
|
||||||
<a href="/tag/{!! toptag.tag !!}" class="tag-card">
|
<a href="/tag/{{ toptag.tag }}" class="tag-card">
|
||||||
<div class="tag-card-image">
|
<div class="tag-card-image">
|
||||||
<img src="/tag_image/{!! toptag.tag !!}" loading="lazy" alt="{!! toptag.tag !!}">
|
<img src="/tag_image/{{ toptag.tag }}" loading="lazy" alt="{{ toptag.tag }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="tag-card-content">
|
<div class="tag-card-content">
|
||||||
<span class="tag-name">#{!! toptag.tag !!}</span>
|
<span class="tag-name">#{{ toptag.tag }}</span>
|
||||||
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
<span class="tag-count">{{ toptag.total_items }} posts</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
140
views/terms.html
140
views/terms.html
@@ -1,56 +1,96 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div id="main">
|
<div id="main">
|
||||||
<div class="tos">
|
<div class="tos">
|
||||||
<p>Terms of Service</p>
|
<h1 style="text-align: center; margin-bottom: 20px;">Terms of Service</h1>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Acceptance of Terms</li>
|
<li>
|
||||||
<p>By accessing and using this website, you acknowledge that your access is a privilege, not a right. If you do not agree with these terms, you are free to leave at any time.</p>
|
<strong>Acceptance of Terms</strong>
|
||||||
<li>No Claims</li>
|
<p>By accessing and using this website, you acknowledge that your access is a privilege, not a right. If
|
||||||
<p>Visitors to this website have no claims whatsoever against the website owner or operators. Access to the website and its content is provided as-is, with no guarantees, warranties, or entitlements of any kind.</p>
|
you do not agree with these terms, you are free to leave at any time.</p>
|
||||||
<li>No Liability</li>
|
</li>
|
||||||
<p>This website and its operators assume no liability for any errors, omissions, inaccuracies, or any other issues that may arise from the use of this site. Use of this website is entirely at your own risk.</p>
|
<li>
|
||||||
<li>No Warranty</li>
|
<strong>No Claims</strong>
|
||||||
<p>There is no warranty regarding the completeness, accuracy, reliability, or availability of the content provided on this website. The content may change at any time without notice.</p>
|
<p>Visitors to this website have no claims whatsoever against the website owner or operators. Access to
|
||||||
<li>Compliance with Requests</li>
|
the website and its content is provided as-is, with no guarantees, warranties, or entitlements of
|
||||||
<p>The website owner reserves the right to remove content, restrict access, or comply with any valid legal or personal requests at their sole discretion.</p>
|
any kind.</p>
|
||||||
<li>Changes to Terms</li>
|
</li>
|
||||||
<p>These terms may be updated at any time without prior notice. It is your responsibility to review them periodically.</p>
|
<li>
|
||||||
</ol>
|
<strong>No Liability</strong>
|
||||||
<p>Data Privacy</p>
|
<p>This website and its operators assume no liability for any errors, omissions, inaccuracies, or any
|
||||||
<ol>
|
other issues that may arise from the use of this site. Use of this website is entirely at your own
|
||||||
<li>No Data Logging</li>
|
risk.</p>
|
||||||
<p>This website does not collect, store, or log any personal data, including IP addresses or other identifying information of its visitors. No server-side logs are maintained.</p>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>No Warranty</strong>
|
||||||
|
<p>There is no warranty regarding the completeness, accuracy, reliability, or availability of the
|
||||||
|
content provided on this website. The content may change at any time without notice.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Compliance with Requests</strong>
|
||||||
|
<p>The website owner reserves the right to remove content, restrict access, or comply with any valid
|
||||||
|
legal or personal requests at their sole discretion.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Changes to Terms</strong>
|
||||||
|
<p>These terms may be updated at any time without prior notice. It is your responsibility to review them
|
||||||
|
periodically.</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
<li>Use of Cookies</li>
|
<h2 style="margin-top: 30px;">Data Privacy</h2>
|
||||||
<p>Upon changing the theme, a single cookie is set. This cookie solely stores the name of the currently active theme to enhance the visual experience. It does not contain any personal data, tracking information, or other identifiers.</p>
|
<ol>
|
||||||
|
<li>
|
||||||
|
<strong>No Data Logging</strong>
|
||||||
|
<p>This website does not collect, store, or log any personal data, including IP addresses or other
|
||||||
|
identifying information of its visitors. No server-side logs are maintained.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Use of Cookies</strong>
|
||||||
|
<p>Upon changing the theme, a single cookie is set. This cookie solely stores the name of the currently
|
||||||
|
active theme to enhance the visual experience. It does not contain any personal data, tracking
|
||||||
|
information, or other identifiers.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Cookie Control</strong>
|
||||||
|
<p>The cookie is purely of cosmetic nature and not essential for the website's functionality. Users can
|
||||||
|
disable cookies for this website entirely via their browser settings without affecting their ability
|
||||||
|
to access and use the site.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>No Third-Party Tracking</strong>
|
||||||
|
<p>This website does not use third-party tracking services, analytics tools, or embedded content that
|
||||||
|
collects user data.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>User Accounts</strong>
|
||||||
|
<p>When a former visitor is granted access with an account, the following data is collected:</p>
|
||||||
|
<ul>
|
||||||
|
<li>The User Agent</li>
|
||||||
|
<li>The Timestamp of the first login</li>
|
||||||
|
<li>The Timestamp of the account's last usage</li>
|
||||||
|
<li>The User's last recorded action</li>
|
||||||
|
</ul>
|
||||||
|
<br>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Email Communication</strong>
|
||||||
|
<p>If you send me an email your mail is stored on our server, we can make a connection to your
|
||||||
|
Email-Address and your user account if you contact us this way.</p>
|
||||||
|
<p>The Emails are not deleted after being answered.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Fully complying with Art. 15 GDPR</strong>
|
||||||
|
<p>You can ask anytime what data we have of you and how we use it, see Email Communication too.</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Changes to This Policy</strong>
|
||||||
|
<p>This privacy policy may be updated from time to time. Users are encouraged to review it periodically
|
||||||
|
to stay informed about any changes.</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
<li>Cookie Control</li>
|
<p style="margin-top: 30px; font-style: italic;">By using this website, you acknowledge and accept the terms of
|
||||||
<p>The cookie is purely of cosmetic nature and not essential for the website's functionality. Users can disable cookies for this website entirely via their browser settings without affecting their ability to access and use the site.</p>
|
service and the data privacy policy.</p>
|
||||||
|
</div>
|
||||||
<li>No Third-Party Tracking</li>
|
|
||||||
<p>This website does not use third-party tracking services, analytics tools, or embedded content that collects user data.</p>
|
|
||||||
|
|
||||||
<li>User Accounts</li>
|
|
||||||
<p>When a former visitor is granted access with an account, the following data is collected:</p>
|
|
||||||
<ul>
|
|
||||||
<li>The User Agent</li>
|
|
||||||
<li>The Timestamp of the first login</li>
|
|
||||||
<li>The Timestamp of the account's last usage</li>
|
|
||||||
<li>The User's last recorded action</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<li>Email Communication</li>
|
|
||||||
<p>If you send me an email your mail is stored on our server, we can make a connection to your Email-Address and your user account if you contact us this way.</p>
|
|
||||||
<p>The Emails are not deleted after being answered.</p>
|
|
||||||
|
|
||||||
<li>Fully complying with Art. 15 GDPR</li>
|
|
||||||
<p>You can ask anytime what data we have of you and how we use it, see Email Communication too.</p>
|
|
||||||
|
|
||||||
<li>Changes to This Policy</li>
|
|
||||||
<p>This privacy policy may be updated from time to time. Users are encouraged to review it periodically to stay informed about any changes.</p>
|
|
||||||
|
|
||||||
<p>By using this website, you acknowledge and accept the terms of service and the data privacy policy.</p>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
@include(snippets/footer)
|
@include(snippets/footer)
|
||||||
@@ -1,37 +1,115 @@
|
|||||||
@include(snippets/header)
|
@include(snippets/header)
|
||||||
<div class="upload">
|
<link rel="stylesheet" href="/s/css/upload.css">
|
||||||
<h5>Upload</h5>
|
|
||||||
<p>To add videos to the w0bm catalogue you must join our <a href="https://t.me/+w97TCd988ehkNWEy">Telegram</a> group</p>
|
<div class="upload-container">
|
||||||
<h5>Content Guideline</h5>
|
<h2>Upload Content</h2>
|
||||||
<p>w0bm follows strict principles when it comes to content, please keep this in mind.</p>
|
|
||||||
<p>We do not want content that</p>
|
<details class="content-guidelines">
|
||||||
<ul>
|
<summary>Content Guidelines (Click to expand)</summary>
|
||||||
<li>glorifies Nazis</li>
|
<div class="guidelines-content">
|
||||||
<li>sexualizes children and minors</li>
|
<p>We want this place to be fun. Keep it cool, keep it legal.</p>
|
||||||
<li>is political</li>
|
<div class="guidelines-grid">
|
||||||
<li>glorifies military</li>
|
<div class="guidelines-do">
|
||||||
<li>depicts gore</li>
|
<h5>Do's (Vibes & Hypnosis)</h5>
|
||||||
<li>depicts acts of terrorism</li>
|
<ul>
|
||||||
<li>depicts violence and cruelty against animals</li>
|
<li>Cool, relaxing, or weird "vibing" content</li>
|
||||||
</ul>
|
<li>Classic-style loops (Flash era vibes)</li>
|
||||||
<p>We want content that</p>
|
<li>High-quality, hypnotic edits (PMVs welcome)</li>
|
||||||
<ul>
|
<li>Interesting, freaky, or just plain cool stuff</li>
|
||||||
<li>is cool</li>
|
</ul>
|
||||||
<li>has deeper value</li>
|
</div>
|
||||||
<li>is fun to watch</li>
|
<div class="guidelines-dont">
|
||||||
<li>has a vibe to it</li>
|
<h5>Don'ts (The Banhammer)</h5>
|
||||||
<li>can be looped for 5000 times and doesnt get boring</li>
|
<ul>
|
||||||
</ul>
|
<li>Political commentary, preaching, or "pol" bait</li>
|
||||||
<p>but in general we welcome content that has been curated beforehand by the uploader and believe that they understand the vibe.</p>
|
<li>Gore, extreme violence, or animal cruelty (Instant Ban)</li>
|
||||||
<p>Content that is deemed NSFW (Not Safe For Work) MUST be tagged with "nsfw"</p>
|
<li>Illegal content (CP, Terror, etc.) (Instant Ban)</li>
|
||||||
<p>This list is subject to change, please review it periodically.</p>
|
<li>Boring, unedited, or lengthy videos</li>
|
||||||
<br>
|
</ul>
|
||||||
<h5>How it works</h5>
|
</div>
|
||||||
<ul>
|
</div>
|
||||||
<li>The maximum filesize for direct file upload is 20MB and cannot be exceeded.</li>
|
</div>
|
||||||
<li>There is a much higher limit for non-direct uploads via sending a URL.</li>
|
</details>
|
||||||
<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>
|
@if(session)
|
||||||
</ul>
|
<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">
|
||||||
|
<div class="drop-zone-prompt">
|
||||||
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
|
||||||
|
style="opacity: 0.7; margin-bottom: 1rem;">
|
||||||
|
<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 style="font-size: 1.1rem; font-weight: 500;">Drop your video here</p>
|
||||||
|
<p style="font-size: 0.9rem; opacity: 0.6;">(mp4 or webm)</p>
|
||||||
|
</div>
|
||||||
|
<!-- Preview Container -->
|
||||||
|
<div class="file-preview" id="file-preview" style="display: none;">
|
||||||
|
<!-- Video will be injected here via JS -->
|
||||||
|
<div class="file-meta-row">
|
||||||
|
<div class="file-info">
|
||||||
|
<span class="file-name" id="file-name"></span>
|
||||||
|
<span class="file-size" id="file-size"></span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-remove" id="remove-file" title="Remove File">✕</button>
|
||||||
|
</div>
|
||||||
|
</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">Select a file</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">
|
||||||
|
<h3>Authentication Required</h3>
|
||||||
|
<p>You must be logged in to upload content to w0bm.</p>
|
||||||
|
<a href="/login" class="btn-login">Login</a>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/s/js/upload.js"></script>
|
||||||
@include(snippets/footer)
|
@include(snippets/footer)
|
||||||
Reference in New Issue
Block a user