init f0ckm
This commit is contained in:
233
src/avatar_handler.mjs
Normal file
233
src/avatar_handler.mjs
Normal file
@@ -0,0 +1,233 @@
|
||||
import cfg from "./inc/config.mjs";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import db from "./inc/sql.mjs";
|
||||
import lib from "./inc/lib.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
// Multi-part parsing logic removed, using shared imports
|
||||
|
||||
// Helper for JSON response
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Generate UUID using the same method as video uploads
|
||||
const genuuid = async () => {
|
||||
return (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
|
||||
};
|
||||
|
||||
export const handleAvatarUpload = async (req, res) => {
|
||||
console.log('[AVATAR HANDLER] Upload started');
|
||||
|
||||
// Manual Session Lookup
|
||||
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_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (user.length === 0) {
|
||||
console.log('[AVATAR HANDLER] Unauthorized - No valid session found');
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
console.log('[AVATAR HANDLER] Authorized:', req.session.user);
|
||||
|
||||
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked avatar upload for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||
|
||||
if (!boundaryMatch) {
|
||||
console.log('[AVATAR HANDLER] No boundary');
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
console.log('[AVATAR HANDLER] Collecting body...');
|
||||
const body = await collectBody(req);
|
||||
console.log('[AVATAR HANDLER] Body collected, size:', body.length);
|
||||
|
||||
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||
const file = parts.file;
|
||||
|
||||
if (!file || !file.data) {
|
||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// Validate file size (5MB max)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.data.length > maxSize) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Allowed MIME types
|
||||
const allowedMimes = [
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
// Validate MIME type from content-type header
|
||||
let mime = file.contentType.toLowerCase();
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Save to tmp and verify with file magic
|
||||
const uuid = await genuuid();
|
||||
const tmpPath = path.join(cfg.paths.tmp, `avatar_${uuid}_tmp`);
|
||||
const finalFilename = `${uuid}.webp`;
|
||||
const finalPath = path.join(cfg.paths.a, finalFilename);
|
||||
|
||||
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||
await fs.mkdir(cfg.paths.a, { recursive: true });
|
||||
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Verify MIME with file magic
|
||||
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||
const allowedActualMimes = [
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
if (!allowedActualMimes.includes(actualMime.trim())) {
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `Invalid file type detected: ${actualMime.trim()}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Get current avatar_file to delete old one
|
||||
const currentAvatar = (await db`
|
||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0]?.avatar_file;
|
||||
|
||||
// Convert to webp using ImageMagick with coalesce for GIF handling
|
||||
try {
|
||||
await execFile('magick', [tmpPath, '-coalesce', '-resize', '256x256^', '-gravity', 'center', '-extent', '256x256', '-quality', '50', finalPath]);
|
||||
} catch (err) {
|
||||
console.error('[AVATAR HANDLER] Magick error:', err);
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
|
||||
}
|
||||
|
||||
// Clean up tmp file
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
|
||||
// Delete old avatar file if exists (except default.png)
|
||||
if (currentAvatar && currentAvatar !== 'default.png') {
|
||||
const oldPath = path.join(cfg.paths.a, currentAvatar);
|
||||
await fs.unlink(oldPath).catch(() => { });
|
||||
}
|
||||
|
||||
// Update database — clear item-based avatar so custom file takes priority
|
||||
await db`
|
||||
update user_options
|
||||
set avatar_file = ${finalFilename},
|
||||
avatar = NULL
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
avatar_file: finalFilename,
|
||||
msg: 'Avatar uploaded successfully'
|
||||
}, 200);
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'BODY_TOO_LARGE') {
|
||||
return sendJson(res, { success: false, msg: 'File too large (5 MB max for avatars)' }, 413);
|
||||
}
|
||||
console.error('[AVATAR HANDLER ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: lib.logError(err, 'Avatar Upload failed') }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAvatarDelete = async (req, res) => {
|
||||
console.log('[AVATAR HANDLER] Delete started');
|
||||
|
||||
// Manual Session Lookup
|
||||
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_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (user.length === 0) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
|
||||
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked avatar delete for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const currentAvatar = (await db`
|
||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0]?.avatar_file;
|
||||
|
||||
if (currentAvatar && currentAvatar !== 'default.png') {
|
||||
const oldPath = path.join(cfg.paths.a, currentAvatar);
|
||||
await fs.unlink(oldPath).catch(() => { });
|
||||
}
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set avatar_file = null
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
console.log('[AVATAR HANDLER] Delete complete');
|
||||
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
|
||||
} catch (err) {
|
||||
console.error('[AVATAR DELETE ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: 'Failed to remove avatar' }, 500);
|
||||
}
|
||||
};
|
||||
110
src/emoji_upload_handler.mjs
Normal file
110
src/emoji_upload_handler.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
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 { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import path from "path";
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
export const handleEmojiUpload = async (req, res) => {
|
||||
console.error('[BOOT] [EMOJI HANDLER] Started');
|
||||
|
||||
// Manual Session Lookup
|
||||
let user = [];
|
||||
if (req.cookies && req.cookies.session) {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (user.length === 0 || !user[0].admin) {
|
||||
console.error('[BOOT] [EMOJI HANDLER] Unauthorized');
|
||||
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
|
||||
// CSRF validation
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked emoji upload for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||
|
||||
if (!boundaryMatch) {
|
||||
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
let boundary = boundaryMatch[1].trim();
|
||||
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
||||
boundary = boundary.substring(1, boundary.length - 1);
|
||||
}
|
||||
|
||||
const bodyBuffer = await collectBody(req);
|
||||
const parts = parseMultipart(bodyBuffer, boundary);
|
||||
|
||||
const name = (parts.name || '').trim().toLowerCase();
|
||||
let url = (parts.url || '').trim();
|
||||
|
||||
if (!name) {
|
||||
return sendJson(res, { success: false, message: 'Emoji name is required' }, 400);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(name)) {
|
||||
return sendJson(res, { success: false, message: 'Invalid name. Use lowercase a-z, 0-9, _, - only.' }, 400);
|
||||
}
|
||||
|
||||
const file = parts.file;
|
||||
if (file && file.data && file.data.length > 0) {
|
||||
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : 'png';
|
||||
const filename = `${name}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||
|
||||
// Emojis go to public/s/emojis
|
||||
const filePath = path.join(cfg.paths.emojis, filename);
|
||||
console.error(`[BOOT] [EMOJI HANDLER] Writing file to: ${filePath} (Size: ${file.data.length})`);
|
||||
await fs.writeFile(filePath, file.data);
|
||||
|
||||
// Verify write
|
||||
const exists = (await fs.stat(filePath)).size > 0;
|
||||
if (!exists) throw new Error("File write verification failed");
|
||||
|
||||
url = `/s/emojis/${filename}`;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return sendJson(res, { success: false, message: 'Either image URL or File is required' }, 400);
|
||||
}
|
||||
|
||||
const newEmoji = await db`
|
||||
INSERT INTO custom_emojis (name, url)
|
||||
VALUES (${name}, ${url})
|
||||
RETURNING id, name, url
|
||||
`;
|
||||
|
||||
console.error(`[BOOT] [EMOJI HANDLER] Success: ${name}`);
|
||||
await db`NOTIFY emojis_updated, '{}'`;
|
||||
return sendJson(res, { success: true, emoji: newEmoji[0] });
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
||||
}
|
||||
console.error('[EMOJI HANDLER ERROR]', err);
|
||||
return sendJson(res, { success: false, message: err.message }, 500);
|
||||
}
|
||||
};
|
||||
296
src/hall_image_handler.mjs
Normal file
296
src/hall_image_handler.mjs
Normal file
@@ -0,0 +1,296 @@
|
||||
import cfg from "./inc/config.mjs";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import db from "./inc/sql.mjs";
|
||||
import lib from "./inc/lib.mjs";
|
||||
import audit from "./inc/audit.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
const HALL_IMG_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const HALL_CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Direct DB session lookup — same pattern as emoji_upload_handler.mjs
|
||||
const lookupSession = async (req) => {
|
||||
if (!req.cookies || !req.cookies.session) return null;
|
||||
const users = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator,
|
||||
"user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
return users.length > 0 ? users[0] : null;
|
||||
};
|
||||
|
||||
const clearHallCache = async (hallSlug) => {
|
||||
const { createHash } = await import('crypto');
|
||||
for (const mode of [0, 1, 2]) {
|
||||
const hash = createHash('md5').update(hallSlug + '_' + mode).digest('hex');
|
||||
await fs.unlink(path.join(HALL_CACHE_DIR, hash + '.webp')).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// POST /api/v2/admin/halls/:slug/image — upload a custom image for a hall
|
||||
export const handleHallImageUpload = async (req, res) => {
|
||||
console.error('[BOOT] [HALL IMG] Upload started');
|
||||
|
||||
const session = await lookupSession(req);
|
||||
if (!session || (!session.admin && !session.is_moderator)) {
|
||||
console.error('[BOOT] [HALL IMG] Unauthorized');
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
const hallSlug = req.params && req.params.slug;
|
||||
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
||||
|
||||
const hall = await db`SELECT id, custom_image FROM halls WHERE slug = ${hallSlug} LIMIT 1`;
|
||||
if (!hall.length) return sendJson(res, { success: false, msg: 'Hall not found' }, 404);
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||
if (!boundaryMatch) return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
|
||||
let boundary = boundaryMatch[1].trim();
|
||||
if (boundary.startsWith('"') && boundary.endsWith('"')) boundary = boundary.slice(1, -1);
|
||||
|
||||
const body = await collectBody(req);
|
||||
const parts = parseMultipart(body, boundary);
|
||||
const file = parts.file;
|
||||
|
||||
if (!file || !file.data) return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
if (file.data.length > 10 * 1024 * 1024) return sendJson(res, { success: false, msg: 'File too large (10 MB max)' }, 400);
|
||||
|
||||
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
const fileMime = (file.contentType || '').toLowerCase().trim();
|
||||
if (!allowedMimes.includes(fileMime))
|
||||
return sendJson(res, { success: false, msg: 'Invalid file type: ' + fileMime }, 400);
|
||||
|
||||
await fs.mkdir(HALL_IMG_DIR, { recursive: true });
|
||||
|
||||
const tmpPath = path.join(cfg.paths.tmp || '/tmp', 'hall_img_' + hallSlug + '_tmp');
|
||||
const finalFilename = hallSlug + '.webp';
|
||||
const finalPath = path.join(HALL_IMG_DIR, finalFilename);
|
||||
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Verify with file magic
|
||||
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||
if (!['image/gif', 'image/jpeg', 'image/png', 'image/webp'].includes(actualMime.trim())) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: 'Invalid file type: ' + actualMime.trim() }, 400);
|
||||
}
|
||||
|
||||
// Resize to 600x300 webp
|
||||
// -coalesce is required for animated GIFs: it composites delta frames into full frames
|
||||
// before resizing, preventing heavy artifacts. Output is animated WebP for GIFs.
|
||||
try {
|
||||
await execFile('magick', [tmpPath, '-coalesce', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', '-quality', '85', finalPath]);
|
||||
} catch (err) {
|
||||
console.error('[HALL IMG] Magick error:', err.stderr || err.message);
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
|
||||
}
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
await db`UPDATE halls SET custom_image = ${finalFilename} WHERE slug = ${hallSlug}`;
|
||||
await clearHallCache(hallSlug);
|
||||
|
||||
console.error('[BOOT] [HALL IMG] Uploaded custom image for:', hallSlug);
|
||||
return sendJson(res, { success: true, msg: 'Image uploaded', file: finalFilename });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[HALL IMG] Upload error:', err);
|
||||
return sendJson(res, { success: false, msg: err.message || 'Upload failed' }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
// DELETE /api/v2/admin/halls/:slug/image — remove custom image
|
||||
export const handleHallImageDelete = async (req, res) => {
|
||||
const session = await lookupSession(req);
|
||||
if (!session || (!session.admin && !session.is_moderator)) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
const hallSlug = req.params && req.params.slug;
|
||||
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
||||
|
||||
const hall = await db`SELECT id, custom_image FROM halls WHERE slug = ${hallSlug} LIMIT 1`;
|
||||
if (!hall.length) return sendJson(res, { success: false, msg: 'Hall not found' }, 404);
|
||||
|
||||
// Always delete the canonical {slug}.webp from disk regardless of DB value
|
||||
const filePath = path.join(HALL_IMG_DIR, hallSlug + '.webp');
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
console.error('[HALL IMG] Deleted file:', filePath);
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
console.error('[HALL IMG] Failed to delete file:', filePath, e.message);
|
||||
}
|
||||
// ENOENT = already gone, that's fine
|
||||
}
|
||||
|
||||
// Also delete whatever the DB says (in case it differs)
|
||||
if (hall[0].custom_image && hall[0].custom_image !== hallSlug + '.webp') {
|
||||
await fs.unlink(path.join(HALL_IMG_DIR, hall[0].custom_image)).catch((e) => {
|
||||
if (e.code !== 'ENOENT') console.error('[HALL IMG] Failed to delete legacy file:', e.message);
|
||||
});
|
||||
}
|
||||
|
||||
await db`UPDATE halls SET custom_image = NULL WHERE slug = ${hallSlug}`;
|
||||
await clearHallCache(hallSlug);
|
||||
|
||||
return sendJson(res, { success: true, msg: 'Custom image removed' });
|
||||
};
|
||||
|
||||
// DELETE /api/v2/admin/halls/:slug — delete a hall entirely
|
||||
export const handleHallDelete = async (req, res) => {
|
||||
const session = await lookupSession(req);
|
||||
if (!session || (!session.admin && !session.is_moderator)) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
const hallSlug = req.params && req.params.slug;
|
||||
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing hall slug' }, 400);
|
||||
|
||||
await db`DELETE FROM halls_assign WHERE hall_id = (SELECT id FROM halls WHERE slug = ${hallSlug})`.catch(() => {});
|
||||
await db`DELETE FROM halls WHERE slug = ${hallSlug}`;
|
||||
|
||||
await fs.unlink(path.join(HALL_IMG_DIR, hallSlug + '.webp')).catch(() => {});
|
||||
await clearHallCache(hallSlug);
|
||||
|
||||
console.error('[BOOT] [HALL] Deleted hall:', hallSlug);
|
||||
return sendJson(res, { success: true, msg: 'Hall deleted' });
|
||||
};
|
||||
|
||||
// PATCH /api/v2/admin/halls/:slug — update name/description/slug
|
||||
export const handleHallUpdate = async (req, res) => {
|
||||
const session = await lookupSession(req);
|
||||
if (!session || (!session.admin && !session.is_moderator)) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
const hallSlug = req.params && req.params.slug;
|
||||
if (!hallSlug) return sendJson(res, { success: false, msg: 'Missing slug' }, 400);
|
||||
|
||||
// For PATCH requests, parse body manually since this is a bypass handler
|
||||
let body = {};
|
||||
try {
|
||||
const raw = await collectBody(req);
|
||||
body = JSON.parse(raw.toString('utf8'));
|
||||
} catch (e) {
|
||||
body = req.post || {};
|
||||
}
|
||||
|
||||
const { name, description } = body;
|
||||
const rawRating = body.rating;
|
||||
const rating = ['sfw', 'nsfw', 'nsfl'].includes(rawRating) ? rawRating : null;
|
||||
const rawSlug = body.slug;
|
||||
|
||||
// Handle slug rename
|
||||
if (rawSlug !== undefined) {
|
||||
const newSlug = rawSlug.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
if (!newSlug) return sendJson(res, { success: false, msg: 'Slug cannot be empty' }, 400);
|
||||
|
||||
if (newSlug !== hallSlug) {
|
||||
// Check for conflict with another hall
|
||||
const conflict = await db`SELECT id FROM halls WHERE slug = ${newSlug} LIMIT 1`;
|
||||
if (conflict.length > 0) {
|
||||
return sendJson(res, { success: false, msg: 'A hall with that slug already exists' }, 409);
|
||||
}
|
||||
|
||||
// Rename custom image file on disk
|
||||
const oldFile = path.join(HALL_IMG_DIR, hallSlug + '.webp');
|
||||
const newFile = path.join(HALL_IMG_DIR, newSlug + '.webp');
|
||||
try {
|
||||
await fs.rename(oldFile, newFile);
|
||||
console.error('[HALL IMG] Renamed image:', hallSlug + '.webp', '->', newSlug + '.webp');
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') console.error('[HALL IMG] Failed to rename image:', e.message);
|
||||
}
|
||||
|
||||
// Update slug in DB and fix custom_image filename if set
|
||||
await db`
|
||||
UPDATE halls
|
||||
SET slug = ${newSlug},
|
||||
custom_image = CASE WHEN custom_image IS NOT NULL THEN ${newSlug + '.webp'} ELSE NULL END
|
||||
WHERE slug = ${hallSlug}
|
||||
`;
|
||||
|
||||
await clearHallCache(hallSlug);
|
||||
await clearHallCache(newSlug);
|
||||
|
||||
// Update name/description/rating under the new slug
|
||||
if (name !== undefined && name.trim()) {
|
||||
await db`UPDATE halls SET name = ${name.trim()} WHERE slug = ${newSlug}`;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
await db`UPDATE halls SET description = ${description} WHERE slug = ${newSlug}`;
|
||||
}
|
||||
if (rating) {
|
||||
await db`UPDATE halls SET rating = ${rating} WHERE slug = ${newSlug}`;
|
||||
}
|
||||
|
||||
await audit.log(session.id, 'rename_hall', 'hall', newSlug, { old_slug: hallSlug, new_slug: newSlug, name: name?.trim(), description });
|
||||
return sendJson(res, { success: true, msg: 'Hall updated', newSlug });
|
||||
}
|
||||
}
|
||||
|
||||
// No slug change — just update name/description/rating
|
||||
if (name !== undefined && name.trim()) {
|
||||
await db`UPDATE halls SET name = ${name.trim()} WHERE slug = ${hallSlug}`;
|
||||
}
|
||||
if (description !== undefined) {
|
||||
await db`UPDATE halls SET description = ${description} WHERE slug = ${hallSlug}`;
|
||||
}
|
||||
if (rating) {
|
||||
await db`UPDATE halls SET rating = ${rating} WHERE slug = ${hallSlug}`;
|
||||
}
|
||||
|
||||
await audit.log(session.id, 'update_hall', 'hall', hallSlug, { name: name?.trim(), description });
|
||||
return sendJson(res, { success: true, msg: 'Hall updated' });
|
||||
};
|
||||
|
||||
// POST /api/v2/admin/halls — create a new hall
|
||||
export const handleHallCreate = async (req, res) => {
|
||||
const session = await lookupSession(req);
|
||||
if (!session || (!session.admin && !session.is_moderator)) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
let body = {};
|
||||
try {
|
||||
const raw = await collectBody(req);
|
||||
body = JSON.parse(raw.toString('utf8'));
|
||||
} catch (e) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
|
||||
const name = (body.name || '').trim();
|
||||
const slug = (body.slug || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const rawRating = body.rating;
|
||||
const rating = ['sfw', 'nsfw', 'nsfl'].includes(rawRating) ? rawRating : 'sfw';
|
||||
|
||||
if (!name) return sendJson(res, { success: false, msg: 'Name is required' }, 400);
|
||||
if (!slug) return sendJson(res, { success: false, msg: 'Could not derive a valid slug from the name' }, 400);
|
||||
|
||||
// Check for duplicate slug
|
||||
const existing = await db`SELECT id FROM halls WHERE slug = ${slug} LIMIT 1`;
|
||||
if (existing.length > 0) return sendJson(res, { success: false, msg: 'A hall with that slug already exists' }, 409);
|
||||
|
||||
await db`INSERT INTO halls (name, slug, description, rating) VALUES (${name}, ${slug}, '', ${rating})`;
|
||||
|
||||
await audit.log(session.id, 'create_hall', 'hall', slug, { name, slug });
|
||||
console.error('[HALL] Created hall:', slug, '-', name);
|
||||
return sendJson(res, { success: true, msg: 'Hall created', slug });
|
||||
};
|
||||
22
src/inc/admin.mjs
Normal file
22
src/inc/admin.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
import config from "./config.mjs";
|
||||
|
||||
export const getLevel = user => {
|
||||
let ret = {
|
||||
level: 0,
|
||||
verified: false
|
||||
};
|
||||
if (typeof user !== "object")
|
||||
return "user has to be an object!";
|
||||
if (!user.prefix)
|
||||
return ret;
|
||||
|
||||
let admin;
|
||||
if(admin = config.admins.filter(e => e.prefix === user.prefix)[0]) {
|
||||
ret = {
|
||||
level: admin.level,
|
||||
verified: true
|
||||
};
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
23
src/inc/audit.mjs
Normal file
23
src/inc/audit.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import db from "./sql.mjs";
|
||||
|
||||
export default new class {
|
||||
/**
|
||||
* Log an action to the audit log.
|
||||
* @param {number} userId - The ID of the user performing the action.
|
||||
* @param {string} action - Short description of the action (e.g. 'delete_item').
|
||||
* @param {string} targetType - The type of target (e.g. 'item', 'comment', 'user').
|
||||
* @param {string} targetId - The ID of the target.
|
||||
* @param {object} details - Additional JSON details.
|
||||
*/
|
||||
async log(userId, action, targetType, targetId, details = {}) {
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO audit_log (user_id, action, target_type, target_id, details)
|
||||
VALUES (${userId}, ${action}, ${targetType}, ${targetId}, ${db.json(details)})
|
||||
`;
|
||||
console.log(`[AUDIT] User ${userId} performed ${action} on ${targetType}:${targetId}`);
|
||||
} catch (err) {
|
||||
console.error('[AUDIT] Failed to write log:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
32
src/inc/autotagger.mjs
Normal file
32
src/inc/autotagger.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import fetch from 'flumm-fetch';
|
||||
import cfg from './config.mjs';
|
||||
|
||||
export default new class autotagger {
|
||||
async isNSFW(filename, filesize) {
|
||||
let opts = {
|
||||
method: 'POST',
|
||||
};
|
||||
let apiurl;
|
||||
if(filesize < 4194304) {
|
||||
apiurl = cfg.apis.nsfw1.url;
|
||||
opts.headers = cfg.apis.nsfw1.headers;
|
||||
opts.body = JSON.stringify({
|
||||
DataRepresentation: "URL",
|
||||
Value: `${cfg.main.url.full}/b/${filename}`
|
||||
});
|
||||
}
|
||||
else {
|
||||
apiurl = cfg.apis.nsfw2.url;
|
||||
opts.headers = cfg.apis.nsfw2.headers;
|
||||
opts.body = JSON.stringify({
|
||||
url: `${cfg.main.url.full}/b/${filename}`
|
||||
})
|
||||
}
|
||||
const res = await (await fetch(apiurl, opts)).json();
|
||||
|
||||
if(filesize < 4194304)
|
||||
return res.IsImageAdultClassified || res.RacyClassificationScore > 0.6;
|
||||
else
|
||||
return res.unsafe;
|
||||
};
|
||||
};
|
||||
53
src/inc/config.mjs
Normal file
53
src/inc/config.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import _config from "../../config.json" with { type: "json" };
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
let config = JSON.parse(JSON.stringify(_config));
|
||||
|
||||
// Environment variable overrides for database connection
|
||||
if (process.env.DB_HOST) config.sql.host = process.env.DB_HOST;
|
||||
if (process.env.DB_USER) config.sql.user = process.env.DB_USER;
|
||||
if (process.env.DB_PASS) config.sql.password = process.env.DB_PASS;
|
||||
if (process.env.DB_NAME) config.sql.database = process.env.DB_NAME;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
config.main.development = false;
|
||||
}
|
||||
|
||||
const base = path.resolve();
|
||||
const storage = process.env.STORAGE_DIR;
|
||||
|
||||
const resolvePath = (defaultRel) => {
|
||||
const local = path.resolve(path.join(base, defaultRel));
|
||||
if (storage) {
|
||||
const absStorage = path.resolve(storage);
|
||||
if (defaultRel.startsWith('public/')) {
|
||||
const sub = defaultRel.replace('public/', '');
|
||||
if (sub === 's/emojis' || sub === 's/koepfe') {
|
||||
const storagePath = path.join(absStorage, sub.split('/').pop());
|
||||
if (fs.existsSync(storagePath)) return path.resolve(storagePath);
|
||||
return local;
|
||||
}
|
||||
return path.resolve(path.join(absStorage, sub));
|
||||
}
|
||||
return path.resolve(path.join(absStorage, defaultRel));
|
||||
}
|
||||
return local;
|
||||
};
|
||||
|
||||
config.paths = {
|
||||
a: resolvePath('public/a'),
|
||||
b: resolvePath('public/b'),
|
||||
t: resolvePath('public/t'),
|
||||
ca: resolvePath('public/ca'),
|
||||
s: path.join(base, 'public/s'),
|
||||
emojis: resolvePath('public/s/emojis'),
|
||||
koepfe: resolvePath('public/s/koepfe'),
|
||||
memes: resolvePath('public/memes'),
|
||||
pending: resolvePath('pending'),
|
||||
deleted: resolvePath('deleted'),
|
||||
logs: resolvePath('logs'),
|
||||
tmp: resolvePath('tmp')
|
||||
};
|
||||
|
||||
export default config;
|
||||
316
src/inc/events/callback_query.mjs
Normal file
316
src/inc/events/callback_query.mjs
Normal file
@@ -0,0 +1,316 @@
|
||||
import logger from "../log.mjs";
|
||||
import { getLevel } from "../../inc/admin.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import path from "path";
|
||||
|
||||
const tagkeyboard = id => {
|
||||
const tags = [{
|
||||
tag: 'music',
|
||||
id: 9124
|
||||
}, {
|
||||
tag: 'german',
|
||||
id: 9161
|
||||
}, {
|
||||
tag: 'cat',
|
||||
id: 559
|
||||
}, {
|
||||
tag: 'doggo',
|
||||
id: 10932
|
||||
}];
|
||||
return Promise.all(tags.map(async t => ({ text: `${await lib.hasTag(id, t.id) ? '✓ ' : ''}${t.tag}`, callback_data: `b_settag_${t.id}:${id}` })));
|
||||
};
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "callback_query",
|
||||
listener: "callback_query",
|
||||
f: async e => {
|
||||
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${e.message}`);
|
||||
|
||||
let [cmd, id] = e.opt.data.split(':');
|
||||
let f0ck;
|
||||
id = +id;
|
||||
|
||||
if (cmd.startsWith('b_settag_')) {
|
||||
const tagid = +cmd.replace('b_settag_', '');
|
||||
|
||||
if (!(await lib.getTags(id)).filter(tag => tag.id == tagid).length) {
|
||||
// insert
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
item_id: id,
|
||||
tag_id: tagid,
|
||||
user_id: 1
|
||||
})
|
||||
}
|
||||
`;
|
||||
}
|
||||
else {
|
||||
// delete
|
||||
await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = ${tagid}
|
||||
and item_id = ${id}
|
||||
`;
|
||||
}
|
||||
|
||||
const keyboard = await tagkeyboard(id);
|
||||
|
||||
return await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||
reply_markup: JSON.stringify({
|
||||
inline_keyboard: [[
|
||||
...keyboard
|
||||
], [
|
||||
{ text: 'back', callback_data: `b_back:${id}` }
|
||||
]]
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "b_tags":
|
||||
if (!id)
|
||||
return;
|
||||
|
||||
const keyboard = await tagkeyboard(id);
|
||||
|
||||
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||
reply_markup: JSON.stringify({
|
||||
inline_keyboard: [[
|
||||
...keyboard
|
||||
], [
|
||||
{ text: 'back', callback_data: `b_back:${id}` }
|
||||
]]
|
||||
})
|
||||
});
|
||||
break;
|
||||
case "b_back":
|
||||
if (!id)
|
||||
return;
|
||||
|
||||
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||
reply_markup: JSON.stringify({
|
||||
inline_keyboard: [[
|
||||
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||
], [
|
||||
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||
]]
|
||||
})
|
||||
});
|
||||
break;
|
||||
case "b_sfw":
|
||||
|
||||
if (!id)
|
||||
return;
|
||||
|
||||
if (!await lib.hasTag(id, 1)) {
|
||||
// insert
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
item_id: id,
|
||||
tag_id: 1, // sfw
|
||||
user_id: 1
|
||||
})
|
||||
}
|
||||
`;
|
||||
if (await lib.hasTag(id, 2)) {
|
||||
await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = 2
|
||||
and item_id = ${id}
|
||||
`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// delete
|
||||
await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = 1
|
||||
and item_id = ${id}
|
||||
`;
|
||||
}
|
||||
|
||||
return await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||
reply_markup: JSON.stringify({
|
||||
inline_keyboard: [[
|
||||
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||
], [
|
||||
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||
]]
|
||||
})
|
||||
});
|
||||
|
||||
break;
|
||||
case "b_nsfw":
|
||||
if (!id)
|
||||
return;
|
||||
|
||||
if (!await lib.hasTag(id, 2)) {
|
||||
// insert
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
item_id: id,
|
||||
tag_id: 2, // nsfw
|
||||
user_id: 1
|
||||
})
|
||||
}
|
||||
`;
|
||||
if (await lib.hasTag(id, 1)) {
|
||||
await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = 1
|
||||
and item_id = ${id}
|
||||
`;
|
||||
}
|
||||
}
|
||||
else {
|
||||
// delete
|
||||
await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = 2
|
||||
and item_id = ${id}
|
||||
`;
|
||||
}
|
||||
|
||||
return await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||
reply_markup: JSON.stringify({
|
||||
inline_keyboard: [[
|
||||
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||
], [
|
||||
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||
]]
|
||||
})
|
||||
});
|
||||
break;
|
||||
case "b_delete":
|
||||
if (id <= 1)
|
||||
return;
|
||||
|
||||
e.user = {
|
||||
prefix: `${e.raw.reply_to_message.from.username}!${e.raw.reply_to_message.from.id}@${e.network}`,
|
||||
nick: e.raw.reply_to_message.from.first_name,
|
||||
username: e.raw.reply_to_message.from.username,
|
||||
account: e.raw.reply_to_message.from.id.toString()
|
||||
};
|
||||
|
||||
f0ck = await db`
|
||||
select dest, mime, username, userchannel, usernetwork
|
||||
from "items"
|
||||
where
|
||||
id = ${id} and
|
||||
active = 'true'
|
||||
limit 1
|
||||
`;
|
||||
const level = getLevel(e.user).level;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
return await e.reply(`f0ck ${id}: f0ck not found`);
|
||||
}
|
||||
|
||||
if (
|
||||
(f0ck[0].username !== (e.user.nick || e.user.username) ||
|
||||
f0ck[0].userchannel !== e.channel ||
|
||||
f0ck[0].usernetwork !== e.network) &&
|
||||
level < 100
|
||||
) {
|
||||
return await e.reply(`f0ck ${id}: insufficient permissions`);
|
||||
}
|
||||
|
||||
if (~~(new Date() / 1e3) >= (f0ck[0].stamp + 600) && level < 100) {
|
||||
return await e.reply(`f0ck ${id}: too late lol`);
|
||||
}
|
||||
|
||||
await db`update "items" set active = 'false', is_deleted = true where id = ${id}`;
|
||||
|
||||
await fs.copyFile(path.join(cfg.paths.b, f0ck[0].dest), path.join(cfg.paths.deleted, 'b', f0ck[0].dest)).catch(_ => { });
|
||||
await fs.copyFile(path.join(cfg.paths.t, `${id}.webp`), path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.b, f0ck[0].dest)).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(_ => { });
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await fs.copyFile(path.join(cfg.paths.ca, `${id}.webp`), path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(_ => { });
|
||||
}
|
||||
|
||||
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||
reply_markup: JSON.stringify({
|
||||
inline_keyboard: [[
|
||||
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||
{ text: 'recover', callback_data: `b_recover:${id}` }
|
||||
], [
|
||||
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||
]]
|
||||
})
|
||||
});
|
||||
break;
|
||||
case "b_recover":
|
||||
if (id <= 1)
|
||||
return;
|
||||
|
||||
e.user = {
|
||||
prefix: `${e.raw.reply_to_message.from.username}!${e.raw.reply_to_message.from.id}@${e.network}`,
|
||||
nick: e.raw.reply_to_message.from.first_name,
|
||||
username: e.raw.reply_to_message.from.username,
|
||||
account: e.raw.reply_to_message.from.id.toString()
|
||||
};
|
||||
|
||||
f0ck = await db`
|
||||
select dest, mime
|
||||
from "items"
|
||||
where
|
||||
id = ${id} and
|
||||
active = 'false'
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
return await e.reply(`f0ck ${id}: f0ck not found`);
|
||||
}
|
||||
|
||||
await fs.copyFile(path.join(cfg.paths.deleted, 'b', f0ck[0].dest), path.join(cfg.paths.b, f0ck[0].dest)).catch(_ => { });
|
||||
await fs.copyFile(path.join(cfg.paths.deleted, 't', `${id}.webp`), path.join(cfg.paths.t, `${id}.webp`)).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', f0ck[0].dest)).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(_ => { });
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await fs.copyFile(path.join(cfg.paths.deleted, 'ca', `${id}.webp`), path.join(cfg.paths.ca, `${id}.webp`)).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(_ => { });
|
||||
}
|
||||
|
||||
await db`update "items" set active = 'true' where id = ${id}`;
|
||||
|
||||
await e.editMessageText(e.raw.chat.id, e.raw.message_id, e.message, {
|
||||
reply_markup: JSON.stringify({
|
||||
inline_keyboard: [[
|
||||
{ text: (await lib.hasTag(id, 1) ? '✓ ' : '') + 'sfw', callback_data: `b_sfw:${id}` },
|
||||
{ text: (await lib.hasTag(id, 2) ? '✓ ' : '') + 'nsfw', callback_data: `b_nsfw:${id}` },
|
||||
{ text: 'tags', callback_data: `b_tags:${id}` },
|
||||
{ text: '❌ delete', callback_data: `b_delete:${id}` }
|
||||
], [
|
||||
{ text: `open f0ck #${id}`, url: `${cfg.main.url.full}/${id}` }
|
||||
]]
|
||||
})
|
||||
});
|
||||
break;
|
||||
default:
|
||||
await e.reply('lol');
|
||||
}
|
||||
}
|
||||
}];
|
||||
|
||||
};
|
||||
25
src/inc/events/ctcp.mjs
Normal file
25
src/inc/events/ctcp.mjs
Normal file
@@ -0,0 +1,25 @@
|
||||
import logger from "../log.mjs";
|
||||
|
||||
const versions = [
|
||||
"AmIRC.1 (8 Bit) for Commodore Amiga 500",
|
||||
"HexChat 0.72 [x86] / Windows 95c [500MHz]"
|
||||
];
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "version",
|
||||
listener: "ctcp:version",
|
||||
f: e => {
|
||||
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ctcp:version ${e.message}`);
|
||||
e.write(`notice ${e.user.nick} :\u0001VERSION ${versions[~~(Math.random() * versions.length)]}\u0001`);
|
||||
}
|
||||
}, {
|
||||
name: "ping",
|
||||
listener: "ctcp:ping",
|
||||
f: e => {
|
||||
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ctcp:ping ${e.message}`);
|
||||
e.write(`notice ${e.user.nick} :${e.message}`);
|
||||
}
|
||||
}];
|
||||
};
|
||||
12
src/inc/events/error.mjs
Normal file
12
src/inc/events/error.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import logger from "../log.mjs";
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "error",
|
||||
listener: "error",
|
||||
f: e => {
|
||||
logger.error(e);
|
||||
}
|
||||
}];
|
||||
};
|
||||
18
src/inc/events/info.mjs
Normal file
18
src/inc/events/info.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import logger from "../log.mjs";
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "info",
|
||||
listener: "info",
|
||||
f: e => {
|
||||
logger.debug(e);
|
||||
}
|
||||
}, {
|
||||
name: "debug",
|
||||
listener: "debug",
|
||||
f: e => {
|
||||
logger.debug(e);
|
||||
}
|
||||
}];
|
||||
};
|
||||
76
src/inc/events/message.mjs
Normal file
76
src/inc/events/message.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import logger from "../log.mjs";
|
||||
import { getLevel } from "../../inc/admin.mjs";
|
||||
|
||||
const parseArgs = msg => {
|
||||
let args = msg.trim().split(" ")
|
||||
, cmd = args.shift();
|
||||
return {
|
||||
cmd: cmd.replace(/^(\.|\/|\!)/, ""),
|
||||
args: args
|
||||
};
|
||||
};
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "message",
|
||||
listener: "message",
|
||||
f: e => {
|
||||
logger.info(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${e.message}`);
|
||||
|
||||
// Sanitize Matrix messages to remove reply fallback
|
||||
// Matrix reply fallback format:
|
||||
// > <@user:example.com> message
|
||||
// > continued quote
|
||||
//
|
||||
// Actual reply
|
||||
if (e.type === 'matrix' && e.message) {
|
||||
let lines = e.message.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length && (lines[i].startsWith('>') || lines[i].trim() === '')) {
|
||||
i++;
|
||||
}
|
||||
// Only strip if we found reply blocks, otherwise keep original
|
||||
if (i > 0) {
|
||||
e.message = lines.slice(i).join('\n').trim();
|
||||
}
|
||||
}
|
||||
|
||||
let trigger;
|
||||
|
||||
if (e.media) {
|
||||
trigger = [...bot._trigger.entries()].filter(t => t[1].name === "parser");
|
||||
if (!e.message)
|
||||
e.message = "";
|
||||
} else {
|
||||
console.log(`[MESSAGE DEBUG] Processing message: '${e.message}' from ${e.user.nick} (${e.type}). Prefix: ${e.user.prefix}`);
|
||||
trigger = [...bot._trigger.entries()].filter(t => {
|
||||
const matchesCall = t[1].call.exec(e.message);
|
||||
const includesClient = t[1].clients.includes(e.type);
|
||||
const isActive = t[1].active;
|
||||
const userLevel = getLevel(e.user).level;
|
||||
const hasLevel = t[1].level <= userLevel;
|
||||
|
||||
if (matchesCall) {
|
||||
console.log(`[MESSAGE DEBUG] Matched trigger '${t[1].name}': ClientMatch=${includesClient}, Active=${isActive}, LevelMatch=${hasLevel} (UserLevel=${userLevel} vs Req=${t[1].level})`);
|
||||
}
|
||||
|
||||
return matchesCall && includesClient && isActive && hasLevel;
|
||||
});
|
||||
}
|
||||
|
||||
trigger.forEach(async t => {
|
||||
try {
|
||||
await t[1].f({ ...e, ...parseArgs(e.message) });
|
||||
console.log(`triggered > ${t[0]}`);
|
||||
}
|
||||
catch (err) {
|
||||
console.error(err);
|
||||
await e.reply(`${t[0]}: An error occured.`);
|
||||
logger.error(`${e.network} -> ${e.channel} -> ${e.user.nick}: ${err.toString ? err : JSON.stringify(err)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}];
|
||||
|
||||
};
|
||||
13
src/inc/halls_cache.mjs
Normal file
13
src/inc/halls_cache.mjs
Normal file
@@ -0,0 +1,13 @@
|
||||
import db from "./sql.mjs";
|
||||
|
||||
let hallsCache = [];
|
||||
|
||||
export const updateHallsCache = async () => {
|
||||
try {
|
||||
hallsCache = await db`SELECT id, name, slug, rating FROM halls ORDER BY name ASC`;
|
||||
} catch (e) {
|
||||
console.error('[HALLS CACHE] Failed to update:', e);
|
||||
}
|
||||
};
|
||||
|
||||
export const getHalls = () => hallsCache;
|
||||
57
src/inc/i18n.mjs
Normal file
57
src/inc/i18n.mjs
Normal file
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
const localesDir = join(resolve(), 'src/inc/locales');
|
||||
|
||||
function loadLocale(lang) {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(localesDir, `${lang}.json`), 'utf8'));
|
||||
} catch (e) {
|
||||
console.warn(`[i18n] Failed to load locale "${lang}":`, e.message);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a nested value from an object using dot-notation.
|
||||
* e.g. get(obj, 'nav.login') => obj.nav.login
|
||||
*/
|
||||
function deepGet(obj, key) {
|
||||
return key.split('.').reduce((o, k) => o?.[k], obj);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an i18n instance for the given language.
|
||||
* Falls back to English for any missing keys.
|
||||
* No cache: always reads fresh from disk so locale changes
|
||||
* take effect without restarting the server.
|
||||
*
|
||||
* @param {string} lang - language code, e.g. 'de' or 'en'
|
||||
* @returns {{ t: Function, lang: string }}
|
||||
*/
|
||||
export function createI18n(lang = 'en') {
|
||||
const primary = loadLocale(lang);
|
||||
const fallback = lang !== 'en' ? loadLocale('en') : {};
|
||||
|
||||
/**
|
||||
* Translate a dot-notation key.
|
||||
* Returns the translated string, falling back to the English string,
|
||||
* then to the raw key if nothing else matches.
|
||||
* Supports variable interpolation using {key} syntax.
|
||||
*
|
||||
* @param {string} key - e.g. 'nav.login'
|
||||
* @param {Object} [data] - optional variables to interpolate
|
||||
* @returns {string}
|
||||
*/
|
||||
function t(key, data = {}) {
|
||||
let str = deepGet(primary, key) ?? deepGet(fallback, key) ?? key;
|
||||
if (typeof str !== 'string') return str;
|
||||
Object.keys(data).forEach(k => {
|
||||
const escapedK = k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
str = str.replace(new RegExp(`\\{${escapedK}\\}`, 'g'), data[k]);
|
||||
});
|
||||
return str;
|
||||
}
|
||||
|
||||
return { t, lang };
|
||||
}
|
||||
340
src/inc/lib.mjs
Normal file
340
src/inc/lib.mjs
Normal file
@@ -0,0 +1,340 @@
|
||||
import crypto from "crypto";
|
||||
import util from "util";
|
||||
import db from "./sql.mjs";
|
||||
|
||||
import cfg from "./config.mjs";
|
||||
|
||||
|
||||
|
||||
const scrypt = util.promisify(crypto.scrypt);
|
||||
|
||||
const epochs = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
["second", 1]
|
||||
];
|
||||
const getDuration = timeAgoInSeconds => {
|
||||
for (let [name, seconds] of epochs) {
|
||||
const interval = ~~(timeAgoInSeconds / seconds);
|
||||
if (interval >= 1) return {
|
||||
interval: interval,
|
||||
epoch: name
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export default new class {
|
||||
escapeHTML(str) {
|
||||
if (!str) return "";
|
||||
return str.toString()
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||
};
|
||||
calcSpeed(b, s) {
|
||||
return (Math.round((b * 8 / s / 1e6) * 1e4) / 1e4);
|
||||
};
|
||||
timeAgo(date) {
|
||||
const duration = getDuration(~~((new Date() - new Date(date)) / 1e3));
|
||||
if (!duration) return "just now";
|
||||
const { interval, epoch } = duration;
|
||||
return `${interval} ${epoch}${interval === 1 ? "" : "s"} ago`;
|
||||
};
|
||||
md5(str) {
|
||||
return crypto.createHash('md5').update(str).digest("hex");
|
||||
};
|
||||
sha256(str) {
|
||||
return crypto.createHash('sha256').update(str).digest("hex");
|
||||
};
|
||||
getMode(mode) {
|
||||
let tmp;
|
||||
mode = Number(mode);
|
||||
switch (mode) {
|
||||
case 1: // nsfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
|
||||
break;
|
||||
case 2: // untagged
|
||||
tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
|
||||
break;
|
||||
case 3: // all
|
||||
tmp = "1 = 1";
|
||||
break;
|
||||
case 4: // nsfl
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0";
|
||||
break;
|
||||
default: // sfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
break;
|
||||
}
|
||||
return tmp;
|
||||
};
|
||||
createID() {
|
||||
return crypto.randomBytes(16).toString("hex") + Date.now().toString(24);
|
||||
};
|
||||
generateToken() {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
};
|
||||
genLink(env) {
|
||||
const link = [];
|
||||
if (env.tag) link.push("tag", env.tag);
|
||||
if (env.hall) link.push("h", env.hall);
|
||||
if (env.user) link.push("user", env.user, env.type ?? 'uploads');
|
||||
|
||||
let tmp = link.length === 0 ? '/' : link.join('/');
|
||||
if (!tmp.endsWith('/'))
|
||||
tmp = tmp + '/';
|
||||
if (!tmp.startsWith('/'))
|
||||
tmp = '/' + tmp;
|
||||
|
||||
// Build suffix with query params
|
||||
let suffix = env.strict ? '?strict=1' : '';
|
||||
|
||||
return {
|
||||
main: tmp,
|
||||
path: env.path ? env.path : '',
|
||||
suffix: suffix
|
||||
};
|
||||
};
|
||||
parseTag(tag) {
|
||||
if (!tag)
|
||||
return null;
|
||||
return decodeURIComponent(tag);
|
||||
}
|
||||
slugify(str) {
|
||||
if (!str) return "";
|
||||
return str.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
// Escape ILIKE wildcard characters in user-supplied strings
|
||||
escapeLike(str) {
|
||||
if (!str) return str;
|
||||
return str.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_');
|
||||
}
|
||||
|
||||
// async funcs
|
||||
async countf0cks() {
|
||||
const tagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const untagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where not exists (select 1 from tags_assign where item_id = items.id) and active = true
|
||||
`)[0].total;
|
||||
const sfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsfl = cfg.enable_nsfl ? +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true
|
||||
`)[0].total : 0;
|
||||
const deleted = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
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;
|
||||
const lastf0ck = +(await db`
|
||||
select max(id) as id
|
||||
from "items"
|
||||
`)[0].id;
|
||||
return {
|
||||
tagged,
|
||||
untagged,
|
||||
total: tagged + untagged,
|
||||
deleted,
|
||||
pending,
|
||||
untracked: lastf0ck - (tagged + untagged + deleted + pending),
|
||||
sfw,
|
||||
nsfw,
|
||||
nsfl: cfg.enable_nsfl ? nsfl : 0,
|
||||
};
|
||||
};
|
||||
async hash(str) {
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const derivedKey = await scrypt(str, salt, 64);
|
||||
return "$f0ck$" + salt + ":" + derivedKey.toString("hex");
|
||||
};
|
||||
async verify(str, hash) {
|
||||
const [salt, key] = hash.substring(6).split(":");
|
||||
const keyBuffer = Buffer.from(key, "hex");
|
||||
const derivedKey = await scrypt(str, salt, 64);
|
||||
return crypto.timingSafeEqual(keyBuffer, derivedKey);
|
||||
};
|
||||
async getTags(itemid) {
|
||||
const tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join user_options uo on uo.user_id = "user".id
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
for (let t = 0; t < tags.length; t++) {
|
||||
tags[t].badge = this.getBadge(tags[t]);
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
getBadge(tagObj) {
|
||||
if (tagObj.tag.startsWith(">"))
|
||||
return "badge-greentext badge-light";
|
||||
else if (tagObj.normalized === "ukraine")
|
||||
return "badge-ukraine badge-light";
|
||||
else if (/[а-яё]/.test(tagObj.normalized) || tagObj.normalized === "russia")
|
||||
return "badge-russia badge-light";
|
||||
else if (tagObj.normalized === "german")
|
||||
return "badge-german badge-light";
|
||||
else if (tagObj.normalized === "dutch")
|
||||
return "badge-dutch badge-light";
|
||||
else if (tagObj.normalized === "sfw")
|
||||
return "badge-success";
|
||||
else if (tagObj.normalized === "nsfw")
|
||||
return "badge-danger";
|
||||
else if (tagObj.normalized === "nsfl")
|
||||
return cfg.enable_nsfl ? "badge-nsfl" : "badge-light";
|
||||
else
|
||||
return "badge-light";
|
||||
};
|
||||
async hasTag(itemid, tagid) {
|
||||
const tag = (await db`
|
||||
select *
|
||||
from "tags_assign"
|
||||
where
|
||||
item_id = ${+itemid} and
|
||||
tag_id = ${+tagid}
|
||||
limit 1
|
||||
`).length;
|
||||
return !!tag;
|
||||
};
|
||||
// detectNSFW: removed — contained shell injection via exec() with unescaped `dest` parameter.
|
||||
// If re-implemented, use execFile() with argument arrays and a dedicated Python script.
|
||||
async getDefaultAvatar() {
|
||||
return (await db`
|
||||
select column_default as avatar
|
||||
from "information_schema"."columns"
|
||||
where
|
||||
TABLE_SCHEMA='public' and
|
||||
TABLE_NAME='user_options' and
|
||||
COLUMN_NAME = 'avatar'
|
||||
`)[0].avatar;
|
||||
};
|
||||
|
||||
// meddlware admin
|
||||
async auth(req, res, next) {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
// meddlware user
|
||||
async userauth(req, res, next) {
|
||||
if (!req.session) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout' && req.url.pathname !== '/settings') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
async loggedin(req, res, next) {
|
||||
if (!req.session) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
async modAuth(req, res, next) {
|
||||
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
async adminAuth(req, res, next) {
|
||||
if (!req.session || !req.session.admin) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
getCookieOptions(expires = null, httpOnly = true) {
|
||||
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
|
||||
let options = "Path=/; SameSite=Lax";
|
||||
if (httpOnly) options += "; HttpOnly";
|
||||
if (isSecure) options += "; Secure";
|
||||
if (expires) {
|
||||
if (typeof expires === 'number') {
|
||||
options += `; Max-Age=${expires}`;
|
||||
} else {
|
||||
options += `; Expires=${expires}`;
|
||||
}
|
||||
}
|
||||
this.debug(`[COOKIE DEBUG] full=${cfg.main.url.full}, isSecure=${isSecure}, options=${options}`);
|
||||
return options;
|
||||
}
|
||||
|
||||
debug(...args) {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(...args);
|
||||
}
|
||||
}
|
||||
|
||||
logError(err, context = "Internal Error") {
|
||||
const errId = crypto.randomUUID();
|
||||
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
||||
return `Internal Error. Reference: ${errId}`;
|
||||
}
|
||||
};
|
||||
206
src/inc/lib_delete.mjs
Normal file
206
src/inc/lib_delete.mjs
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* lib_delete.mjs — Safe file deletion helpers for symlink-deduplicated uploads.
|
||||
*
|
||||
* When bypass_duplicate_check is active, reposts are stored as relative symlinks
|
||||
* pointing at the original file. If the original item is deleted, we must not
|
||||
* remove the real file from disk as long as at least one other non-deleted item
|
||||
* still references it.
|
||||
*
|
||||
* Strategy for deleteMediaFile(dest):
|
||||
* 1. lstat(b/dest) to check if it's a symlink or a real file.
|
||||
* 2. If symlink → safe to just unlink it (other items are unaffected).
|
||||
* 3. If real file → query the DB for any other non-deleted item whose dest
|
||||
* resolves to the same real filename (i.e. items that are symlinks HERE
|
||||
* pointing at `dest`).
|
||||
* - If none → the file is exclusively owned; unlink it.
|
||||
* - If found → one of those symlinks must become the new canonical file.
|
||||
* Rename the real file to `survivorDest` and update its symlink to
|
||||
* point at itself (i.e. remove its symlink and leave the real file).
|
||||
* The item being deleted just loses its file entry (it was the real one).
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import db from './sql.mjs';
|
||||
import cfg from './config.mjs';
|
||||
|
||||
/**
|
||||
* Safely remove the media file for a deleted item.
|
||||
*
|
||||
* @param {string} dest - The `dest` filename of the item being deleted (e.g. "531f3737.webm")
|
||||
* @param {number} deletedId - The item ID being deleted (used to exclude it from survivor query)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function safeDeleteMediaFile(dest, deletedId) {
|
||||
const filePath = path.join(cfg.paths.b, dest);
|
||||
|
||||
let lstat;
|
||||
try {
|
||||
lstat = await fs.lstat(filePath);
|
||||
} catch (e) {
|
||||
// File doesn't exist in public dir — nothing to do
|
||||
return;
|
||||
}
|
||||
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Just a symlink — safe to remove, real data is unaffected
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
console.error(`[DELETE] Unlinked symlink b/${dest}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// It's a real file. Check if any other active (non-deleted, non-purged) item
|
||||
// references this filename via a symlink — i.e. items whose dest, when resolved,
|
||||
// matches `dest`.
|
||||
const survivors = await db`
|
||||
SELECT id, dest FROM items
|
||||
WHERE id != ${deletedId}
|
||||
AND is_deleted = false
|
||||
AND is_purged = false
|
||||
AND active = true
|
||||
AND dest IS NOT NULL
|
||||
`;
|
||||
|
||||
// Find which of those survivors are symlinks pointing at our file
|
||||
const symlinkSurvivors = [];
|
||||
for (const s of survivors) {
|
||||
const sPath = path.join(cfg.paths.b, s.dest);
|
||||
try {
|
||||
const sLstat = await fs.lstat(sPath);
|
||||
if (sLstat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(sPath);
|
||||
// target is relative (e.g. "531f3737.webm"), resolve relative to b/
|
||||
const resolvedTarget = path.resolve(path.dirname(sPath), target);
|
||||
if (resolvedTarget === filePath) {
|
||||
symlinkSurvivors.push(s);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip missing files
|
||||
}
|
||||
}
|
||||
|
||||
if (symlinkSurvivors.length === 0) {
|
||||
// No other item references this real file — safe to unlink
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
console.error(`[DELETE] Unlinked real file b/${dest} (no surviving references)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// At least one surviving item symlinks to this file.
|
||||
// Promote the first survivor: rename the real file to its dest name,
|
||||
// and remove its old symlink. All other survivors then need to point at
|
||||
// the new canonical name.
|
||||
const promoted = symlinkSurvivors[0];
|
||||
const promotedPath = path.join(cfg.paths.b, promoted.dest);
|
||||
|
||||
try {
|
||||
// Remove the promoted item's symlink
|
||||
await fs.unlink(promotedPath);
|
||||
// Move the real file to the promoted dest name
|
||||
await fs.rename(filePath, promotedPath);
|
||||
console.error(`[DELETE] Promoted b/${promoted.dest} as new canonical file (was b/${dest})`);
|
||||
|
||||
// Update remaining symlinks to point at the new canonical name
|
||||
for (const s of symlinkSurvivors.slice(1)) {
|
||||
const sPath = path.join(cfg.paths.b, s.dest);
|
||||
try {
|
||||
await fs.unlink(sPath);
|
||||
await fs.symlink(promoted.dest, sPath);
|
||||
console.error(`[DELETE] Re-targeted symlink b/${s.dest} → ${promoted.dest}`);
|
||||
} catch (e) {
|
||||
console.error(`[DELETE] Failed to re-target b/${s.dest}:`, e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[DELETE] Failed to promote b/${promoted.dest}:`, e.message);
|
||||
// Last resort: just unlink original (data may be lost, but avoid leaving dangling state)
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a media file from b/ to deleted/b/ safely (for soft-delete / trash flow).
|
||||
* If the file is a symlink, we copy the real data to deleted/b/ so it's self-contained,
|
||||
* then remove the symlink from b/.
|
||||
* If it's the real file and other items reference it, promote a survivor first.
|
||||
*
|
||||
* @param {string} dest
|
||||
* @param {number} deletedId
|
||||
*/
|
||||
export async function moveToDeleted(dest, deletedId) {
|
||||
const srcPath = path.join(cfg.paths.b, dest);
|
||||
const dstPath = path.join(cfg.paths.deleted, 'b', dest);
|
||||
|
||||
let lstat;
|
||||
try {
|
||||
lstat = await fs.lstat(srcPath);
|
||||
} catch (e) {
|
||||
return; // Nothing to move
|
||||
}
|
||||
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Copy the resolved real data to deleted/ (so the trash viewer works standalone)
|
||||
await fs.copyFile(srcPath, dstPath).catch(() => {});
|
||||
await fs.unlink(srcPath).catch(() => {});
|
||||
console.error(`[DELETE] Moved symlink b/${dest} to deleted/ (copied real data)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Real file — check for survivors before removing from b/
|
||||
const survivors = await db`
|
||||
SELECT id, dest FROM items
|
||||
WHERE id != ${deletedId}
|
||||
AND is_deleted = false
|
||||
AND is_purged = false
|
||||
AND active = true
|
||||
AND dest IS NOT NULL
|
||||
`;
|
||||
|
||||
const symlinkSurvivors = [];
|
||||
for (const s of survivors) {
|
||||
const sPath = path.join(cfg.paths.b, s.dest);
|
||||
try {
|
||||
const sLstat = await fs.lstat(sPath);
|
||||
if (sLstat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(sPath);
|
||||
const resolvedTarget = path.resolve(path.dirname(sPath), target);
|
||||
if (resolvedTarget === srcPath) {
|
||||
symlinkSurvivors.push(s);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// Copy real data to deleted/ regardless
|
||||
await fs.copyFile(srcPath, dstPath).catch(() => {});
|
||||
|
||||
if (symlinkSurvivors.length === 0) {
|
||||
// No survivors — remove the real file from b/
|
||||
await fs.unlink(srcPath).catch(() => {});
|
||||
console.error(`[DELETE] Moved real file b/${dest} to deleted/`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Promote a survivor to hold the real file
|
||||
const promoted = symlinkSurvivors[0];
|
||||
const promotedPath = path.join(cfg.paths.b, promoted.dest);
|
||||
|
||||
try {
|
||||
await fs.unlink(promotedPath); // remove symlink
|
||||
await fs.rename(srcPath, promotedPath); // real file takes promoted's name
|
||||
console.error(`[DELETE] Promoted b/${promoted.dest} as canonical; moved b/${dest} to deleted/`);
|
||||
|
||||
// Re-target remaining survivors to point at the new canonical name
|
||||
for (const s of symlinkSurvivors.slice(1)) {
|
||||
const sPath = path.join(cfg.paths.b, s.dest);
|
||||
try {
|
||||
await fs.unlink(sPath);
|
||||
await fs.symlink(promoted.dest, sPath);
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[DELETE] Promotion failed for b/${promoted.dest}:`, e.message);
|
||||
await fs.unlink(srcPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
556
src/inc/locales/de.json
Normal file
556
src/inc/locales/de.json
Normal file
@@ -0,0 +1,556 @@
|
||||
{
|
||||
"nav": {
|
||||
"upload": "hochladen",
|
||||
"meme": "meme",
|
||||
"halls": "Hallen",
|
||||
"tags": "Tags",
|
||||
"search": "Suchen",
|
||||
"random": "Zufall",
|
||||
"profile": "Profil",
|
||||
"my_halls": "Meine Hallen",
|
||||
"favs": "Favoriten",
|
||||
"admin": "Admin",
|
||||
"mod": "mod",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmelden",
|
||||
"notifications": "Nuttis",
|
||||
"mark_all_read": "Alle als gelesen markieren",
|
||||
"no_notifications": "Keine neuen Nuttis",
|
||||
"view_all_notifications": "Alle Nuttis anzeigen",
|
||||
"manage_subscriptions": "Abonnements verwalten",
|
||||
"favorites": "Favoriten",
|
||||
"direct_messages": "Direktnachrichten",
|
||||
"excluded_tags": "Ausgeschlossene Tags",
|
||||
"guest": "Gast",
|
||||
"login": "Anmelden",
|
||||
"register": "Registrieren",
|
||||
"overview": "Übersicht",
|
||||
"prev": "zurück",
|
||||
"next": "weiter",
|
||||
"random_nav": "Zufall"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Hochladen",
|
||||
"drop_anywhere": "Fallen lassen zum Hochladen",
|
||||
"unlimited": "Unbegrenzte Uploads",
|
||||
"limit_reached": "Upload-Limit erreicht",
|
||||
"remaining": "Uploads übrig",
|
||||
"file_tab": "Datei hochladen",
|
||||
"url_tab": "URL",
|
||||
"url_tab_yt": "URL / YouTube",
|
||||
"url_placeholder": "URL zum Herunterladen einfügen...",
|
||||
"url_placeholder_yt": "URL oder YouTube-Link einfügen...",
|
||||
"drop_here": "Datei hier fallen lassen oder auswählen",
|
||||
"admin_boost": "Admin-Boost",
|
||||
"custom_thumbnail": "Eigenes Vorschaubild",
|
||||
"custom_thumbnail_hint": "Falls nicht gesetzt, wird das Standard-Vorschaubild verwendet",
|
||||
"rating": "Rating",
|
||||
"original_content": "OC",
|
||||
"tags": "Tags",
|
||||
"tags_minimum": "Minimum",
|
||||
"extracting_title": "Titel wird ermittelt...",
|
||||
"tag_placeholder": "Tag eingeben und Enter drücken",
|
||||
"suggestions_header": "Vorschläge aus Metadaten",
|
||||
"comment": "Kommentar",
|
||||
"comment_optional": "(optional)",
|
||||
"comment_placeholder": "Kommentar zum Upload hinzufügen...",
|
||||
"select_file": "Datei auswählen",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"pending_approval_patient": "Upload wartet auf Freigabe, bitte haben Sie etwas Geduld",
|
||||
"remove_file": "Datei entfernen",
|
||||
"cancel_upload": "Upload abbrechen"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Wird registriert...",
|
||||
"already_logged_in": "Bereits eingeloggt",
|
||||
"login_title": "Anmelden",
|
||||
"username_placeholder": "Benutzername",
|
||||
"username_or_email": "Benutzername oder E-Mail",
|
||||
"password_placeholder": "Passwort",
|
||||
"password_placeholder_min": "Passwort",
|
||||
"stay_signed_in": "Angemeldet bleiben",
|
||||
"stay_signed_in_label": "Angemeldet bleiben",
|
||||
"forgot_password": "Passwort vergessen?",
|
||||
"no_account": "Noch kein Konto?",
|
||||
"register_now": "Jetzt registrieren!",
|
||||
"register_title": "Registrieren",
|
||||
"password_min_hint": "Mindestens 20 Zeichen erforderlich.",
|
||||
"confirm_password": "Passwort bestätigen",
|
||||
"email_placeholder": "E-Mail",
|
||||
"invite_token": "Einladungstoken",
|
||||
"tos_private": "Ich bin mindestens 18 Jahre alt und stimme den Regeln zu",
|
||||
"tos_public": "Ich habe gelesen und akzeptiere die",
|
||||
"tos_terms": "Nutzungsbedingungen",
|
||||
"tos_rules": "Regeln",
|
||||
"tos_age": "und ich bin mindestens 18 Jahre alt",
|
||||
"tos_private_simple": "Ich bin mindestens 18 Jahre alt",
|
||||
"create_account": "Konto erstellen",
|
||||
"back_to_login": "Zurück zur Anmeldung",
|
||||
"forgot_title": "Passwort vergessen",
|
||||
"forgot_desc": "E-Mail-Adresse eingeben, um einen Zurücksetzungslink zu erhalten.",
|
||||
"email_address": "E-Mail-Adresse",
|
||||
"send_reset": "Zurücksetzungslink senden",
|
||||
"reset_title": "Passwort zurücksetzen",
|
||||
"reset_desc": "Neues Passwort eingeben.",
|
||||
"new_password_min": "Neues Passwort (min. 20 Zeichen)",
|
||||
"confirm_new_password": "Neues Passwort bestätigen",
|
||||
"update_password": "Passwort aktualisieren",
|
||||
"password_change_required": "Passwortänderung erforderlich",
|
||||
"password_change_desc": "Ein Administrator hat angefragt, dass du dein Passwort änderst, bevor du weitermachst. Das Passwort muss mindestens 20 Zeichen lang sein.",
|
||||
"new_password_label": "Neues Passwort",
|
||||
"confirm_password_label": "Passwort bestätigen",
|
||||
"min_chars_placeholder": "Mindestens 20 Zeichen",
|
||||
"confirm_placeholder": "Neues Passwort bestätigen",
|
||||
"update_password_btn": "Passwort aktualisieren",
|
||||
"or_logout": "Oder von diesem Konto abmelden"
|
||||
},
|
||||
"settings": {
|
||||
"switching": "Wird umgeschaltet...",
|
||||
"generating": "Wird generiert...",
|
||||
"title": "Einstellungen",
|
||||
"avatar": "Avatar",
|
||||
"current_avatar": "Aktueller Avatar",
|
||||
"upload_custom_avatar": "Eigenen Avatar hochladen",
|
||||
"avatar_hint": "Max. 5 MB. Erlaubt: gif, jpg, png, webp",
|
||||
"choose_file": "Datei auswählen",
|
||||
"no_file_selected": "Keine Datei ausgewählt",
|
||||
"upload_btn": "Hochladen",
|
||||
"remove_custom": "Eigenen entfernen",
|
||||
"custom_description": "Eigene Beschreibung",
|
||||
"description_placeholder": "Beschreibung zum Profil hinzufügen",
|
||||
"save_description": "Beschreibung speichern",
|
||||
"clear": "Löschen",
|
||||
"preferences": "Einstellungen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||
"disable_autoplay_hint": "Verhindert die automatische Wiedergabe von Videos und Audio",
|
||||
"disable_swiping": "Wischen deaktivieren",
|
||||
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
"enable_bg_blur_hint": "Unscharfen Hintergrund bei Beiträgen anzeigen",
|
||||
"render_emojis": "Emojis in Zitatantworten anzeigen",
|
||||
"render_emojis_hint": ":emoji:-Bilder in >zitierten Zeilen anzeigen",
|
||||
"embed_yt": "YouTube-Videos in Kommentaren einbetten",
|
||||
"embed_yt_hint": "YouTube-Videos durch eingebettete Videoplayer ersetzen",
|
||||
"hide_koepfe": "Köpfe ausblenden",
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"language": "Sprache",
|
||||
"language_hint": "Seitensprache ändern. Lädt die Seite zur Übernahme neu.",
|
||||
"language_default": "Standard (Seite)",
|
||||
"language_en": "English",
|
||||
"language_de": "Deutsch",
|
||||
"language_nl": "Kokomehl",
|
||||
"language_zange": "Zangendeutsch",
|
||||
"scroll_nav": "Mausrad-Navigation bei Beiträgen",
|
||||
"scroll_nav_hint": "Mit dem Mausrad auf dem Medienbereich zum nächsten/vorherigen Beitrag navigieren",
|
||||
"username_color": "Eigene Benutzernamen-Farbe",
|
||||
"username_color_hint": "Farbe auswählen oder Hex-Code für deinen Benutzernamen bei Beiträgen und Kommentaren eingeben.",
|
||||
"save_color": "Farbe speichern",
|
||||
"reset": "Zurücksetzen",
|
||||
"website_font": "Website-Schrift",
|
||||
"font_default": "Standard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standard-Flash-Lautstärke",
|
||||
"flash_bg": "Flash im Hintergrund abspielen",
|
||||
"flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Tab verlassen wird",
|
||||
"save_flash": "Flash-Einstellungen speichern",
|
||||
"content_filters": "Inhaltsfilter",
|
||||
"min_xd_score": "Mindest-xD-Score",
|
||||
"min_xd_score_hint": "Nur Beiträge mit mindestens diesem xD-Score anzeigen. Auf 0 setzen zum Deaktivieren.",
|
||||
"save": "Speichern",
|
||||
"account": "Konto",
|
||||
"user_id": "Benutzer-ID",
|
||||
"username": "Benutzername",
|
||||
"display_name": "Anzeigename",
|
||||
"display_name_placeholder": "Anzeigename",
|
||||
"email": "E-Mail",
|
||||
"email_not_set": "Nicht festgelegt",
|
||||
"joined": "Beigetreten",
|
||||
"joined_unknown": "Unbekannt",
|
||||
"change_password": "Passwort ändern",
|
||||
"current_password": "Aktuelles Passwort",
|
||||
"new_password": "Neues Passwort (min. 20 Zeichen)",
|
||||
"confirm_new_password": "Neues Passwort bestätigen",
|
||||
"update_password": "Passwort aktualisieren",
|
||||
"update_email": "E-Mail aktualisieren",
|
||||
"new_email": "Neue E-Mail-Adresse",
|
||||
"update_email_btn": "E-Mail aktualisieren",
|
||||
"email_warning_smtp": "Falls keine gültige E-Mail-Adresse angegeben wird, kann das Passwort im Falle eines Vergessens nicht zurückgesetzt werden!",
|
||||
"email_info_no_smtp": "Die E-Mail muss <b>nicht</b> gültig sein. Sie wird neben dem Benutzernamen für die Anmeldung verwendet.<br> Es wird keine Bestätigung gesendet.",
|
||||
"linked_accounts": "Verknüpfte Konten",
|
||||
"matrix_link_desc": "Matrix verknüpfen, um die Upload-Funktion aus Matrix-Räumen zu nutzen",
|
||||
"active_links": "Aktive Verknüpfungen:",
|
||||
"loading": "Wird geladen...",
|
||||
"add_new_link": "Neue Verknüpfung hinzufügen",
|
||||
"matrix_instructions": "1. Unten ein allgemeines Link-Token generieren.<br>2. <code>!link</code> im Hauptraum senden<br>3. Token in der Bot-DM eingeben",
|
||||
"your_token": "Dein Token:",
|
||||
"one_time_use": "Einmalig verwendbar.",
|
||||
"generate_token": "Link-Token generieren"
|
||||
},
|
||||
"filter": {
|
||||
"tag_placeholder": "Tag ausschließen",
|
||||
"random_mode": "RAND",
|
||||
"min_xd_score": "Min. xD-Score",
|
||||
"apply_filter": "Filter anwenden",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Bild",
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Tastaturkürzel",
|
||||
"search": "Suchen",
|
||||
"main_page": "Startseite",
|
||||
"random": "Zufällig",
|
||||
"shuffle": "Zufallsmodus",
|
||||
"quick_upload": "Schnell-Upload",
|
||||
"toggle_comments": "Kommentare ein-/ausblenden",
|
||||
"toggle_sidebar": "Seitenleiste ein-/ausblenden",
|
||||
"focus_comment": "Kommentarfeld fokussieren",
|
||||
"send_comment": "Kommentar senden",
|
||||
"flash_yank": "Flash-Yank ein-/ausschalten",
|
||||
"tag_exclude": "Tag-Ausschluss öffnen",
|
||||
"tag_input": "Tag-Eingabe öffnen",
|
||||
"toggle_bg": "Hintergrund ein-/ausschalten",
|
||||
"cycle_themes": "Themes wechseln",
|
||||
"delete": "Löschen",
|
||||
"toggle_rating": "Beitragsbewertung umschalten",
|
||||
"play_pause": "Abspielen/Pause",
|
||||
"next_prev": "Weiter/Zurück",
|
||||
"scroll_nav": "Weiter/Zurück",
|
||||
"show_help": "Hilfe anzeigen"
|
||||
},
|
||||
"notifications": {
|
||||
"system": "System",
|
||||
"admin": "Admin",
|
||||
"moderation": "Moderation",
|
||||
"upload_approved": "Dein Upload #{id} wurde genehmigt",
|
||||
"upload_approved_short": "Dein Upload wurde genehmigt",
|
||||
"upload_pending": "Ein neuer Upload (#{id}) wartet auf Genehmigung",
|
||||
"upload_pending_short": "Ein neuer Upload wartet auf Genehmigung",
|
||||
"new_report": "Ein neuer Benutzerbericht (#{id}) wurde eingereicht",
|
||||
"new_report_short": "Ein neuer Benutzerbericht wurde eingereicht",
|
||||
"upload_denied": "Dein Upload #{id} wurde abgelehnt",
|
||||
"upload_denied_short": "Dein Upload wurde abgelehnt",
|
||||
"reason": "Grund:",
|
||||
"reason_label": "Grund:",
|
||||
"no_reason": "Kein Grund angegeben",
|
||||
"upload_deleted": "Ein Moderator hat deinen Upload #{id} gelöscht",
|
||||
"upload_deleted_short": "Ein Moderator hat deinen Upload gelöscht",
|
||||
"click_reason": "Klicken für Begründung",
|
||||
"new_comments": "Neue Kommentare",
|
||||
"on_your_upload": "zu deinem Upload #{id}",
|
||||
"replied": "hat auf #{id} geantwortet",
|
||||
"replied_short": "hat dir geantwortet",
|
||||
"subscribed": "hat in einem Thread kommentiert, dem du folgst (#{id})",
|
||||
"subscribed_short": "hat in einem Thread kommentiert, dem du folgst",
|
||||
"mentioned": "hat dich auf #{id} erwähnt",
|
||||
"mentioned_short": "hat dich erwähnt",
|
||||
"commented": "hat kommentiert",
|
||||
"upload_success": "Dein Hintergrund-Auflad ist fertig!",
|
||||
"upload_error": "Dein Hintergrund-Auflad ist fehlgeschlagen.",
|
||||
"page_title": "BENACHRICHTIGUNGEN",
|
||||
"mark_all_read": "Alle als gelesen markieren",
|
||||
"scroll_for_more": "Scrollen für mehr..."
|
||||
},
|
||||
"error": {
|
||||
"label": "Fehler"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Überall ablegen zum Hochladen"
|
||||
},
|
||||
"comments": {
|
||||
"write_comment": "Kommentar schreiben...",
|
||||
"post": "Abschnalzen",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Datei auswählen",
|
||||
"enter_url": "URL eingeben",
|
||||
"tags_required": "{n} Tag(s) noch erforderlich",
|
||||
"select_rating": "SFW oder NSFW auswählen",
|
||||
"embed_youtube": "YouTube-Video einbetten",
|
||||
"upload_from_url": "Von URL hochladen",
|
||||
"upload": "Hochladen"
|
||||
},
|
||||
"common": {
|
||||
"sending": "Wird versendet...",
|
||||
"updating": "Wird aktualisiert...",
|
||||
"saving": "Wird gespeichert...",
|
||||
"loading": "Laden...",
|
||||
"cancel": "Abbrechen",
|
||||
"add": "Hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"close": "Schließen",
|
||||
"submit": "Absenden",
|
||||
"confirm": "Bestätigen",
|
||||
"send": "Senden",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"private": "Privat",
|
||||
"view": "Ansehen"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Aktion bestätigen",
|
||||
"reason_placeholder": "Grund für diese Aktion...",
|
||||
"confirm": "Bestätigen"
|
||||
},
|
||||
"toptags": {
|
||||
"posts": "{count} Beiträge"
|
||||
},
|
||||
"hall": {
|
||||
"modal_title": "Zu Halle hinzufügen",
|
||||
"site_hall_label": "Seitenhalle (mod):",
|
||||
"choose_hall": "-- Halle auswählen --",
|
||||
"add_btn": "Zu Halle hinzufügen",
|
||||
"remove_btn": "Aus Halle entfernen",
|
||||
"my_hall_label": "Meine Hallen:",
|
||||
"my_hall_placeholder": "-- Meine Halle --",
|
||||
"create_new": "oder neu erstellen:",
|
||||
"new_name_placeholder": "Neuer Hall-Name…",
|
||||
"no_halls": "Noch keine Hallen — erstelle eine!",
|
||||
"select_a_hall": "Bitte eine Halle auswählen.",
|
||||
"choose_or_create": "Halle auswählen oder neuen Namen eingeben.",
|
||||
"creating": "Wird erstellt…",
|
||||
"adding": "Wird hinzugefügt…",
|
||||
"removing": "Wird entfernt…",
|
||||
"posts": "{count} Beiträge",
|
||||
"user_halls_title": "Hallen von {user}",
|
||||
"new_hall_placeholder": "Neuer Hall-Name…",
|
||||
"new_hall_btn": "+ Neue Halle",
|
||||
"enter_name_error": "Bitte zuerst einen Namen eingeben.",
|
||||
"no_halls_owner": "Du hast noch keine Hallen erstellt. Gib oben einen Namen ein und klicke auf <strong>+ Neue Halle</strong>!",
|
||||
"no_halls_guest": "Hier gibt es noch keine Hallen.",
|
||||
"manager_title": "Hallen verwalten",
|
||||
"manager_desc": "Verwalte alle Hallen — Beschreibungen bearbeiten, eigene Bilder hochladen oder Hallen löschen.",
|
||||
"create_hall_btn": "+ Halle erstellen",
|
||||
"slug": "Slug",
|
||||
"rating": "Bewertung",
|
||||
"click_upload_hint": "Klicken, um ein eigenes Bild hochzuladen",
|
||||
"delete_confirm_slug": "Halle \"{slug}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"delete_confirm": "Diese Halle löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"deleting": "Löschen…",
|
||||
"saving": "Speichern…",
|
||||
"saved": "Gespeichert",
|
||||
"error_label": "Fehler",
|
||||
"slug_empty_error": "Slug darf nicht leer sein",
|
||||
"uploading": "Hochladen…",
|
||||
"image_uploaded": "Bild hochgeladen",
|
||||
"image_removed": "Eigenes Bild entfernt"
|
||||
},
|
||||
"content_warning": {
|
||||
"title": "Inhaltswarnung",
|
||||
"text": "Diese Website enthält nutzergenerierte Inhalte, die Material enthalten können, das nicht für alle Altersgruppen oder Zielgruppen geeignet ist, einschließlich Lichtblitzen, die bei Menschen mit lichtempfindlicher Epilepsie Anfälle auslösen können. Wenn du fortfährst, bestätigst du, dass du bereit bist, solche Inhalte anzusehen.",
|
||||
"proceed": "Weiter",
|
||||
"decline": "Zurück zur Startseite"
|
||||
},
|
||||
"report": {
|
||||
"title": "Meldung einreichen",
|
||||
"placeholder": "Bitte gib Details für diese Meldung an (erforderlich)...",
|
||||
"submit": "Absenden"
|
||||
},
|
||||
"account_warning": {
|
||||
"title": "Kontowarnung",
|
||||
"text": "Du hast eine Verwarnung von einem Moderator erhalten:",
|
||||
"acknowledge": "Verstanden"
|
||||
},
|
||||
"sidebar": {
|
||||
"loading_activity": "Aktivität wird geladen...",
|
||||
"view": "Ansehen",
|
||||
"read_more": "mehr sehen",
|
||||
"see_less": "weniger anzeigen"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Meine Abonnements",
|
||||
"empty": "Du hast noch keine Threads abonniert.",
|
||||
"by_user": "von {user}",
|
||||
"unsubscribe": "Abbestellen"
|
||||
},
|
||||
"search": {
|
||||
"title": "Suche",
|
||||
"strict_mode": "Strikter Modus",
|
||||
"results_found": "{count} Uploads gefunden (Seite {page} von {total}):",
|
||||
"col_thumbnail": "Vorschau",
|
||||
"col_id": "ID",
|
||||
"col_tag": "Tag",
|
||||
"col_mime": "Typ",
|
||||
"col_username": "Nutzername",
|
||||
"col_score": "Bewertung"
|
||||
},
|
||||
"ranking": {
|
||||
"title": "Rangliste",
|
||||
"top_contributors": "Top-Mitwirkende",
|
||||
"col_rank": "Rang",
|
||||
"col_avatar": "Avatar",
|
||||
"col_username": "Nutzername",
|
||||
"col_tagged": "Markiert",
|
||||
"tag_stats": "Tag-Statistiken",
|
||||
"stat_total": "Gesamt",
|
||||
"stat_tagged": "Markiert",
|
||||
"stat_untagged": "Unmarkiert",
|
||||
"stat_sfw": "SFW-Inhalte",
|
||||
"stat_nsfw": "NSFW-Inhalte",
|
||||
"stat_deleted": "Gelöscht",
|
||||
"most_favorited": "Meiste Favs",
|
||||
"favs": "Favs",
|
||||
"top_xd": "Top xD-Score"
|
||||
},
|
||||
"upload_page": {
|
||||
"title": "Inhalt hochladen",
|
||||
"limit_unlimited": "Unbegrenzte Uploads",
|
||||
"limit_reached": "Upload-Limit erreicht (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} Uploads verbleibend",
|
||||
"auth_required_title": "Anmeldung erforderlich",
|
||||
"auth_required_text": "Du musst angemeldet sein, um Inhalte hochzuladen.",
|
||||
"login_btn": "Anmelden"
|
||||
},
|
||||
"messages": {
|
||||
"page_title": "NACHRICHTEN",
|
||||
"manage_keys": "Schlüssel verwalten",
|
||||
"loading": "Gespräche werden geladen…",
|
||||
"decrypting": "Nachrichten werden entschlüsselt…",
|
||||
"input_placeholder": "Nachricht schreiben…",
|
||||
"send": "Abschnalzen"
|
||||
},
|
||||
"profile": {
|
||||
"message_btn": "Nachricht",
|
||||
"legacy_record": "Legacy-Eintrag – Erster Upload:",
|
||||
"joined": "Beigetreten:",
|
||||
"stat_comments": "Kommentare:",
|
||||
"stat_tags": "Tags:",
|
||||
"stat_halls": "Hallen:",
|
||||
"unban_btn": "Bann aufheben",
|
||||
"ban_btn": "Benutzer sperren",
|
||||
"warn_btn": "Verwarnen",
|
||||
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
||||
"no_uploads": "Keine Uploads gefunden",
|
||||
"no_favs": "Keine Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
"ban_modal_reason": "Grund:",
|
||||
"ban_modal_duration": "Dauer:",
|
||||
"ban_modal_cancel": "Abbrechen",
|
||||
"ban_modal_confirm": "Sperre bestätigen",
|
||||
"ban_1h": "1 Stunde",
|
||||
"ban_1d": "1 Tag",
|
||||
"ban_1w": "1 Woche",
|
||||
"ban_1m": "1 Monat",
|
||||
"ban_permanent": "Dauerhaft",
|
||||
"warn_modal_title": "Verwarnung aussprechen",
|
||||
"warn_modal_reason": "Grund der Verwarnung:",
|
||||
"warn_modal_hint": "Dieser Grund wird dem Benutzer angezeigt und muss bestätigt werden.",
|
||||
"warn_modal_cancel": "Abbrechen",
|
||||
"warn_modal_submit": "Verwarnung aussprechen",
|
||||
"comments_title": "Kommentare von {user}",
|
||||
"view_all": "Alle anzeigen",
|
||||
"uploads_label": "Hochlads",
|
||||
"favs_label": "Favoriten",
|
||||
"confirm_unban": "Möchtest du diesen Benutzer wirklich entsperren?",
|
||||
"unban_success": "Benutzer entsperrt",
|
||||
"ban_success": "Benutzer gesperrt",
|
||||
"warning_issuing": "Wird ausgesprochen...",
|
||||
"warning_success": "Benutzer erfolgreich verwarnt",
|
||||
"warning_issue_btn": "Verwarnung aussprechen",
|
||||
"confirm_subscribe_uploads": "Damit abonnierst du alle bisherigen Uploads. Fortfahren?",
|
||||
"subscribing": "Wird abonniert...",
|
||||
"subscribed": "Abonniert!",
|
||||
"subscribe_to_my_uploads": "Alle meine Uploads abonnieren"
|
||||
},
|
||||
"metadata_modal": {
|
||||
"title": "Metadaten-Vorschläge",
|
||||
"loading": "Metadaten werden extrahiert...",
|
||||
"found": "Gefundene Metadaten:",
|
||||
"no_results": "Keine weiteren Metadaten in dieser Datei gefunden."
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Textebene hinzufügen",
|
||||
"tags_label": "Tags (kommagetrennt)",
|
||||
"upload_btn": "Meme hochladen",
|
||||
"back_btn": "Zurück zu Vorlagen",
|
||||
"select_title": "Vorlage auswählen",
|
||||
"select_subtitle": "Wähle eine Vorlage, um dein Meme zu erstellen",
|
||||
"text_layer": "Textebene",
|
||||
"enter_text": "Text eingeben...",
|
||||
"size_label": "Größe",
|
||||
"create_meme": "Meme erstellen:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "gerade eben",
|
||||
"year": "{n} Jahr",
|
||||
"years": "{n} Jahren",
|
||||
"month": "{n} Monat",
|
||||
"months": "{n} Monaten",
|
||||
"day": "{n} Tag",
|
||||
"days": "{n} Tagen",
|
||||
"hour": "{n} Stunde",
|
||||
"hours": "{n} Stunden",
|
||||
"minute": "{n} Minute",
|
||||
"minutes": "{n} Minuten",
|
||||
"second": "{n} Sekunde",
|
||||
"seconds": "{n} Sekunden",
|
||||
"ago": "vor {t}"
|
||||
},
|
||||
"search_overlay": {
|
||||
"placeholder": "Tags suchen",
|
||||
"strict_mode": "Strikter Modus"
|
||||
},
|
||||
"toast": {
|
||||
"report_success": "Meldung erfolgreich übermittelt.",
|
||||
"report_error": "Ein Fehler ist aufgetreten.",
|
||||
"network_error": "Netzwerkfehler.",
|
||||
"reason_required": "Grund ist erforderlich.",
|
||||
"reason_optional": "Grund (optional)",
|
||||
"reason_required_label": "Grund (erforderlich)",
|
||||
"processing": "Wird verarbeitet...",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"add_tag": "Tag hinzufügen",
|
||||
"fav_added": "ZU FAVORITEN HINZUGEFÜGT",
|
||||
"fav_removed": "AUS FAVORITEN ENTFERNT",
|
||||
"subscribed_thread": "Faden abonniert",
|
||||
"unsubscribed_thread": "Faden abbestellt",
|
||||
"oc_marked": "ALS OC MARKIERT",
|
||||
"oc_removed": "OC STATUS ENTFERNT",
|
||||
"no_tags_excluded": "Keine Tags ausgeschlossen",
|
||||
"tag_delete_title": "Tag löschen",
|
||||
"tag_delete_confirm": "Möchtest du den Tag wirklich löschen?",
|
||||
"tag_deleted_success": "Tag erfolgreich gelöscht",
|
||||
"item_deleted_success": "Beitrag erfolgreich gelöscht",
|
||||
"approve_success": "Beitrag genehmigt!",
|
||||
"approve_error": "Fehler beim Genehmigen",
|
||||
"item_delete_title": "Beitrag löschen",
|
||||
"item_delete_confirm": "Beitrag {id} von {user} wirklich löschen?",
|
||||
"hall_added": "ZUR HALLE HINZUGEFÜGT",
|
||||
"hall_removed": "AUS HALLE ENTFERNT",
|
||||
"item_pinned": "BEITRAG ANGEHEFTET",
|
||||
"item_unpinned": "ANGEHEFTET ENTFERNT",
|
||||
"tag_added": "Tag \"{tag}\" hinzugefügt!",
|
||||
"error_adding_tag": "Fehler beim Hinzufügen des Tags",
|
||||
"network_error_short": "Netzwerkfehler",
|
||||
"error_saving": "Fehler beim Speichern",
|
||||
"mode_activated": "{mode}-MODUS AKTIVIERT",
|
||||
"zomg_on": "ZOMG-Modus aktiviert",
|
||||
"zomg_off": "ZOMG-Modus deaktiviert",
|
||||
"copied": "URL kopiert"
|
||||
},
|
||||
"footer": {
|
||||
"ranking": "Rangliste",
|
||||
"rules": "Regelwerk",
|
||||
"about": "Über"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Schett",
|
||||
"placeholder": "Schetten…",
|
||||
"minimize": "Minimieren",
|
||||
"expand": "Erweitern",
|
||||
"slow_down": "Langsamer!",
|
||||
"error_send": "Fehler beim Senden",
|
||||
"network_error": "Netzwerkfehler"
|
||||
}
|
||||
}
|
||||
558
src/inc/locales/en.json
Normal file
558
src/inc/locales/en.json
Normal file
@@ -0,0 +1,558 @@
|
||||
{
|
||||
"nav": {
|
||||
"upload": "upload",
|
||||
"meme": "meme",
|
||||
"halls": "Halls",
|
||||
"tags": "Tags",
|
||||
"search": "Search",
|
||||
"random": "Random",
|
||||
"profile": "profile",
|
||||
"my_halls": "My Halls",
|
||||
"favs": "favs",
|
||||
"admin": "Admin",
|
||||
"mod": "mod",
|
||||
"settings": "settings",
|
||||
"logout": "logout",
|
||||
"notifications": "Notifications",
|
||||
"mark_all_read": "Mark all read",
|
||||
"no_notifications": "No new notifications",
|
||||
"view_all_notifications": "View all notifications",
|
||||
"manage_subscriptions": "manage subscriptions",
|
||||
"favorites": "Favorites",
|
||||
"direct_messages": "Direct Messages",
|
||||
"excluded_tags": "Excluded Tags",
|
||||
"guest": "guest",
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"overview": "Overview",
|
||||
"prev": "prev",
|
||||
"next": "next",
|
||||
"random_nav": "random"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Upload Content",
|
||||
"drop_anywhere": "Drop anywhere to upload",
|
||||
"unlimited": "Unlimited uploads",
|
||||
"limit_reached": "Upload limit reached",
|
||||
"remaining": "uploads remaining",
|
||||
"file_tab": "File Upload",
|
||||
"url_tab": "URL",
|
||||
"url_tab_yt": "URL / YouTube",
|
||||
"url_placeholder": "Paste a URL to download...",
|
||||
"url_placeholder_yt": "Paste a URL or YouTube link...",
|
||||
"drop_here": "Drop your file here",
|
||||
"admin_boost": "Admin Boost",
|
||||
"custom_thumbnail": "Custom Thumbnail",
|
||||
"custom_thumbnail_hint": "If not set will default back to the default Thumbnail",
|
||||
"rating": "Rating",
|
||||
"original_content": "Original Content (OC)",
|
||||
"tags": "Tags",
|
||||
"tags_minimum": "minimum",
|
||||
"extracting_title": "Extracting title...",
|
||||
"tag_placeholder": "Type a tag and press Enter",
|
||||
"suggestions_header": "Suggested from metadata",
|
||||
"comment": "Comment",
|
||||
"comment_optional": "(optional)",
|
||||
"comment_placeholder": "Add a comment to your upload...",
|
||||
"select_file": "Select a file",
|
||||
"uploading": "Uploading...",
|
||||
"pending_approval_patient": "Upload awaits approval, please be patient",
|
||||
"remove_file": "Remove File",
|
||||
"cancel_upload": "Cancel Upload"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Registering...",
|
||||
"already_logged_in": "Already logged in",
|
||||
"login_title": "Login",
|
||||
"username_placeholder": "username",
|
||||
"username_or_email": "Username or Email",
|
||||
"password_placeholder": "password",
|
||||
"password_placeholder_min": "Password",
|
||||
"stay_signed_in": "Stay signed in",
|
||||
"stay_signed_in_label": "stay signed in",
|
||||
"forgot_password": "Forgot Password?",
|
||||
"no_account": "No Account?",
|
||||
"register_now": "Register now!",
|
||||
"register_title": "Register",
|
||||
"password_min_hint": "Must be at least 20 characters long.",
|
||||
"confirm_password": "confirm password",
|
||||
"email_placeholder": "email",
|
||||
"invite_token": "invite token",
|
||||
"tos_private": "I am at least 18 years old and agree to obey the rules",
|
||||
"tos_public": "I have read and accept the",
|
||||
"tos_terms": "Terms of Service",
|
||||
"tos_rules": "Rules",
|
||||
"tos_age": "and I am at least 18 years old",
|
||||
"tos_private_simple": "I am at least 18 years old",
|
||||
"create_account": "Create Account",
|
||||
"back_to_login": "Back to Login",
|
||||
"forgot_title": "Forgot Password",
|
||||
"forgot_desc": "Enter your email to receive a reset link.",
|
||||
"email_address": "Email Address",
|
||||
"send_reset": "Send Reset Link",
|
||||
"reset_title": "Reset Password",
|
||||
"reset_desc": "Enter your new password below.",
|
||||
"new_password_min": "New Password (min 20 chars)",
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
"update_password": "Update Password",
|
||||
"password_change_required": "Password Update Required",
|
||||
"password_change_desc": "An administrator has requested that you change your password before continuing. This password must be at least 20 characters long.",
|
||||
"new_password_label": "New Password",
|
||||
"confirm_password_label": "Confirm Password",
|
||||
"min_chars_placeholder": "Minimum 20 characters",
|
||||
"confirm_placeholder": "Confirm new password",
|
||||
"update_password_btn": "Update Password",
|
||||
"or_logout": "Or logout from this account"
|
||||
},
|
||||
"settings": {
|
||||
"switching": "Switching...",
|
||||
"generating": "Generating...",
|
||||
"title": "Settings",
|
||||
"avatar": "Avatar",
|
||||
"current_avatar": "Current Avatar",
|
||||
"upload_custom_avatar": "Upload Custom Avatar",
|
||||
"avatar_hint": "Max 5MB. Allowed: gif, jpg, png, webp",
|
||||
"choose_file": "Choose File",
|
||||
"no_file_selected": "No file selected",
|
||||
"upload_btn": "Upload",
|
||||
"remove_custom": "Remove Custom",
|
||||
"custom_description": "Custom Description",
|
||||
"description_placeholder": "Add a description to your profile",
|
||||
"save_description": "Save Description",
|
||||
"clear": "Clear",
|
||||
"preferences": "Preferences",
|
||||
"ui_section": "User Interface",
|
||||
"show_motd": "Show Message of the Day (MOTD)",
|
||||
"modern_layout": "Modern layout",
|
||||
"modern_layout_hint": "3 Column Layout",
|
||||
"disable_autoplay": "Disable Autoplay",
|
||||
"disable_autoplay_hint": "Prevent videos and audio from playing automatically",
|
||||
"disable_swiping": "Disable Swiping",
|
||||
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
|
||||
"enable_bg_blur": "Enable Background blur",
|
||||
"enable_bg_blur_hint": "Show blurred background on items",
|
||||
"render_emojis": "Render emojis in quote replies",
|
||||
"render_emojis_hint": "Show :emoji: images inside >quoted lines",
|
||||
"embed_yt": "Embed YouTube links in comments",
|
||||
"embed_yt_hint": "Replace YouTube links with inline video players",
|
||||
"hide_koepfe": "Hide Köpfe",
|
||||
"hide_koepfe_hint": "Disable the Köpfe",
|
||||
"language": "Language",
|
||||
"language_hint": "Change the site language. Reloads the page to apply.",
|
||||
"language_default": "Default (site)",
|
||||
"language_en": "English",
|
||||
"language_de": "Deutsch",
|
||||
"language_nl": "Nederlands",
|
||||
"language_zange": "Zangendeutsch",
|
||||
"scroll_nav": "Scroll wheel navigation on items",
|
||||
"scroll_nav_hint": "Navigate to next/previous item by scrolling on the media area",
|
||||
"username_color": "Custom Username Color",
|
||||
"username_color_hint": "Pick a color or type a hex code for your username on items and comments.",
|
||||
"save_color": "Save Color",
|
||||
"reset": "Reset",
|
||||
"website_font": "Website Font",
|
||||
"font_default": "Default",
|
||||
"theme": "Theme",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Default Flash Volume",
|
||||
"flash_bg": "Keep Flash Playing in Background",
|
||||
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
|
||||
"save_flash": "Save Flash Settings",
|
||||
"content_filters": "Content Filters",
|
||||
"min_xd_score": "Minimum xD Score",
|
||||
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
|
||||
"save": "Save",
|
||||
"account": "Account",
|
||||
"user_id": "UserID",
|
||||
"username": "Username",
|
||||
"display_name": "Display Name",
|
||||
"display_name_placeholder": "Display Name",
|
||||
"email": "Email",
|
||||
"email_not_set": "Not set",
|
||||
"joined": "Joined",
|
||||
"joined_unknown": "Unknown",
|
||||
"change_password": "Change Password",
|
||||
"current_password": "Current Password",
|
||||
"new_password": "New Password (min 20 chars)",
|
||||
"confirm_new_password": "Confirm New Password",
|
||||
"update_password": "Update Password",
|
||||
"update_email": "Update Email",
|
||||
"new_email": "New Email Address",
|
||||
"update_email_btn": "Update Email",
|
||||
"email_warning_smtp": "If not a valid email you might be unable to reset your password in case you forget it!",
|
||||
"email_info_no_smtp": "Mail must <b>not</b> be valid, will be used for your login alongside your username.<br> No confirmation will be sent.",
|
||||
"linked_accounts": "Linked Accounts",
|
||||
"matrix_link_desc": "Link your Matrix to use the upload functionality from Matrix Rooms",
|
||||
"active_links": "Active Links:",
|
||||
"loading": "Loading...",
|
||||
"add_new_link": "Add New Link",
|
||||
"matrix_instructions": "1. Generate a generic link token below.<br>2. Send <code>!link</code> in the general<br>3. Reply with your token in the bot dm",
|
||||
"your_token": "Your Token:",
|
||||
"one_time_use": "Valid for one-time use.",
|
||||
"generate_token": "Generate Link Token"
|
||||
},
|
||||
"filter": {
|
||||
"tag_placeholder": "Tag to exclude",
|
||||
"random_mode": "RAND",
|
||||
"min_xd_score": "Min xD Score",
|
||||
"apply_filter": "Apply Filter",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Image",
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"search": "search",
|
||||
"main_page": "main page",
|
||||
"random": "random",
|
||||
"shuffle": "shuffle / random mode",
|
||||
"quick_upload": "quick upload",
|
||||
"toggle_comments": "hides/shows the comments",
|
||||
"toggle_sidebar": "hides/shows the sidebar",
|
||||
"focus_comment": "focus comment input",
|
||||
"send_comment": "send comment",
|
||||
"flash_yank": "enable/disable flash yank",
|
||||
"tag_exclude": "open tag exclude",
|
||||
"tag_input": "open tag input",
|
||||
"toggle_bg": "turns on/off the background",
|
||||
"cycle_themes": "cycle themes",
|
||||
"delete": "del",
|
||||
"toggle_rating": "toggle item rating",
|
||||
"play_pause": "play/pause",
|
||||
"next_prev": "next/prev",
|
||||
"scroll_nav": "next/prev",
|
||||
"show_help": "show help"
|
||||
},
|
||||
"notifications": {
|
||||
"system": "System",
|
||||
"admin": "Admin",
|
||||
"moderation": "Moderation",
|
||||
"upload_approved": "Your Upload #{id} has been approved",
|
||||
"upload_approved_short": "Your Upload has been approved",
|
||||
"upload_pending": "A new upload (#{id}) needs approval",
|
||||
"upload_pending_short": "A new upload needs approval",
|
||||
"new_report": "A new user report (#{id}) has been submitted",
|
||||
"new_report_short": "A new user report has been submitted",
|
||||
"upload_denied": "Your Upload #{id} was denied",
|
||||
"upload_denied_short": "Your Upload was denied",
|
||||
"reason": "Reason:",
|
||||
"reason_label": "Reason:",
|
||||
"no_reason": "No reason provided",
|
||||
"upload_deleted": "A moderator deleted your upload #{id}",
|
||||
"upload_deleted_short": "A moderator deleted your upload",
|
||||
"click_reason": "Click to see reason",
|
||||
"new_comments": "New comments",
|
||||
"on_your_upload": "on your upload #{id}",
|
||||
"replied": "replied to you on #{id}",
|
||||
"replied_short": "replied to you",
|
||||
"subscribed": "commented in a thread you follow (#{id})",
|
||||
"subscribed_short": "commented in a thread you follow",
|
||||
"mentioned": "mentioned you on #{id}",
|
||||
"mentioned_short": "mentioned you",
|
||||
"commented": "commented",
|
||||
"upload_success": "Your background upload is finished!",
|
||||
"upload_error": "Your background upload failed.",
|
||||
"page_title": "NOTIFICATIONS",
|
||||
"mark_all_read": "Mark all as read",
|
||||
"scroll_for_more": "Scroll for more..."
|
||||
},
|
||||
"error": {
|
||||
"label": "Error"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Drop anywhere to upload"
|
||||
},
|
||||
"comments": {
|
||||
"write_comment": "Write a comment...",
|
||||
"post": "Post",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Select a file",
|
||||
"enter_url": "Enter a URL",
|
||||
"tags_required": "{n} more tag{s} required",
|
||||
"select_rating": "Select SFW or NSFW",
|
||||
"embed_youtube": "Embed YouTube Video",
|
||||
"upload_from_url": "Upload from URL",
|
||||
"upload": "Upload"
|
||||
},
|
||||
"common": {
|
||||
"sending": "Sending...",
|
||||
"updating": "Updating...",
|
||||
"saving": "Saving...",
|
||||
"loading": "Loading...",
|
||||
"cancel": "Cancel",
|
||||
"add": "Add",
|
||||
"remove": "Remove",
|
||||
"close": "Close",
|
||||
"submit": "Submit",
|
||||
"confirm": "Confirm",
|
||||
"send": "Send",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"private": "Private",
|
||||
"view": "View",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Confirm Action",
|
||||
"reason_placeholder": "Reason for this action...",
|
||||
"confirm": "Confirm"
|
||||
},
|
||||
"toptags": {
|
||||
"posts": "{count} posts"
|
||||
},
|
||||
"hall": {
|
||||
"modal_title": "Add to Hall",
|
||||
"site_hall_label": "Site Hall (mod):",
|
||||
"choose_hall": "-- Choose a Hall --",
|
||||
"add_btn": "Add to Hall",
|
||||
"remove_btn": "Remove from Hall",
|
||||
"my_hall_label": "My Hall:",
|
||||
"my_hall_placeholder": "-- My Hall --",
|
||||
"create_new": "or create new:",
|
||||
"new_name_placeholder": "New hall name…",
|
||||
"no_halls": "No halls yet — create one!",
|
||||
"select_a_hall": "Please select a hall.",
|
||||
"choose_or_create": "Choose a hall or enter a new name.",
|
||||
"creating": "Creating…",
|
||||
"adding": "Adding…",
|
||||
"removing": "Removing…",
|
||||
"posts": "{count} posts",
|
||||
"user_halls_title": "📁 {user}'s Halls",
|
||||
"new_hall_placeholder": "New hall name…",
|
||||
"new_hall_btn": "+ New Hall",
|
||||
"enter_name_error": "Enter a name first.",
|
||||
"no_halls_owner": "You haven't created any halls yet. Enter a name above and hit <strong>+ New Hall</strong>!",
|
||||
"no_halls_guest": "No halls here yet.",
|
||||
"manager_title": "🏛 Hall Manager",
|
||||
"manager_desc": "Manage all halls — edit descriptions, upload custom images, or delete halls.",
|
||||
"create_hall_btn": "Create Hall",
|
||||
"slug": "Slug",
|
||||
"rating": "Rating",
|
||||
"click_upload_hint": "Click to upload a custom image",
|
||||
"delete_confirm_slug": "Really delete hall \"{slug}\"? This cannot be undone.",
|
||||
"delete_confirm": "Delete this hall? This cannot be undone.",
|
||||
"created": "✓ Created!",
|
||||
"deleting": "Deleting…",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
"error_label": "Error",
|
||||
"slug_empty_error": "Slug cannot be empty",
|
||||
"uploading": "Uploading…",
|
||||
"image_uploaded": "Image uploaded",
|
||||
"image_removed": "Custom image removed"
|
||||
},
|
||||
"content_warning": {
|
||||
"title": "Content Warning",
|
||||
"text": "This site contains user-generated content that may include material not suitable for all ages or audiences, including flashing lights which may cause seizures in people with photosensitive epilepsy. By proceeding, you confirm that you are willing to view such content.",
|
||||
"proceed": "Proceed",
|
||||
"decline": "Get me out of here"
|
||||
},
|
||||
"report": {
|
||||
"title": "Submit Report",
|
||||
"placeholder": "Please provide details for this report (required)...",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"account_warning": {
|
||||
"title": "Account Warning",
|
||||
"text": "You have received a warning from a moderator:",
|
||||
"acknowledge": "I Understand"
|
||||
},
|
||||
"sidebar": {
|
||||
"loading_activity": "Loading activity...",
|
||||
"view": "View",
|
||||
"read_more": "read more",
|
||||
"see_less": "see less"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "My Subscriptions",
|
||||
"empty": "You haven't subscribed to any threads yet.",
|
||||
"by_user": "by {user}",
|
||||
"unsubscribe": "Unsubscribe"
|
||||
},
|
||||
"search": {
|
||||
"title": "search",
|
||||
"strict_mode": "Strict Mode",
|
||||
"results_found": "{count} uploads found (page {page} of {total}):",
|
||||
"col_thumbnail": "Thumbnail",
|
||||
"col_id": "ID",
|
||||
"col_tag": "Tag",
|
||||
"col_mime": "Mime",
|
||||
"col_username": "Username",
|
||||
"col_score": "Score"
|
||||
},
|
||||
"ranking": {
|
||||
"title": "Ranking",
|
||||
"top_contributors": "Top Contributors",
|
||||
"col_rank": "Rank",
|
||||
"col_avatar": "Avatar",
|
||||
"col_username": "Username",
|
||||
"col_tagged": "Tagged",
|
||||
"tag_stats": "Tag Stats",
|
||||
"stat_total": "Total Items",
|
||||
"stat_tagged": "Tagged",
|
||||
"stat_untagged": "Untagged",
|
||||
"stat_sfw": "SFW content",
|
||||
"stat_nsfw": "NSFW content",
|
||||
"stat_deleted": "Deleted",
|
||||
"most_favorited": "Most Favorited",
|
||||
"favs": "favs",
|
||||
"top_xd": "Top xD Scores"
|
||||
},
|
||||
"upload_page": {
|
||||
"title": "Upload Content",
|
||||
"limit_unlimited": "Unlimited uploads",
|
||||
"limit_reached": "Upload limit reached (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} uploads remaining",
|
||||
"auth_required_title": "Authentication Required",
|
||||
"auth_required_text": "You must be logged in to upload content to w0bm.",
|
||||
"login_btn": "Login"
|
||||
},
|
||||
"messages": {
|
||||
"page_title": "MESSAGES",
|
||||
"manage_keys": "🔑 Manage Keys",
|
||||
"loading": "Loading conversations…",
|
||||
"decrypting": "Decrypting messages…",
|
||||
"input_placeholder": "Write a message…",
|
||||
"send": "Send"
|
||||
},
|
||||
"profile": {
|
||||
"message_btn": "✉ Message",
|
||||
"legacy_record": "Legacy Record – First Upload:",
|
||||
"joined": "Joined:",
|
||||
"stat_comments": "Comments:",
|
||||
"stat_tags": "Tags:",
|
||||
"stat_halls": "Halls:",
|
||||
"unban_btn": "Unban User",
|
||||
"ban_btn": "Ban User",
|
||||
"warn_btn": "Warn User",
|
||||
"subscribe_uploads_btn": "Subscribe user to uploads",
|
||||
"no_uploads": "no uploads found",
|
||||
"no_favs": "no favorites",
|
||||
"back_to_profile": "Back to Profile",
|
||||
"ban_modal_title": "Ban User",
|
||||
"ban_modal_reason": "Reason:",
|
||||
"ban_modal_duration": "Duration:",
|
||||
"ban_modal_cancel": "Cancel",
|
||||
"ban_modal_confirm": "Confirm Ban",
|
||||
"ban_1h": "1 Hour",
|
||||
"ban_1d": "1 Day",
|
||||
"ban_1w": "1 Week",
|
||||
"ban_1m": "1 Month",
|
||||
"ban_permanent": "Permanent",
|
||||
"warn_modal_title": "Issue Warning",
|
||||
"warn_modal_reason": "Reason for Warning:",
|
||||
"warn_modal_hint": "This reason will be visible to the user, and they must acknowledge it to dismiss the message.",
|
||||
"warn_modal_cancel": "Cancel",
|
||||
"warn_modal_submit": "Issue Warning",
|
||||
"comments_title": "{user}'s Comments",
|
||||
"view_all": "view all",
|
||||
"uploads_label": "uploads",
|
||||
"favs_label": "favs",
|
||||
"confirm_unban": "Are you sure you want to unban this user?",
|
||||
"unban_success": "User unbanned successfully",
|
||||
"ban_success": "User banned successfully",
|
||||
"warning_issuing": "Issuing...",
|
||||
"warning_success": "User successfully warned",
|
||||
"warning_issue_btn": "Issue Warning",
|
||||
"confirm_subscribe_uploads": "This will subscribe you to all your past uploads. Continue?",
|
||||
"subscribing": "Subscribing...",
|
||||
"subscribed": "Subscribed!",
|
||||
"subscribe_to_my_uploads": "Subscribe to all my uploads"
|
||||
},
|
||||
"metadata_modal": {
|
||||
"title": "Metadata Suggestions",
|
||||
"loading": "Extracting metadata from file...",
|
||||
"found": "Found in metadata:",
|
||||
"no_results": "No additional metadata fields found in this file."
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Add Text Layer",
|
||||
"tags_label": "Tags (comma separated)",
|
||||
"upload_btn": "Upload Meme",
|
||||
"back_btn": "Back to Templates",
|
||||
"select_title": "Select Template",
|
||||
"select_subtitle": "Choose a template to start creating your meme",
|
||||
"text_layer": "Text Layer",
|
||||
"enter_text": "Enter text...",
|
||||
"size_label": "Size",
|
||||
"create_meme": "Create Meme:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "just now",
|
||||
"year": "{n} year",
|
||||
"years": "{n} years",
|
||||
"month": "{n} month",
|
||||
"months": "{n} months",
|
||||
"day": "{n} day",
|
||||
"days": "{n} days",
|
||||
"hour": "{n} hour",
|
||||
"hours": "{n} hours",
|
||||
"minute": "{n} minute",
|
||||
"minutes": "{n} minutes",
|
||||
"second": "{n} second",
|
||||
"seconds": "{n} seconds",
|
||||
"ago": "{t} ago"
|
||||
},
|
||||
"search_overlay": {
|
||||
"placeholder": "Search Tags",
|
||||
"strict_mode": "Strict Mode"
|
||||
},
|
||||
"toast": {
|
||||
"report_success": "Report submitted successfully.",
|
||||
"report_error": "An error occurred.",
|
||||
"network_error": "Network error.",
|
||||
"reason_required": "Reason is required.",
|
||||
"reason_optional": "Reason (optional)",
|
||||
"reason_required_label": "Reason (required)",
|
||||
"processing": "Processing...",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"add_tag": "Add tag",
|
||||
"fav_added": "ADDED TO FAVORITES",
|
||||
"fav_removed": "REMOVED FROM FAVORITES",
|
||||
"subscribed_thread": "SUBSCRIBED TO THREAD",
|
||||
"unsubscribed_thread": "UNSUBSCRIBED FROM THREAD",
|
||||
"oc_marked": "MARKED AS ORIGINAL CONTENT",
|
||||
"oc_removed": "OC STATUS REMOVED",
|
||||
"no_tags_excluded": "No tags excluded",
|
||||
"tag_delete_title": "Delete Tag",
|
||||
"tag_delete_confirm": "Are you sure you want to delete this tag?",
|
||||
"tag_deleted_success": "Tag deleted successfully",
|
||||
"item_deleted_success": "Item deleted successfully",
|
||||
"item_delete_title": "Delete Item",
|
||||
"item_delete_confirm": "Are you sure you want to delete item {id} by {user}?",
|
||||
"hall_added": "ADDED TO HALL",
|
||||
"hall_removed": "REMOVED FROM HALL",
|
||||
"item_pinned": "ITEM PINNED",
|
||||
"item_unpinned": "ITEM UNPINNED",
|
||||
"tag_added": "Tag \"{tag}\" added!",
|
||||
"error_adding_tag": "Error adding tag",
|
||||
"network_error_short": "Network error",
|
||||
"error_saving": "Error saving",
|
||||
"mode_activated": "{mode} MODE ACTIVATED",
|
||||
"zomg_on": "ZOMG Mode activated",
|
||||
"zomg_off": "ZOMG Mode deactivated",
|
||||
"copied": "URL copied to clipboard"
|
||||
},
|
||||
"footer": {
|
||||
"ranking": "Ranking",
|
||||
"rules": "Rules",
|
||||
"about": "About"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Chat",
|
||||
"placeholder": "Chat…",
|
||||
"minimize": "Minimize",
|
||||
"expand": "Expand",
|
||||
"slow_down": "Slow down!",
|
||||
"error_send": "Error sending",
|
||||
"network_error": "Network error"
|
||||
}
|
||||
}
|
||||
554
src/inc/locales/nl.json
Normal file
554
src/inc/locales/nl.json
Normal file
@@ -0,0 +1,554 @@
|
||||
{
|
||||
"nav": {
|
||||
"upload": "uploaden",
|
||||
"meme": "meme",
|
||||
"halls": "Hallen",
|
||||
"tags": "Tags",
|
||||
"search": "Zoeken",
|
||||
"random": "Willekeurig",
|
||||
"profile": "profiel",
|
||||
"my_halls": "Mijn Hallen",
|
||||
"favs": "favorieten",
|
||||
"admin": "Admin",
|
||||
"mod": "mod",
|
||||
"settings": "instellingen",
|
||||
"logout": "uitloggen",
|
||||
"notifications": "Meldingen",
|
||||
"mark_all_read": "Alles als gelezen markeren",
|
||||
"no_notifications": "Geen nieuwe meldingen",
|
||||
"view_all_notifications": "Alle meldingen bekijken",
|
||||
"manage_subscriptions": "abonnementen beheren",
|
||||
"favorites": "Favorieten",
|
||||
"direct_messages": "Directe Berichten",
|
||||
"excluded_tags": "Uitgesloten Tags",
|
||||
"guest": "gast",
|
||||
"login": "Inloggen",
|
||||
"register": "Registreren",
|
||||
"overview": "Overzicht",
|
||||
"prev": "vorige",
|
||||
"next": "volgende",
|
||||
"random_nav": "willekeurig"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Inhoud Uploaden",
|
||||
"drop_anywhere": "Overal slepen om te uploaden",
|
||||
"unlimited": "Onbeperkte uploads",
|
||||
"limit_reached": "Uploadlimiet bereikt",
|
||||
"remaining": "uploads over",
|
||||
"file_tab": "Bestand Uploaden",
|
||||
"url_tab": "URL",
|
||||
"url_tab_yt": "URL / YouTube",
|
||||
"url_placeholder": "Plak een URL om te downloaden...",
|
||||
"url_placeholder_yt": "Plak een URL of YouTube-link...",
|
||||
"drop_here": "Sleep je bestand hierheen",
|
||||
"admin_boost": "Admin Boost",
|
||||
"custom_thumbnail": "Aangepaste Thumbnail",
|
||||
"custom_thumbnail_hint": "Indien niet ingesteld, wordt de standaard Thumbnail gebruikt",
|
||||
"rating": "Beoordeling",
|
||||
"original_content": "Originele Inhoud (OC)",
|
||||
"tags": "Tags",
|
||||
"tags_minimum": "minimum",
|
||||
"extracting_title": "Titel extraheren...",
|
||||
"tag_placeholder": "Typ een tag en druk op Enter",
|
||||
"suggestions_header": "Gesuggereerd uit metadata",
|
||||
"comment": "Opmerking",
|
||||
"comment_optional": "(optioneel)",
|
||||
"comment_placeholder": "Voeg een opmerking toe aan je upload...",
|
||||
"select_file": "Selecteer een bestand",
|
||||
"uploading": "Uploaden...",
|
||||
"pending_approval_patient": "Upload wacht op goedkeuring, even geduld alstublieft",
|
||||
"remove_file": "Bestand Verwijderen",
|
||||
"cancel_upload": "Upload Annuleren"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Registreren...",
|
||||
"already_logged_in": "Al ingelogd",
|
||||
"login_title": "Inloggen",
|
||||
"username_placeholder": "gebruikersnaam",
|
||||
"username_or_email": "Gebruikersnaam of E-mail",
|
||||
"password_placeholder": "wachtwoord",
|
||||
"password_placeholder_min": "Wachtwoord",
|
||||
"stay_signed_in": "Aangemeld blijven",
|
||||
"stay_signed_in_label": "aangemeld blijven",
|
||||
"forgot_password": "Wachtwoord vergeten?",
|
||||
"no_account": "Geen account?",
|
||||
"register_now": "Nu registreren!",
|
||||
"register_title": "Registreren",
|
||||
"password_min_hint": "Moet minimaal 20 tekens lang zijn.",
|
||||
"confirm_password": "bevestig wachtwoord",
|
||||
"email_placeholder": "e-mail",
|
||||
"invite_token": "uitnodigingscode",
|
||||
"tos_private": "Ik ben minimaal 18 jaar oud en ga akkoord met de regels",
|
||||
"tos_public": "Ik heb de voorwaarden gelezen en accepteer de",
|
||||
"tos_terms": "Servicevoorwaarden",
|
||||
"tos_rules": "Regels",
|
||||
"tos_age": "and I am at least 18 years old",
|
||||
"tos_private_simple": "Ik ben minimaal 18 jaar oud",
|
||||
"create_account": "Account Aanmaken",
|
||||
"back_to_login": "Terug naar Inloggen",
|
||||
"forgot_title": "Wachtwoord Vergeten",
|
||||
"forgot_desc": "Voer je e-mailadres in om een resetlink te ontvangen.",
|
||||
"email_address": "E-mailadres",
|
||||
"send_reset": "Resetlink Verzenden",
|
||||
"reset_title": "Wachtwoord Resetten",
|
||||
"reset_desc": "Voer hieronder je nieuwe wachtwoord in.",
|
||||
"new_password_min": "Nieuw Wachtwoord (min 20 tekens)",
|
||||
"confirm_new_password": "Bevestig Nieuw Wachtwoord",
|
||||
"update_password": "Wachtwoord Bijwerken",
|
||||
"password_change_required": "Wachtwoordupdate Vereist",
|
||||
"password_change_desc": "Een beheerder heeft gevraagd dat je je wachtwoord wijzigt voordat je doorgaat. Dit wachtwoord moet minimaal 20 tekens lang zijn.",
|
||||
"new_password_label": "Nieuw Wachtwoord",
|
||||
"confirm_password_label": "Bevestig Wachtwoord",
|
||||
"min_chars_placeholder": "Minimaal 20 tekens",
|
||||
"confirm_placeholder": "Bevestig nieuw wachtwoord",
|
||||
"update_password_btn": "Wachtwoord Bijwerken",
|
||||
"or_logout": "Of uitloggen uit dit account"
|
||||
},
|
||||
"settings": {
|
||||
"switching": "Omschakelen...",
|
||||
"generating": "Genereren...",
|
||||
"title": "Instellingen",
|
||||
"avatar": "Avatar",
|
||||
"current_avatar": "Huidige Avatar",
|
||||
"upload_custom_avatar": "Aangepaste Avatar Uploaden",
|
||||
"avatar_hint": "Max 5MB. Toegestaan: gif, jpg, png, webp",
|
||||
"choose_file": "Bestand Kiezen",
|
||||
"no_file_selected": "Geen bestand geselecteerd",
|
||||
"upload_btn": "Uploaden",
|
||||
"remove_custom": "Aangepaste Verwijderen",
|
||||
"custom_description": "Aangepaste Beschrijving",
|
||||
"description_placeholder": "Voeg een beschrijving toe aan je profiel",
|
||||
"save_description": "Beschrijving Opslaan",
|
||||
"clear": "Wissen",
|
||||
"preferences": "Voorkeuren",
|
||||
"ui_section": "Gebruikersinterface",
|
||||
"show_motd": "Toon Bericht van de Dag (MOTD)",
|
||||
"modern_layout": "Moderne layout",
|
||||
"modern_layout_hint": "Indeling met 3 kolommen",
|
||||
"disable_autoplay": "Automatisch afspelen uitschakelen",
|
||||
"disable_autoplay_hint": "Voorkomen dat video's en audio automatisch worden afgespeeld",
|
||||
"disable_swiping": "Swipen uitschakelen",
|
||||
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
||||
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
||||
"enable_bg_blur_hint": "Vervaagde achtergrond tonen bij items",
|
||||
"render_emojis": "Emoji's weergeven in antwoorden",
|
||||
"render_emojis_hint": "Toon :emoji: afbeeldingen binnen >geciteerde regels",
|
||||
"embed_yt": "YouTube-links insluiten in opmerkingen",
|
||||
"embed_yt_hint": "YouTube-links vervangen door inline videospelers",
|
||||
"hide_koepfe": "Köpfe verbergen",
|
||||
"hide_koepfe_hint": "De Köpfe uitschakelen",
|
||||
"language": "Taal",
|
||||
"language_hint": "Wijzig de sitetaal. Pagina wordt herladen om toe te passen.",
|
||||
"language_default": "Standaard (site)",
|
||||
"language_en": "Engels",
|
||||
"language_de": "Duits",
|
||||
"language_nl": "Nederlands",
|
||||
"language_zange": "Zangendeutsch",
|
||||
"scroll_nav": "Muiswiel-navigatie op items",
|
||||
"scroll_nav_hint": "Navigeer naar volgend/vorig item door te scrollen op het mediagebied",
|
||||
"username_color": "Aangepaste Gebruikersnaamkleur",
|
||||
"username_color_hint": "Kies een kleur of typ een hex-code voor je gebruikersnaam bij items en opmerkingen.",
|
||||
"save_color": "Kleur Opslaan",
|
||||
"reset": "Resetten",
|
||||
"website_font": "Website Lettertype",
|
||||
"font_default": "Standaard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standaard Flash Volume",
|
||||
"flash_bg": "Flash in de achtergrond blijven afspelen",
|
||||
"flash_bg_hint": "Prevents Ruffle from pausing when leaving the tab",
|
||||
"save_flash": "Flash Instellingen Opslaan",
|
||||
"content_filters": "Inhoudsfilters",
|
||||
"min_xd_score": "Minimale xD-score",
|
||||
"min_xd_score_hint": "Only show posts with at least this xD score. Set to 0 to disable.",
|
||||
"save": "Opslaan",
|
||||
"account": "Account",
|
||||
"user_id": "GebruikersID",
|
||||
"username": "Gebruikersnaam",
|
||||
"display_name": "Weergavenaam",
|
||||
"display_name_placeholder": "Weergavenaam",
|
||||
"email": "E-mail",
|
||||
"email_not_set": "Niet ingesteld",
|
||||
"joined": "Lid geworden",
|
||||
"joined_unknown": "Onbekend",
|
||||
"change_password": "Wachtwoord Wijzigen",
|
||||
"current_password": "Huidig Wachtwoord",
|
||||
"new_password": "Nieuw Wachtwoord (min 20 tekens)",
|
||||
"confirm_new_password": "Bevestig Nieuw Wachtwoord",
|
||||
"update_password": "Wachtwoord Bijwerken",
|
||||
"update_email": "E-mail Bijwerken",
|
||||
"new_email": "Nieuw E-mailadres",
|
||||
"update_email_btn": "E-mail Bijwerken",
|
||||
"email_warning_smtp": "Als het geen geldig e-mailadres is, kun je je wachtwoord mogelijk niet resetten als je het vergeet!",
|
||||
"email_info_no_smtp": "Mail must <b>not</b> be valid, will be used for your login alongside your username.<br> No confirmation will be sent.",
|
||||
"linked_accounts": "Gekoppelde Accounts",
|
||||
"matrix_link_desc": "Koppel je Matrix om de uploadfunctionaliteit vanuit Matrix-kamers te gebruiken",
|
||||
"active_links": "Actieve Koppelingen:",
|
||||
"loading": "Laden...",
|
||||
"add_new_link": "Nieuwe Koppeling Toevoegen",
|
||||
"matrix_instructions": "1. Generate a generic link token below.<br>2. Send <code>!link</code> in the general<br>3. Reply with your token in the bot dm",
|
||||
"your_token": "Je Token:",
|
||||
"one_time_use": "Valid for one-time use.",
|
||||
"generate_token": "Koppelingstoken Genereren"
|
||||
},
|
||||
"filter": {
|
||||
"tag_placeholder": "Tag om uit te sluiten",
|
||||
"random_mode": "WILLEKEURIG",
|
||||
"min_xd_score": "Min xD-score",
|
||||
"apply_filter": "Filter Toepassen",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Afbeelding",
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Sneltoetsen",
|
||||
"search": "zoeken",
|
||||
"main_page": "hoofdpagina",
|
||||
"random": "willekeurig",
|
||||
"shuffle": "shuffelen / willekeurige modus",
|
||||
"quick_upload": "snel uploaden",
|
||||
"toggle_comments": "verbergt/toont de opmerkingen",
|
||||
"toggle_sidebar": "verbergt/toont de zijbalk",
|
||||
"focus_comment": "focus op commentaarinvoer",
|
||||
"send_comment": "opmerking verzenden",
|
||||
"flash_yank": "flash yank in/uitschakelen",
|
||||
"tag_exclude": "open tag uitsluiten",
|
||||
"tag_input": "open tag invoer",
|
||||
"toggle_bg": "turns on/off the background",
|
||||
"cycle_themes": "door thema's bladeren",
|
||||
"delete": "del",
|
||||
"toggle_rating": "itembeoordeling omschakelen",
|
||||
"play_pause": "afspelen/pauzeren",
|
||||
"next_prev": "volgende/vorige",
|
||||
"scroll_nav": "next/prev",
|
||||
"show_help": "help tonen"
|
||||
},
|
||||
"notifications": {
|
||||
"system": "Systeem",
|
||||
"admin": "Admin",
|
||||
"moderation": "Moderatie",
|
||||
"upload_approved": "Je Upload #{id} is goedgekeurd",
|
||||
"upload_approved_short": "Je Upload is goedgekeurd",
|
||||
"upload_pending": "Een nieuwe upload (#{id}) moet worden goedgekeurd",
|
||||
"upload_pending_short": "Een nieuwe upload moet worden goedgekeurd",
|
||||
"new_report": "Een nieuw gebruikersrapport (#{id}) is ingediend",
|
||||
"new_report_short": "Een nieuw gebruikersrapport is ingediend",
|
||||
"upload_denied": "Je Upload #{id} is afgewezen",
|
||||
"upload_denied_short": "Je Upload is afgewezen",
|
||||
"reason": "Reden:",
|
||||
"reason_label": "Reden:",
|
||||
"no_reason": "Geen reden opgegeven",
|
||||
"upload_deleted": "Een moderator heeft je upload #{id} verwijderd",
|
||||
"upload_deleted_short": "Een moderator heeft je upload verwijderd",
|
||||
"click_reason": "Klik om de reden te zien",
|
||||
"new_comments": "Nieuwe opmerkingen",
|
||||
"on_your_upload": "bij je upload #{id}",
|
||||
"replied": "heeft op je gereageerd bij #{id}",
|
||||
"replied_short": "heeft op je gereageerd",
|
||||
"subscribed": "heeft gereageerd in een thread die je volgt (#{id})",
|
||||
"subscribed_short": "heeft gereageerd in een thread die je volgt",
|
||||
"mentioned": "noemde je bij #{id}",
|
||||
"mentioned_short": "noemde je",
|
||||
"commented": "heeft gereageerd",
|
||||
"upload_success": "Je achtergrond-upload is klaar!",
|
||||
"upload_error": "Je achtergrond-upload is mislukt.",
|
||||
"page_title": "MELDINGEN",
|
||||
"mark_all_read": "Alles als gelezen markeren",
|
||||
"scroll_for_more": "Scroll voor meer..."
|
||||
},
|
||||
"error": {
|
||||
"label": "Fout"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Overal slepen om te uploaden"
|
||||
},
|
||||
"comments": {
|
||||
"write_comment": "Schrijf een opmerking...",
|
||||
"post": "Plaatsen",
|
||||
"cancel": "Annuleren"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Selecteer een bestand",
|
||||
"enter_url": "Voer een URL in",
|
||||
"tags_required": "{n} extra tag{s} vereist",
|
||||
"select_rating": "Selecteer SFW of NSFW",
|
||||
"embed_youtube": "YouTube-video Insluiten",
|
||||
"upload_from_url": "Uploaden via URL",
|
||||
"upload": "Uploaden"
|
||||
},
|
||||
"common": {
|
||||
"sending": "Verzenden...",
|
||||
"updating": "Bijwerken...",
|
||||
"saving": "Opslaan...",
|
||||
"loading": "Laden...",
|
||||
"cancel": "Annuleren",
|
||||
"add": "Toevoegen",
|
||||
"remove": "Verwijderen",
|
||||
"close": "Sluiten",
|
||||
"submit": "Indienen",
|
||||
"confirm": "Bevestigen",
|
||||
"send": "Verzenden",
|
||||
"name": "Naam",
|
||||
"description": "Beschrijving",
|
||||
"private": "Privé",
|
||||
"view": "Bekijken"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Actie Bevestigen",
|
||||
"reason_placeholder": "Reden voor deze actie...",
|
||||
"confirm": "Bevestigen"
|
||||
},
|
||||
"toptags": {
|
||||
"posts": "{count} posts"
|
||||
},
|
||||
"hall": {
|
||||
"modal_title": "Toevoegen aan Hal",
|
||||
"site_hall_label": "Site-hal (mod):",
|
||||
"choose_hall": "-- Kies een Hal --",
|
||||
"add_btn": "Toevoegen aan Hal",
|
||||
"remove_btn": "Verwijderen uit Hal",
|
||||
"my_hall_label": "Mijn Hal:",
|
||||
"my_hall_placeholder": "-- Mijn Hal --",
|
||||
"create_new": "of maak een nieuwe aan:",
|
||||
"new_name_placeholder": "Nieuwe hal-naam…",
|
||||
"no_halls": "Nog geen hallen — maak er een aan!",
|
||||
"select_a_hall": "Selecteer een hal.",
|
||||
"choose_or_create": "Kies een hal of voer een nieuwe naam in.",
|
||||
"creating": "Aanmaken…",
|
||||
"adding": "Toevoegen…",
|
||||
"removing": "Verwijderen…",
|
||||
"posts": "{count} posts",
|
||||
"user_halls_title": "📁 Hallen van {user}",
|
||||
"new_hall_placeholder": "Nieuwe hall-naam…",
|
||||
"new_hall_btn": "+ Nieuwe Hal",
|
||||
"enter_name_error": "Voer eerst een naam in.",
|
||||
"no_halls_owner": "Je hebt nog geen hallen aangemaakt. Voer hierboven een naam in en klik op <strong>+ Nieuwe Hal</strong>!",
|
||||
"no_halls_guest": "Nog geen hallen hier.",
|
||||
"manager_title": "🏛 Hal-Manager",
|
||||
"manager_desc": "Beheer alle hallen — bewerk beschrijvingen, upload eigen afbeeldingen, of verwijder hallen.",
|
||||
"create_hall_btn": "+ Hal Aanmaken",
|
||||
"slug": "Slug",
|
||||
"rating": "Beoordeling",
|
||||
"click_upload_hint": "Klik om een eigen afbeelding te uploaden",
|
||||
"delete_confirm_slug": "Hal \"{slug}\" echt verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"delete_confirm": "Deze hal verwijderen? Dit kan niet ongedaan worden gemaakt.",
|
||||
"deleting": "Verwijderen…",
|
||||
"saving": "Opslaan…",
|
||||
"saved": "Opgeslagen",
|
||||
"error_label": "Fout",
|
||||
"slug_empty_error": "Slug mag niet leeg zijn",
|
||||
"uploading": "Uploaden…",
|
||||
"image_uploaded": "Afbeelding geüpload",
|
||||
"image_removed": "Eigen afbeelding verwijderd"
|
||||
},
|
||||
"content_warning": {
|
||||
"title": "Inhoudswaarschuwing",
|
||||
"text": "Deze site bevat door gebruikers gegenereerde inhoud die materiaal kan bevatten dat niet geschikt is voor alle leeftijden of doelgroepen, inclusief flitsende lichten die epileptische aanvallen kunnen veroorzaken bij mensen met fotosensitieve epilepsie. Door verder te gaan, bevestig je dat je bereid bent dergelijke inhoud te bekijken.",
|
||||
"proceed": "Doorgaan",
|
||||
"decline": "Breng me hier weg"
|
||||
},
|
||||
"report": {
|
||||
"title": "Rapport Indienen",
|
||||
"placeholder": "Geef details voor dit rapport (vereist)...",
|
||||
"submit": "Indienen"
|
||||
},
|
||||
"account_warning": {
|
||||
"title": "Accountwaarschuwing",
|
||||
"text": "Je hebt een waarschuwing ontvangen van een moderator:",
|
||||
"acknowledge": "Ik Begrijp het"
|
||||
},
|
||||
"sidebar": {
|
||||
"loading_activity": "Activiteit laden...",
|
||||
"view": "Bekijken",
|
||||
"read_more": "lees meer",
|
||||
"see_less": "zie minder"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Mijn Abonnementen",
|
||||
"empty": "Je bent nog niet geabonneerd op threads.",
|
||||
"by_user": "door {user}",
|
||||
"unsubscribe": "Afmelden"
|
||||
},
|
||||
"search": {
|
||||
"title": "zoeken",
|
||||
"strict_mode": "Strikte Modus",
|
||||
"results_found": "{count} uploads gevonden (pagina {page} van {total}):",
|
||||
"col_thumbnail": "Thumbnail",
|
||||
"col_id": "ID",
|
||||
"col_tag": "Tag",
|
||||
"col_mime": "Mime",
|
||||
"col_username": "Gebruikersnaam",
|
||||
"col_score": "Score"
|
||||
},
|
||||
"ranking": {
|
||||
"title": "Ranking",
|
||||
"top_contributors": "Top Bijdragers",
|
||||
"col_rank": "Rang",
|
||||
"col_avatar": "Avatar",
|
||||
"col_username": "Gebruikersnaam",
|
||||
"col_tagged": "Getagd",
|
||||
"tag_stats": "Tag-statistieken",
|
||||
"stat_total": "Totaal aantal items",
|
||||
"stat_tagged": "Getagd",
|
||||
"stat_untagged": "Ongetagd",
|
||||
"stat_sfw": "SFW-inhoud",
|
||||
"stat_nsfw": "NSFW-inhoud",
|
||||
"stat_deleted": "Verwijderd",
|
||||
"most_favorited": "Meest Gefavoriet",
|
||||
"favs": "favorieten",
|
||||
"top_xd": "Top xD-scores"
|
||||
},
|
||||
"upload_page": {
|
||||
"title": "Inhoud Uploaden",
|
||||
"limit_unlimited": "Onbeperkte uploads",
|
||||
"limit_reached": "Uploadlimiet bereikt (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} uploads over",
|
||||
"auth_required_title": "Authenticatie Vereist",
|
||||
"auth_required_text": "Je moet ingelogd zijn om inhoud naar w0bm te uploaden.",
|
||||
"login_btn": "Inloggen"
|
||||
},
|
||||
"messages": {
|
||||
"page_title": "BERICHTEN",
|
||||
"manage_keys": "🔑 Sleutels Beheren",
|
||||
"loading": "Gesprekken laden…",
|
||||
"decrypting": "Berichten ontsleutelen…",
|
||||
"input_placeholder": "Schrijf een bericht…",
|
||||
"send": "Verzenden"
|
||||
},
|
||||
"profile": {
|
||||
"message_btn": "✉ bericht",
|
||||
"legacy_record": "Legacy Record – Eerste Upload:",
|
||||
"joined": "Lid geworden:",
|
||||
"stat_comments": "Opmerkingen:",
|
||||
"stat_tags": "Tags:",
|
||||
"stat_halls": "Hallen:",
|
||||
"unban_btn": "Gebruiker ontbannen",
|
||||
"ban_btn": "Gebruiker bannen",
|
||||
"warn_btn": "Gebruiker waarschuwen",
|
||||
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
||||
"no_uploads": "geen uploads gevonden",
|
||||
"no_favs": "geen favorieten",
|
||||
"back_to_profile": "Terug naar Profiel",
|
||||
"ban_modal_title": "Gebruiker Bannen",
|
||||
"ban_modal_reason": "Reden:",
|
||||
"ban_modal_duration": "Duur:",
|
||||
"ban_modal_cancel": "Annuleren",
|
||||
"ban_modal_confirm": "Ban Bevestigen",
|
||||
"ban_1h": "1 Uur",
|
||||
"ban_1d": "1 Dag",
|
||||
"ban_1w": "1 Week",
|
||||
"ban_1m": "1 Maand",
|
||||
"ban_permanent": "Permanent",
|
||||
"warn_modal_title": "Waarschuwing Geven",
|
||||
"warn_modal_reason": "Reden voor waarschuwing:",
|
||||
"warn_modal_hint": "Deze reden zal zichtbaar zijn voor de gebruiker, en zij moeten deze erkennen om het bericht te verwijderen.",
|
||||
"warn_modal_cancel": "Annuleren",
|
||||
"warn_modal_submit": "Waarschuwing Geven",
|
||||
"comments_title": "Opmerkingen van {user}",
|
||||
"view_all": "bekijk alles",
|
||||
"uploads_label": "uploads",
|
||||
"favs_label": "favorieten",
|
||||
"confirm_unban": "Weet je zeker dat je deze gebruiker wilt ontbannen?",
|
||||
"unban_success": "Gebruiker succesvol ontband",
|
||||
"ban_success": "Gebruiker succesvol verbannen",
|
||||
"warning_issuing": "Verzenden...",
|
||||
"warning_success": "Gebruiker succesvol gewaarschuwd",
|
||||
"warning_issue_btn": "Waarschuwing Geven",
|
||||
"confirm_subscribe_uploads": "Hiermee word je geabonneerd op al je eerdere uploads. Doorgaan?",
|
||||
"subscribing": "Abonneren...",
|
||||
"subscribed": "Geabonneerd!",
|
||||
"subscribe_to_my_uploads": "Abonneren op al mijn uploads"
|
||||
},
|
||||
"metadata_modal": {
|
||||
"title": "Metadata-suggesties",
|
||||
"loading": "Metadata uit bestand extraheren...",
|
||||
"found": "Gevonden in metadata:",
|
||||
"no_results": "Geen extra metadata-velden gevonden in dit bestand."
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Tekstlaag Toevoegen",
|
||||
"tags_label": "Tags (gescheiden door komma's)",
|
||||
"upload_btn": "Meme Uploaden",
|
||||
"back_btn": "Terug naar Sjablonen",
|
||||
"select_title": "Selecteer Sjabloon",
|
||||
"select_subtitle": "Kies een sjabloon om je meme te maken",
|
||||
"text_layer": "Tekstlaag",
|
||||
"enter_text": "Voer tekst in...",
|
||||
"size_label": "Grootte",
|
||||
"create_meme": "Meme Maken:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "zojuist",
|
||||
"year": "{n} jaar",
|
||||
"years": "{n} jaar",
|
||||
"month": "{n} maand",
|
||||
"months": "{n} maanden",
|
||||
"day": "{n} dag",
|
||||
"days": "{n} dagen",
|
||||
"hour": "{n} uur",
|
||||
"hours": "{n} uur",
|
||||
"minute": "{n} minuut",
|
||||
"minutes": "{n} minuten",
|
||||
"second": "{n} seconde",
|
||||
"seconds": "{n} seconden",
|
||||
"ago": "{t} geleden"
|
||||
},
|
||||
"search_overlay": {
|
||||
"placeholder": "Tags Zoeken",
|
||||
"strict_mode": "Strikte Modus"
|
||||
},
|
||||
"toast": {
|
||||
"report_success": "Rapport succesvol ingediend.",
|
||||
"report_error": "Er is een fout opgetreden.",
|
||||
"network_error": "Netwerkfout.",
|
||||
"reason_required": "Reden is vereist.",
|
||||
"reason_optional": "Reden (optioneel)",
|
||||
"reason_required_label": "Reden (vereist)",
|
||||
"processing": "Verwerken...",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"confirm": "Bevestigen",
|
||||
"cancel": "Annuleren",
|
||||
"add_tag": "Tag toevoegen",
|
||||
"fav_added": "TOEGEVOEGD AAN FAVORIETEN",
|
||||
"fav_removed": "VERWIJDERD UIT FAVORIETEN",
|
||||
"subscribed_thread": "GEABONNEERD OP THREAD",
|
||||
"unsubscribed_thread": "AFGEMELD VOOR THREAD",
|
||||
"oc_marked": "GEMARKEERD ALS ORIGINELE INHOUD",
|
||||
"oc_removed": "OC-STATUS VERWIJDERD",
|
||||
"no_tags_excluded": "Geen tags uitgesloten",
|
||||
"tag_delete_title": "Tag Verwijderen",
|
||||
"tag_delete_confirm": "Weet je zeker dat je deze tag wilt verwijderen?",
|
||||
"tag_deleted_success": "Tag succesvol verwijderd",
|
||||
"item_deleted_success": "Item succesvol verwijderd",
|
||||
"item_delete_title": "Item Verwijderen",
|
||||
"item_delete_confirm": "Weet je zeker dat je item {id} van {user} wilt verwijderen?",
|
||||
"hall_added": "TOEGEVOEGD AAN HAL",
|
||||
"hall_removed": "VERWIJDERD UIT HAL",
|
||||
"item_pinned": "ITEM VASTGEZET",
|
||||
"item_unpinned": "ITEM LOSGEMAAKT",
|
||||
"tag_added": "Tag \"{tag}\" toegevoegd!",
|
||||
"error_adding_tag": "Fout bij toevoegen tag",
|
||||
"network_error_short": "Netwerkfout",
|
||||
"error_saving": "Fout bij opslaan",
|
||||
"mode_activated": "{mode}-MODUS GEACTIVEERD",
|
||||
"zomg_on": "ZOMG-modus geactiveerd",
|
||||
"zomg_off": "ZOMG-modus gedeactiveerd",
|
||||
"copied": "URL naar klembord gekopieerd"
|
||||
},
|
||||
"footer": {
|
||||
"ranking": "Ranking",
|
||||
"rules": "Regels",
|
||||
"about": "Over"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Site-chat",
|
||||
"placeholder": "Chatten…",
|
||||
"minimize": "Minimaliseren",
|
||||
"expand": "Uitvouwen",
|
||||
"slow_down": "Langzamer!",
|
||||
"error_send": "Versturen mislukt",
|
||||
"network_error": "Netwerkfout"
|
||||
}
|
||||
}
|
||||
560
src/inc/locales/zange.json
Normal file
560
src/inc/locales/zange.json
Normal file
@@ -0,0 +1,560 @@
|
||||
{
|
||||
"nav": {
|
||||
"upload": "Aufladieren",
|
||||
"meme": "Memen",
|
||||
"halls": "Hallen",
|
||||
"tags": "Etiketten",
|
||||
"search": "Suche",
|
||||
"random": "Zufall",
|
||||
"profile": "Profil",
|
||||
"my_halls": "Meine Hallen",
|
||||
"favs": "Favoriten",
|
||||
"admin": "Administrator",
|
||||
"mod": "Moderator",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmeldung",
|
||||
"notifications": "Hinweise",
|
||||
"mark_all_read": "Alle als gelesen markieren",
|
||||
"no_notifications": "Keine neuen Hinweise",
|
||||
"view_all_notifications": "Alle Hinweise betrachten",
|
||||
"manage_subscriptions": "Abonnements verwalten",
|
||||
"favorites": "Favoriten",
|
||||
"direct_messages": "Direktnachrichten",
|
||||
"excluded_tags": "Ausgeschlossene Etiketten",
|
||||
"guest": "Gast",
|
||||
"login": "Anmeldung",
|
||||
"register": "Registrierung",
|
||||
"overview": "Überblick",
|
||||
"prev": "zurück",
|
||||
"next": "weiter",
|
||||
"random_nav": "Zufall"
|
||||
},
|
||||
"upload": {
|
||||
"title": "Aufladieren",
|
||||
"drop_anywhere": "Überall ablegen zum Aufladieren",
|
||||
"unlimited": "Unbegrenzte Aufladierungen",
|
||||
"limit_reached": "Aufladierungslimit erreicht",
|
||||
"remaining": "Aufladierungen verbleibend",
|
||||
"file_tab": "Aufladierung",
|
||||
"url_tab": "Elfen",
|
||||
"url_tab_yt": "Elfen / DuRöhre",
|
||||
"url_placeholder": "Fügen Sie eine Elfe zum Hinunterladieren ein...",
|
||||
"url_placeholder_yt": "Fügen Sie eine Netz-Elfe oder eine DuRöhrenelfe ein",
|
||||
"drop_here": "Legen Sie Ihre Datei hier ab",
|
||||
"admin_boost": "Administrator-Verstärkung",
|
||||
"custom_thumbnail": "Benutzerdefiniertes Vorschaubild",
|
||||
"custom_thumbnail_hint": "Falls nicht gesetzt, wird auf das Standard-Vorschaubild zurückgegriffen",
|
||||
"rating": "Bewertung",
|
||||
"original_content": "Originaler Inhalt (OI)",
|
||||
"tags": "Etiketten",
|
||||
"tags_minimum": "Minimum",
|
||||
"extracting_title": "Titel wird extrahiert...",
|
||||
"tag_placeholder": "Etikett eingeben und Eingabetaste drücken",
|
||||
"suggestions_header": "Vorschläge aus den Metadaten",
|
||||
"comment": "Kommentar",
|
||||
"comment_optional": "(optional)",
|
||||
"comment_placeholder": "Fügen Sie Ihrer Aufladierung doch einen Kommentar hinzu...",
|
||||
"select_file": "Datei auswählen",
|
||||
"uploading": "Wird aufladiert...",
|
||||
"pending_approval_patient": "Die Ladung harrt der Absegnung, bitte haben Sie Geduld",
|
||||
"remove_file": "Datei entfernen",
|
||||
"cancel_upload": "Aufladierung abbrechen"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Registrierung wird in die Wege geleitet...",
|
||||
"already_logged_in": "Sie sind bereits angemeldet, Mensch",
|
||||
"login_title": "Anmeldung",
|
||||
"username_placeholder": "Benutzername",
|
||||
"username_or_email": "Benutzername oder E-Post",
|
||||
"password_placeholder": "Kennwort",
|
||||
"password_placeholder_min": "Kennwort",
|
||||
"stay_signed_in": "Angemeldet bleiben",
|
||||
"stay_signed_in_label": "angemeldet bleiben",
|
||||
"forgot_password": "Kennwort vergessen?",
|
||||
"no_account": "Kein Konto vorhanden?",
|
||||
"register_now": "Jetzt registrieren!",
|
||||
"register_title": "Registrierung",
|
||||
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
|
||||
"confirm_password": "Kennwort bestätigen",
|
||||
"email_placeholder": "E-Post",
|
||||
"invite_token": "Einladungskennzeichen",
|
||||
"tos_private": "Ich bin mindestens 18 Jahre alt und stimme der Befolgung des Regelwerks zu",
|
||||
"tos_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
|
||||
"tos_terms": "Nutzungsbedingungen",
|
||||
"tos_rules": "Regeln",
|
||||
"tos_age": "und ich bin mindestens 18 Jahre alt",
|
||||
"tos_private_simple": "Ich bin mindestens 18 Jahre alt",
|
||||
"create_account": "Konto erstellen",
|
||||
"back_to_login": "Zurück zur Anmeldung",
|
||||
"forgot_title": "Kennwort vergessen",
|
||||
"forgot_desc": "Geben Sie Ihre E-Post-Adresse ein, um eine Verknüpfung zum Zurücksetzen zu erhalten.",
|
||||
"email_address": "E-Post-Adresse",
|
||||
"send_reset": "Zurücksetzungsverknüpfung senden",
|
||||
"reset_title": "Kennwort zurücksetzen",
|
||||
"reset_desc": "Geben Sie unten Ihr neues Kennwort ein.",
|
||||
"new_password_min": "Neues Kennwort (mind. 20 Zeichen)",
|
||||
"confirm_new_password": "Neues Kennwort bestätigen",
|
||||
"update_password": "Kennwort aktualisieren",
|
||||
"password_change_required": "Kennwort-Aktualisierung erforderlich",
|
||||
"password_change_desc": "Ein Administrator hat verlangt, dass Sie Ihr Kennwort ändern, bevor Sie fortfahren. Dieses Kennwort muss mindestens 20 Zeichen lang sein.",
|
||||
"new_password_label": "Neues Kennwort",
|
||||
"confirm_password_label": "Kennwort bestätigen",
|
||||
"min_chars_placeholder": "Mindestens 20 Zeichen",
|
||||
"confirm_placeholder": "Neues Kennwort bestätigen",
|
||||
"update_password_btn": "Kennwort aktualisieren",
|
||||
"or_logout": "Oder von diesem Konto abmelden"
|
||||
},
|
||||
"settings": {
|
||||
"switching": "Umschaltung wird vorgenommen...",
|
||||
"generating": "Generierung wird angestoßen...",
|
||||
"title": "Einstellungen",
|
||||
"avatar": "Profilbild",
|
||||
"current_avatar": "Aktuelles Profilbild",
|
||||
"upload_custom_avatar": "Benutzerdefiniertes Profilbild aufladieren",
|
||||
"avatar_hint": "Maximal 5 MB. Erlaubt: gif, jpg, png, webp",
|
||||
"choose_file": "Datei auswählen",
|
||||
"no_file_selected": "Keine Datei ausgewählt",
|
||||
"upload_btn": "Aufladieren",
|
||||
"remove_custom": "Benutzerdefiniertes entfernen",
|
||||
"custom_description": "Benutzerdefinierte Beschreibung",
|
||||
"description_placeholder": "Fügen Sie Ihrem Profil eine Beschreibung hinzu",
|
||||
"save_description": "Beschreibung speichern",
|
||||
"clear": "Leeren",
|
||||
"preferences": "Präferenzen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"show_motd": "Nachricht des Tages (NdT) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||
"disable_autoplay_hint": "Vermeiden Sie das automatische Abspielen von Videos und Tondateien",
|
||||
"disable_swiping": "Wischen deaktivieren",
|
||||
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
"enable_bg_blur_hint": "Gefalteten Hintergrund auf Elementen anzeigen",
|
||||
"render_emojis": "Emojis in Zitatantworten darstellen",
|
||||
"render_emojis_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
|
||||
"embed_yt": "DuRöhre-Verknüpfungen in Kommentaren einbetten",
|
||||
"embed_yt_hint": "Ersetzen Sie DuRöhre-Verknüpfungen durch integrierte Videospieler",
|
||||
"hide_koepfe": "Köpfe verbergen",
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"language": "Sprache",
|
||||
"language_hint": "Ändern Sie die Seitensprache. Lädt die Seite neu, um die Änderungen anzuwenden.",
|
||||
"language_default": "Standard (Seite)",
|
||||
"language_en": "Englisch",
|
||||
"language_de": "Deutsch",
|
||||
"language_nl": "Niederländisch",
|
||||
"language_zange": "Zangendeutsch",
|
||||
"scroll_nav": "Mausrad-Navigation auf Elementen",
|
||||
"scroll_nav_hint": "Navigieren Sie zum nächsten/vorherigen Element durch Scrollen auf dem Medienbereich",
|
||||
"username_color": "Benutzerdefinierte Benutzername-Farbe",
|
||||
"username_color_hint": "Wählen Sie eine Farbe oder geben Sie einen Hex-Code für Ihren Benutzernamen auf Elementen und Kommentaren ein.",
|
||||
"save_color": "Farbe speichern",
|
||||
"reset": "Zurücksetzen",
|
||||
"website_font": "Schriftart der Weltnetzpräsenz",
|
||||
"font_default": "Standard",
|
||||
"theme": "Thema",
|
||||
"flash_section": "Flash",
|
||||
"flash_volume": "Standard-Flash-Lautstärke",
|
||||
"flash_bg": "Flash im Hintergrund weiterlaufen lassen",
|
||||
"flash_bg_hint": "Verhindert, dass Ruffle pausiert, wenn der Reiter verlassen wird",
|
||||
"save_flash": "Flash-Einstellungen speichern",
|
||||
"content_filters": "Inhaltsfilter",
|
||||
"min_xd_score": "Minimaler xD-Punktestand",
|
||||
"min_xd_score_hint": "Zeige nur Pfosten mit mindestens diesem xD-Punktestand an. Auf 0 setzen zum Deaktivieren.",
|
||||
"save": "Speichern",
|
||||
"account": "Konto",
|
||||
"user_id": "Benutzeridentifikation",
|
||||
"username": "Benutzername",
|
||||
"display_name": "Anzeigename",
|
||||
"display_name_placeholder": "Anzeigename",
|
||||
"email": "E-Post",
|
||||
"email_not_set": "Nicht gesetzt",
|
||||
"joined": "Beigetreten",
|
||||
"joined_unknown": "Unbekannt",
|
||||
"change_password": "Kennwort ändern",
|
||||
"current_password": "Aktuelles Kennwort",
|
||||
"new_password": "Neues Kennwort (mind. 20 Zeichen)",
|
||||
"confirm_new_password": "Neues Kennwort bestätigen",
|
||||
"update_password": "Kennwort aktualisieren",
|
||||
"update_email": "E-Post aktualisieren",
|
||||
"new_email": "Neue E-Post-Adresse",
|
||||
"update_email_btn": "E-Post aktualisieren",
|
||||
"email_warning_smtp": "Falls keine gültige E-Post-Adresse angegeben wird, können Sie Ihr Kennwort möglicherweise nicht zurücksetzen!",
|
||||
"email_info_no_smtp": "E-Post muss <b>nicht</b> gültig sein, sie wird für Ihre Anmeldung zusammen mit Ihrem Benutzernamen verwendet.<br> Es wird keine Bestätigung gesendet.",
|
||||
"linked_accounts": "Verknüpfte Konten",
|
||||
"matrix_link_desc": "Verknüpfen Sie Ihren Matrix-Nutzerzugang, um die Aufladierfunktion aus Matrix-Räumen zu nutzen",
|
||||
"active_links": "Aktive Verknüpfungen:",
|
||||
"loading": "Wird geladen...",
|
||||
"add_new_link": "Neue Verknüpfung hinzufügen",
|
||||
"matrix_instructions": "1. Erzeugen Sie unten ein allgemeines Verknüpfungskennzeichen.<br>2. Senden Sie <code>!link</code> im Hauptraum<br>3. Antworten Sie mit Ihrem Kennzeichen in der Bot-Direktnachricht",
|
||||
"your_token": "Ihr Kennzeichen:",
|
||||
"one_time_use": "Gültig für den einmaligen Gebrauch.",
|
||||
"generate_token": "Verknüpfungskennzeichen erzeugen"
|
||||
},
|
||||
"filter": {
|
||||
"tag_placeholder": "Auszuschließendes Etikett",
|
||||
"random_mode": "ZUFA",
|
||||
"min_xd_score": "Min. xD-Punktestand",
|
||||
"apply_filter": "Filter anwenden",
|
||||
"video": "Video",
|
||||
"audio": "Tondatei",
|
||||
"image": "Bild",
|
||||
"flash": "Blitz"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Tastaturkürzel",
|
||||
"search": "Suche",
|
||||
"main_page": "Hauptseite",
|
||||
"random": "Zufall",
|
||||
"shuffle": "Misch- / Zufallsmodus",
|
||||
"quick_upload": "Schnellaufladierung",
|
||||
"toggle_comments": "verbirgt/zeigt die Kommentare",
|
||||
"toggle_sidebar": "verbirgt/zeigt die Seitenleiste",
|
||||
"focus_comment": "Kommentareingabe fokussieren",
|
||||
"send_comment": "Kommentar senden",
|
||||
"flash_yank": "Blitz-Rucken aktivieren/deaktivieren",
|
||||
"tag_exclude": "Etiketten-Ausschluss öffnen",
|
||||
"tag_input": "Etiketten-Eingabe öffnen",
|
||||
"toggle_bg": "Hintergrund ein-/ausschalten",
|
||||
"cycle_themes": "Themen durchwechseln",
|
||||
"delete": "Entf",
|
||||
"toggle_rating": "Elementbewertung umschalten",
|
||||
"play_pause": "Abspielen/Pause",
|
||||
"next_prev": "nächster/vorheriger",
|
||||
"scroll_nav": "nächster/vorheriger",
|
||||
"show_help": "Hilfe anzeigen"
|
||||
},
|
||||
"notifications": {
|
||||
"system": "System",
|
||||
"admin": "Administrator",
|
||||
"moderation": "Moderation",
|
||||
"upload_approved": "Ihre Aufladierung #{id} wurde genehmigt",
|
||||
"upload_approved_short": "Ihre Aufladierung wurde genehmigt",
|
||||
"upload_pending": "Eine neue Aufladierung (#{id}) benötigt eine Genehmigung",
|
||||
"upload_pending_short": "Eine neue Aufladierung benötigt eine Genehmigung",
|
||||
"new_report": "Eine neue Benutzermeldung (#{id}) wurde eingereicht",
|
||||
"new_report_short": "Eine neue Benutzermeldung wurde eingereicht",
|
||||
"upload_denied": "Ihre Aufladierung #{id} wurde abgelehnt",
|
||||
"upload_denied_short": "Ihre Aufladierung wurde abgelehnt",
|
||||
"reason": "Grund:",
|
||||
"reason_label": "Grund:",
|
||||
"no_reason": "Kein Grund angegeben",
|
||||
"upload_deleted": "Ein Moderator hat Ihre Aufladierung #{id} gelöscht",
|
||||
"upload_deleted_short": "Ein Moderator hat Ihre Aufladierung gelöscht",
|
||||
"click_reason": "Klicken, um den Grund zu sehen",
|
||||
"new_comments": "Neue Kommentare",
|
||||
"on_your_upload": "zu Ihrer Aufladierung #{id}",
|
||||
"replied": "hat Ihnen auf #{id} geantwortet",
|
||||
"replied_short": "hat Ihnen geantwortet",
|
||||
"subscribed": "hat in einem Faden kommentiert, dem Sie folgen (#{id})",
|
||||
"subscribed_short": "hat in einem Faden kommentiert, dem Sie folgen",
|
||||
"mentioned": "hat Sie auf #{id} erwähnt",
|
||||
"mentioned_short": "hat Sie erwähnt",
|
||||
"commented": "kommentierte",
|
||||
"upload_success": "Ihre Aufladierung ist fertig!",
|
||||
"upload_error": "Ihre Aufladierung ist fehlgeschlagen.",
|
||||
"page_title": "BENACHRICHTIGUNGEN",
|
||||
"mark_all_read": "Alle als gelesen markieren",
|
||||
"scroll_for_more": "Scrollen für mehr..."
|
||||
},
|
||||
"error": {
|
||||
"label": "Fehler"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Überall ablegen zum Aufladieren"
|
||||
},
|
||||
"comments": {
|
||||
"write_comment": "Schreiben Sie doch einen Kommentar...",
|
||||
"post": "Pfostieren",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Datei auswählen",
|
||||
"enter_url": "Elfen eingeben",
|
||||
"tags_required": "{n} weitere(s) Etikett(en) erforderlich",
|
||||
"select_rating": "Wählen Sie SFW oder NSFW",
|
||||
"embed_youtube": "DuRöhre-Video einbetten",
|
||||
"upload_from_url": "Von Elfe aufladieren",
|
||||
"upload": "Aufladieren"
|
||||
},
|
||||
"common": {
|
||||
"sending": "Sendung wird initiiert...",
|
||||
"updating": "Aktualisierung wird vollzogen...",
|
||||
"saving": "Speicherung wird vollzogen...",
|
||||
"loading": "Ladung wird aufbereitet...",
|
||||
"cancel": "Abbrechen",
|
||||
"add": "Hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"close": "Schließen",
|
||||
"submit": "Pfostieren",
|
||||
"confirm": "Bestätigen",
|
||||
"send": "Pfostieren",
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"private": "Privat",
|
||||
"view": "Ansehen",
|
||||
"save": "Speichern",
|
||||
"delete": "Löschen",
|
||||
"edit": "Editieren"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Aktion bestätigen",
|
||||
"reason_placeholder": "Grund für diese Aktion...",
|
||||
"confirm": "Bestätigen"
|
||||
},
|
||||
"toptags": {
|
||||
"posts": "{count} Pfosten"
|
||||
},
|
||||
"hall": {
|
||||
"modal_title": "Zur Halle hinzufügen",
|
||||
"site_hall_label": "Seitenhalle (Mod):",
|
||||
"choose_hall": "-- Wählen Sie eine Halle --",
|
||||
"add_btn": "Zur Halle hinzufügen",
|
||||
"remove_btn": "Aus der Halle entfernen",
|
||||
"my_hall_label": "Meine Halle:",
|
||||
"my_hall_placeholder": "-- Meine Halle --",
|
||||
"create_new": "oder neu erstellen:",
|
||||
"new_name_placeholder": "Neuer Hallenname…",
|
||||
"no_halls": "Noch keine Hallen vorhanden — erstellen Sie eine!",
|
||||
"select_a_hall": "Bitte wählen Sie eine Halle aus.",
|
||||
"choose_or_create": "Wählen Sie eine Halle oder geben Sie einen neuen Namen ein.",
|
||||
"creating": "Erstellung wird durchgeführt…",
|
||||
"adding": "Hinzufügung wird durchgeführt…",
|
||||
"removing": "Entfernung wird durchgeführt…",
|
||||
"posts": "{count} Pfosten",
|
||||
"user_halls_title": "Hallen von {user}",
|
||||
"new_hall_placeholder": "Neuer Hallenname…",
|
||||
"new_hall_btn": "+ Neue Halle",
|
||||
"enter_name_error": "Geben Sie zuerst einen Namen ein.",
|
||||
"no_halls_owner": "Sie haben bisher keine Hallen erstellt. Geben Sie oben einen Namen ein und klicken Sie auf <strong>+ Neue Halle</strong>!",
|
||||
"no_halls_guest": "Bisher keine Hallen vorhanden.",
|
||||
"manager_title": "🏛 Hallen-Manager",
|
||||
"manager_desc": "Verwalten Sie alle Hallen — Beschreibungen bearbeiten, eigene Lichtbilder aufladieren oder Hallen löschen.",
|
||||
"create_hall_btn": "Halle erstellen",
|
||||
"slug": "Schnecke",
|
||||
"rating": "Bewertung",
|
||||
"click_upload_hint": "Klicken Sie, um ein eigenes Lichtbild aufzuladieren",
|
||||
"delete_confirm_slug": "Halle \"{slug}\" wirklich löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"delete_confirm": "Diese Halle löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"created": "✓ Erstellt!",
|
||||
"deleting": "Löschung wird durchgeführt…",
|
||||
"saving": "Speicherung wird durchgeführt…",
|
||||
"saved": "Gespeichert",
|
||||
"error_label": "Absturz",
|
||||
"slug_empty_error": "Schnecke darf nicht leer sein",
|
||||
"uploading": "Ladung wird durchgeführt…",
|
||||
"image_uploaded": "Lichtbild aufgeladen",
|
||||
"image_removed": "Eigenes Lichtbild entfernt"
|
||||
},
|
||||
"content_warning": {
|
||||
"title": "Inhaltswarnung",
|
||||
"text": "Diese Weltnetzpräsenz enthält von Benutzern erzeugte Inhalte, die Material enthalten können, das nicht für alle Altersgruppen oder Zielgruppen geeignet ist, einschließlich blinkender Lichter, die bei Menschen mit lichtempfindlicher Epilepsie Anfälle auslösen können. Durch Fortfahren bestätigen Sie, dass Sie bereit sind, solche Inhalte zu betrachten.",
|
||||
"proceed": "Fortfahren",
|
||||
"decline": "Bringen Sie mich hier weg"
|
||||
},
|
||||
"report": {
|
||||
"title": "Meldung einreichen",
|
||||
"placeholder": "Bitte geben Sie Details für diese Meldung an (erforderlich)...",
|
||||
"submit": "Absenden"
|
||||
},
|
||||
"account_warning": {
|
||||
"title": "Kontowarnung",
|
||||
"text": "Sie haben eine Verwarnung von einem Moderator erhalten:",
|
||||
"acknowledge": "Ich verstehe"
|
||||
},
|
||||
"sidebar": {
|
||||
"loading_activity": "Aktivität wird geladen...",
|
||||
"view": "Ansehen",
|
||||
"read_more": "mehr lesen",
|
||||
"see_less": "weniger sehen"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Meine Abonnements",
|
||||
"empty": "Sie haben bisher keine Fäden abonniert.",
|
||||
"by_user": "von {user}",
|
||||
"unsubscribe": "Abbestellen"
|
||||
},
|
||||
"search": {
|
||||
"title": "Suche",
|
||||
"strict_mode": "Strenger Modus",
|
||||
"results_found": "{count} Aufladierungen gefunden (Seite {page} von {total}):",
|
||||
"col_thumbnail": "Vorschaubild",
|
||||
"col_id": "Identifikation",
|
||||
"col_tag": "Etikett",
|
||||
"col_mime": "Mime-Typ",
|
||||
"col_username": "Benutzername",
|
||||
"col_score": "Punktzahl"
|
||||
},
|
||||
"ranking": {
|
||||
"title": "Rangliste",
|
||||
"top_contributors": "Top-Beitragende",
|
||||
"col_rank": "Rang",
|
||||
"col_avatar": "Profilbild",
|
||||
"col_username": "Benutzername",
|
||||
"col_tagged": "Etikettiert",
|
||||
"tag_stats": "Etiketten-Statistiken",
|
||||
"stat_total": "Gesamtanzahl Elemente",
|
||||
"stat_tagged": "Etikettiert",
|
||||
"stat_untagged": "Nicht etikettiert",
|
||||
"stat_sfw": "SFW-Inhalt",
|
||||
"stat_nsfw": "NSFW-Inhalt",
|
||||
"stat_deleted": "Gelöscht",
|
||||
"most_favorited": "Am häufigsten favorisiert",
|
||||
"favs": "Favoriten",
|
||||
"top_xd": "Beste xD-Punktestände"
|
||||
},
|
||||
"upload_page": {
|
||||
"title": "Inhalt aufladieren",
|
||||
"limit_unlimited": "Unbegrenzte Aufladierungen",
|
||||
"limit_reached": "Aufladierungsgrenze erreicht (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} Aufladierungen verbleibend",
|
||||
"auth_required_title": "Authentifizierung erforderlich",
|
||||
"auth_required_text": "Sie müssen angemeldet sein, um Inhalte auf w0bm hochzuladen.",
|
||||
"login_btn": "Anmeldung"
|
||||
},
|
||||
"messages": {
|
||||
"page_title": "NACHRICHTEN",
|
||||
"manage_keys": "Schlüssel verwalten",
|
||||
"loading": "Konversationen werden geladen…",
|
||||
"decrypting": "Nachrichten werden entschlüsselt…",
|
||||
"input_placeholder": "Nachricht schreiben…",
|
||||
"send": "Senden"
|
||||
},
|
||||
"profile": {
|
||||
"message_btn": "Nachricht",
|
||||
"legacy_record": "Veralteter Datensatz – Erste Aufladierung:",
|
||||
"joined": "Beigetreten:",
|
||||
"stat_comments": "Kommentare:",
|
||||
"stat_tags": "Etiketten:",
|
||||
"stat_halls": "Hallen:",
|
||||
"unban_btn": "Benutzersperrung aufheben",
|
||||
"ban_btn": "Benutzer sperren",
|
||||
"warn_btn": "Benutzer verwarnen",
|
||||
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
||||
"no_uploads": "keine Aufladierungen gefunden",
|
||||
"no_favs": "keine Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
"ban_modal_reason": "Grund:",
|
||||
"ban_modal_duration": "Dauer:",
|
||||
"ban_modal_cancel": "Abbrechen",
|
||||
"ban_modal_confirm": "Sperrung bestätigen",
|
||||
"ban_1h": "1 Stunde",
|
||||
"ban_1d": "1 Tag",
|
||||
"ban_1w": "1 Woche",
|
||||
"ban_1m": "1 Monat",
|
||||
"ban_permanent": "Dauerhaft",
|
||||
"warn_modal_title": "Verwarnung aussprechen",
|
||||
"warn_modal_reason": "Grund für die Verwarnung:",
|
||||
"warn_modal_hint": "Dieser Grund wird für den Benutzer sichtbar sein, und er muss ihn anerkennen, um die Nachricht zu schließen.",
|
||||
"warn_modal_cancel": "Abbrechen",
|
||||
"warn_modal_submit": "Verwarnung aussprechen",
|
||||
"comments_title": "Kommentare von {user}",
|
||||
"view_all": "alle ansehen",
|
||||
"uploads_label": "Aufladierungen",
|
||||
"favs_label": "Favoriten",
|
||||
"confirm_unban": "Sind Sie sicher, dass Sie die Sperrung dieses Benutzers aufheben möchten?",
|
||||
"unban_success": "Benutzersperrung erfolgreich aufgehoben",
|
||||
"ban_success": "Benutzer erfolgreich gesperrt",
|
||||
"warning_issuing": "Ausstellung wird durchgeführt...",
|
||||
"warning_success": "Benutzer erfolgreich verwarnt",
|
||||
"warning_issue_btn": "Verwarnung aussprechen",
|
||||
"confirm_subscribe_uploads": "Dadurch werden Sie für alle Ihre vergangenen Aufladierungen abonniert. Fortfahren?",
|
||||
"subscribing": "Abonnement wird durchgeführt...",
|
||||
"subscribed": "Abonniert!",
|
||||
"subscribe_to_my_uploads": "Alle meine Aufladierungen abonnieren"
|
||||
},
|
||||
"metadata_modal": {
|
||||
"title": "Metadaten-Vorschläge",
|
||||
"loading": "Metadaten werden aus der Datei extrahiert...",
|
||||
"found": "In den Metadaten gefunden:",
|
||||
"no_results": "Keine weiteren Metadatenfelder in dieser Datei gefunden."
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Textebene hinzufügen",
|
||||
"tags_label": "Etiketten (kommagetrennt)",
|
||||
"upload_btn": "Memel aufladieren",
|
||||
"back_btn": "Zurück zu den Vorlagen",
|
||||
"select_title": "Vorlage auswählen",
|
||||
"select_subtitle": "Wählen Sie eine Vorlage aus, um mit der Erstellung Ihres Memels zu beginnen",
|
||||
"text_layer": "Textebene",
|
||||
"enter_text": "Text eingeben...",
|
||||
"size_label": "Größe",
|
||||
"create_meme": "Memel erstellen:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "gerade eben",
|
||||
"year": "{n} Jahr",
|
||||
"years": "{n} Jahre",
|
||||
"month": "{n} Monat",
|
||||
"months": "{n} Monate",
|
||||
"day": "{n} Tag",
|
||||
"days": "{n} Tage",
|
||||
"hour": "{n} Stunde",
|
||||
"hours": "{n} Stunden",
|
||||
"minute": "{n} Minute",
|
||||
"minutes": "{n} Minuten",
|
||||
"second": "{n} Sekunde",
|
||||
"seconds": "{n} Sekunden",
|
||||
"ago": "vor {t}"
|
||||
},
|
||||
"search_overlay": {
|
||||
"placeholder": "Etiketten suchen",
|
||||
"strict_mode": "Strenger Modus"
|
||||
},
|
||||
"toast": {
|
||||
"report_success": "Meldung erfolgreich eingereicht.",
|
||||
"report_error": "Ein Fehler ist aufgetreten.",
|
||||
"network_error": "Netzwerkfehler.",
|
||||
"reason_required": "Grund ist erforderlich.",
|
||||
"reason_optional": "Grund (optional)",
|
||||
"reason_required_label": "Grund (erforderlich)",
|
||||
"processing": "Verarbeitung wird durchgeführt...",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"confirm": "Bestätigen",
|
||||
"cancel": "Abbrechen",
|
||||
"add_tag": "Etikett hinzufügen",
|
||||
"fav_added": "ZU DEN FAVORITEN HINZUGEFÜGT",
|
||||
"fav_removed": "AUS DEN FAVORITEN ENTFERNT",
|
||||
"subscribed_thread": "FADEN ABONNIERT",
|
||||
"unsubscribed_thread": "FADEN ABBESTELLT",
|
||||
"oc_marked": "ALS ORIGINALER INHALT MARKIERT",
|
||||
"oc_removed": "OI-STATUS ENTFERNT",
|
||||
"no_tags_excluded": "Keine Etiketten ausgeschlossen",
|
||||
"tag_delete_title": "Etikett löschen",
|
||||
"tag_delete_confirm": "Sind Sie sicher, dass Sie dieses Etikett löschen möchten?",
|
||||
"tag_deleted_success": "Etikett erfolgreich gelöscht",
|
||||
"item_deleted_success": "Element erfolgreich gelöscht",
|
||||
"approve_success": "Aufladierung genehmigt!",
|
||||
"approve_error": "Fehler bei der Genehmigung",
|
||||
"item_delete_title": "Element löschen",
|
||||
"item_delete_confirm": "Sind Sie sicher, dass Sie das Element {id} von {user} löschen möchten?",
|
||||
"hall_added": "ZUR HALLE HINZUGEFÜGT",
|
||||
"hall_removed": "AUS DER HALLE ENTFERNT",
|
||||
"item_pinned": "ELEMENT ANGEHEFTET",
|
||||
"item_unpinned": "ELEMENT NICHT MEHR ANGEHEFTET",
|
||||
"tag_added": "Etikett \"{tag}\" hinzugefügt!",
|
||||
"error_adding_tag": "Fehler beim Hinzufügen des Etiketts",
|
||||
"network_error_short": "Netzwerkfehler",
|
||||
"error_saving": "Fehler beim Speichern",
|
||||
"mode_activated": "{mode}-MODUS AKTIVIERT",
|
||||
"zomg_on": "ZOMG-Modus aktiviert",
|
||||
"zomg_off": "ZOMG-Modus deaktiviert",
|
||||
"copied": "Elfen in die Zwischenablage kopiert"
|
||||
},
|
||||
"footer": {
|
||||
"ranking": "Rangliste",
|
||||
"rules": "Regeln",
|
||||
"about": "Über"
|
||||
},
|
||||
"chat": {
|
||||
"title": "Geschwafel",
|
||||
"placeholder": "Hier schwafeln…",
|
||||
"minimize": "Einklappen",
|
||||
"expand": "Ausklappen",
|
||||
"slow_down": "Gemach, gemach!",
|
||||
"error_send": "Sendung fehlgeschlagen",
|
||||
"network_error": "Netzwerkfehler"
|
||||
}
|
||||
}
|
||||
15
src/inc/log.mjs
Normal file
15
src/inc/log.mjs
Normal file
@@ -0,0 +1,15 @@
|
||||
import fs from "fs";
|
||||
|
||||
const logger = ["debug", "info", "error"];
|
||||
const logfiles = logger.reduce((a, b) => ({...a, [b]: fs.createWriteStream(`./logs/${~~(Date.now() / 1000)}_${b}.log`)}), {});
|
||||
|
||||
export default new Proxy({}, {
|
||||
get: (_, prop) => (msg, timestamp = new Date().toLocaleString()) =>
|
||||
logger.includes(prop) &&
|
||||
logfiles[prop].write(JSON.stringify({
|
||||
level: prop,
|
||||
message: msg,
|
||||
timestamp: timestamp
|
||||
}) + "\n")// &&
|
||||
//console.log(timestamp, prop, msg)
|
||||
});
|
||||
6
src/inc/motd.mjs
Normal file
6
src/inc/motd.mjs
Normal file
@@ -0,0 +1,6 @@
|
||||
let motd = '';
|
||||
|
||||
export const getMotd = () => motd;
|
||||
export const setMotd = (val) => {
|
||||
motd = val;
|
||||
};
|
||||
86
src/inc/multipart.mjs
Normal file
86
src/inc/multipart.mjs
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Shared Multi-part Form Data Parsing Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Native multipart form data parser
|
||||
* @param {Buffer} buffer The raw request body buffer
|
||||
* @param {string} boundary The boundary string from the Content-Type header
|
||||
* @returns {Object} Parsed form parts
|
||||
*/
|
||||
export 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects the raw request body buffer
|
||||
* @param {IncomingMessage} req
|
||||
* @param {number} maxBytes
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
export const collectBody = (req, maxBytes = 157286400) => { // Default to 150MB (matching maxfilesize)
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let received = 0;
|
||||
|
||||
// Fast-path: reject immediately if Content-Length header already exceeds limit
|
||||
const declaredLength = parseInt(req.headers['content-length'] || '0', 10);
|
||||
if (declaredLength > maxBytes) {
|
||||
console.error(`[BODY_TOO_LARGE] Rejection via Content-Length: ${declaredLength} > ${maxBytes}`);
|
||||
req.resume();
|
||||
return reject(Object.assign(new Error('Request body too large'), { code: 'BODY_TOO_LARGE' }));
|
||||
}
|
||||
|
||||
req.on('data', chunk => {
|
||||
received += chunk.length;
|
||||
if (received > maxBytes) {
|
||||
req.destroy();
|
||||
return reject(Object.assign(new Error('Request body too large'), { code: 'BODY_TOO_LARGE' }));
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
|
||||
if (req.isPaused()) req.resume();
|
||||
});
|
||||
};
|
||||
12
src/inc/page_texts.mjs
Normal file
12
src/inc/page_texts.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
let about_text = '';
|
||||
let rules_text = '';
|
||||
let terms_text = '';
|
||||
|
||||
export const getAboutText = () => about_text;
|
||||
export const setAboutText = (val) => { about_text = val; };
|
||||
|
||||
export const getRulesText = () => rules_text;
|
||||
export const setRulesText = (val) => { rules_text = val; };
|
||||
|
||||
export const getTermsText = () => terms_text;
|
||||
export const setTermsText = (val) => { terms_text = val; };
|
||||
422
src/inc/queue.mjs
Normal file
422
src/inc/queue.mjs
Normal file
@@ -0,0 +1,422 @@
|
||||
import fetch from "flumm-fetch";
|
||||
import { exec as _exec, spawn as _spawn } from "child_process";
|
||||
import fs from "fs";
|
||||
import db from "./sql.mjs";
|
||||
import cfg from "./config.mjs";
|
||||
import path from "path";
|
||||
|
||||
export default new class queue {
|
||||
|
||||
constructor() {
|
||||
|
||||
};
|
||||
|
||||
addqueue(e, link) {
|
||||
//this.#queue.push(e, link);
|
||||
};
|
||||
|
||||
/**
|
||||
* Safe execution using spawn (no shell by default)
|
||||
* @param {string} cmd
|
||||
* @param {string[]} args
|
||||
* @param {object} options
|
||||
*/
|
||||
spawn(cmd, args = [], options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = _spawn(cmd, args, { ...options });
|
||||
let stdoutChunks = [];
|
||||
let stderrChunks = [];
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", data => { stdoutChunks.push(data); });
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", data => { stderrChunks.push(data); });
|
||||
}
|
||||
|
||||
child.on("close", code => {
|
||||
const stdout = Buffer.concat(stdoutChunks);
|
||||
const stderr = Buffer.concat(stderrChunks);
|
||||
|
||||
if (code !== 0 && !options.ignoreExitCode) {
|
||||
const err = new Error(`Command '${cmd} ${args.join(' ')}' failed with code ${code}`);
|
||||
err.stderr = stderr.toString();
|
||||
err.stdout = stdout.toString();
|
||||
return reject(err);
|
||||
}
|
||||
if (stderr.length > 0 && !options.quiet)
|
||||
console.error(stderr.toString());
|
||||
|
||||
// Return buffer if encoding is 'buffer', else string
|
||||
resolve({
|
||||
stdout: options.encoding === 'buffer' ? stdout : stdout.toString(),
|
||||
stderr: stderr.toString()
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", err => {
|
||||
err.stderr = Buffer.concat(stderrChunks).toString();
|
||||
err.stdout = Buffer.concat(stdoutChunks).toString();
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
exec(cmd, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
_exec(cmd, { maxBuffer: 5e3 * 1024, ...options }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
err.stderr = stderr;
|
||||
return reject(err);
|
||||
}
|
||||
if (stderr && !options.quiet)
|
||||
console.error(stderr);
|
||||
resolve({ stdout: stdout });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
async generatePHash(source) {
|
||||
try {
|
||||
// Temporal dHash implementation:
|
||||
// 1. Get duration.
|
||||
// 2. Extract 3 frames: 10%, 50%, 90%.
|
||||
// 3. Generate dHash for each.
|
||||
// 4. Return combined hash "hash1_hash2_hash3".
|
||||
|
||||
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
|
||||
const duration = parseFloat(durationStr);
|
||||
if (isNaN(duration) || duration <= 0) return null;
|
||||
|
||||
const timestamps = [duration * 0.1, duration * 0.5, duration * 0.9];
|
||||
const hashes = [];
|
||||
|
||||
for (const ts of timestamps) {
|
||||
let buffer;
|
||||
try {
|
||||
const { stdout } = await this.spawn('ffmpeg', ['-ss', ts.toString(), '-v', 'error', '-i', source, '-vf', 'thumbnail,scale=33:32,format=gray', '-frames:v', '1', '-f', 'rawvideo', 'pipe:1'], { encoding: 'buffer', quiet: true });
|
||||
buffer = stdout;
|
||||
} catch (err) {
|
||||
console.warn(`[PHASH] Failed to extract frame at ${ts}s for ${source}: ${err.message}`);
|
||||
// Buffer remains undefined, triggering fallback below
|
||||
}
|
||||
|
||||
if (!buffer || buffer.length !== 1056) {
|
||||
console.warn(`[PHASH] Invalid buffer length (${buffer?.length}) at ${ts}s for ${source}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let hash = '';
|
||||
let currentByte = 0;
|
||||
let bitCount = 0;
|
||||
|
||||
for (let y = 0; y < 32; y++) {
|
||||
for (let x = 0; x < 32; x++) {
|
||||
const left = buffer[y * 33 + x];
|
||||
const right = buffer[y * 33 + x + 1];
|
||||
|
||||
const bit = left > right ? 1 : 0;
|
||||
currentByte = (currentByte << 1) | bit;
|
||||
bitCount++;
|
||||
|
||||
if (bitCount === 8) {
|
||||
hash += currentByte.toString(16).padStart(2, '0');
|
||||
currentByte = 0;
|
||||
bitCount = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
hashes.push(hash);
|
||||
}
|
||||
|
||||
if (hashes.length === 0) return null;
|
||||
return hashes.join('_');
|
||||
|
||||
} catch (e) {
|
||||
console.error("PHash generation failed:", e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
async checkrepostphash(newHash) {
|
||||
if (!newHash) return false;
|
||||
const newHashes = newHash.split('_');
|
||||
if (newHashes.length === 0) return false;
|
||||
|
||||
// Fetch all phashes, filtering out "all zero" failed hashes
|
||||
const items = await db`
|
||||
SELECT id, phash FROM items
|
||||
WHERE phash IS NOT NULL
|
||||
AND phash != ''
|
||||
AND phash NOT LIKE '00000000%'
|
||||
`;
|
||||
|
||||
// Configurable threshold: max Hamming distance per 256-bit dHash frame.
|
||||
// A value of 15 means < 6% bit difference — tight enough to only match true duplicates.
|
||||
const THRESHOLD = 15;
|
||||
|
||||
const getHammingDistance = (h1, h2) => {
|
||||
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
||||
let distance = 0;
|
||||
for (let i = 0; i < h1.length; i += 2) {
|
||||
const v1 = parseInt(h1.substr(i, 2), 16);
|
||||
const v2 = parseInt(h2.substr(i, 2), 16);
|
||||
let xor = v1 ^ v2;
|
||||
while (xor) {
|
||||
distance += xor & 1;
|
||||
xor >>= 1;
|
||||
}
|
||||
}
|
||||
return distance;
|
||||
};
|
||||
|
||||
// We want at least 2 out of 3 frames to match
|
||||
const REQUIRED_MATCHES = 2;
|
||||
|
||||
for (const item of items) {
|
||||
// Handle legacy single hashes vs new multi-hashes
|
||||
const dbHashes = item.phash.split('_');
|
||||
|
||||
let matches = 0;
|
||||
// Compare corresponding frames: 0vs0, 1vs1, 2vs2
|
||||
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
|
||||
|
||||
for (let i = 0; i < framesToCompare; i++) {
|
||||
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
|
||||
if (dist <= THRESHOLD) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have 3 frames, require 2 out of 3 matches.
|
||||
// If we are comparing against a legacy 1-frame hash, require that single frame to match.
|
||||
if (framesToCompare >= 3 && matches >= REQUIRED_MATCHES) {
|
||||
return item.id;
|
||||
} else if (framesToCompare === 1 && matches === 1) {
|
||||
return item.id;
|
||||
} else if (framesToCompare === 2 && matches >= 2) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
async genuuid() {
|
||||
return (await db`
|
||||
select gen_random_uuid() as uuid
|
||||
`)[0].uuid.substring(0, 8);
|
||||
};
|
||||
|
||||
async checkrepostlink(link) {
|
||||
const q_repost = await db`
|
||||
select id
|
||||
from "items"
|
||||
where src = ${link}
|
||||
`;
|
||||
return q_repost.length > 0 ? q_repost[0].id : false;
|
||||
};
|
||||
|
||||
async checkrepostsum(checksum) {
|
||||
const q_repost = await db`
|
||||
select id
|
||||
from "items"
|
||||
where checksum = ${checksum}
|
||||
`;
|
||||
return q_repost.length > 0 ? q_repost[0].id : false;
|
||||
};
|
||||
|
||||
async getItemID(filename) {
|
||||
return (await db`
|
||||
select *
|
||||
from "items"
|
||||
where dest = ${filename}
|
||||
limit 1
|
||||
`)[0].id;
|
||||
};
|
||||
|
||||
async genThumbnail(filename, mime, itemid, link, pending = false) {
|
||||
const bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
|
||||
const tmpFile = path.join(cfg.paths.tmp, itemid + '.png');
|
||||
const tmpJpg = path.join(cfg.paths.tmp, itemid + '.jpg');
|
||||
|
||||
if (mime.startsWith('video/') || mime == 'image/gif') {
|
||||
const seeks = ['20%', '40%', '60%', '80%'];
|
||||
for (const seek of seeks) {
|
||||
await this.spawn('ffmpegthumbnailer', ['-i', path.join(bDir, filename), '-s', '1024', '-t', seek, '-o', tmpFile]);
|
||||
try {
|
||||
const { stdout } = await this.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
||||
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||
} catch (e) { break; }
|
||||
}
|
||||
}
|
||||
else if (mime.startsWith('image/') && mime != 'image/gif')
|
||||
await this.spawn('magick', [path.join(bDir, filename) + '[0]', tmpFile]);
|
||||
else if (mime.startsWith('audio/')) {
|
||||
let coverExtracted = false;
|
||||
if (link.match(/soundcloud/)) {
|
||||
let cover = (await this.spawn('yt-dlp', ['-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim();
|
||||
if (!cover.match(/default_avatar/)) {
|
||||
cover = cover.replace(/-(large|original)\./, '-t500x500.');
|
||||
try {
|
||||
await this.spawn('wget', [cover, '-O', tmpJpg]);
|
||||
const size = (await fs.promises.stat(tmpJpg)).size;
|
||||
if (size >= 0) {
|
||||
await this.spawn('magick', [tmpJpg, tmpFile]);
|
||||
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
|
||||
coverExtracted = true;
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
if (!coverExtracted) {
|
||||
try {
|
||||
await this.spawn('ffmpeg', ['-i', path.join(bDir, filename), '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]);
|
||||
const size = (await fs.promises.stat(tmpJpg)).size;
|
||||
if (size > 0) {
|
||||
await this.spawn('magick', [tmpJpg, tmpFile]);
|
||||
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
|
||||
coverExtracted = true;
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
} else {
|
||||
// Try extracting embedded cover art (video stream in audio file)
|
||||
try {
|
||||
await this.spawn('ffmpeg', ['-i', path.join(bDir, filename), '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]);
|
||||
const size = (await fs.promises.stat(tmpJpg)).size;
|
||||
if (size > 0) {
|
||||
await this.spawn('magick', [tmpJpg, tmpFile]);
|
||||
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
|
||||
coverExtracted = true;
|
||||
}
|
||||
} catch (err) { }
|
||||
}
|
||||
// If no cover art found, use audio.webp as the thumbnail
|
||||
if (!coverExtracted) {
|
||||
const audioFallback = path.join(cfg.paths.s, 'img', 'audio.webp');
|
||||
await fs.promises.copyFile(audioFallback, tmpFile).catch(async () => {
|
||||
// If copy fails, fall back to generated placeholder
|
||||
await this.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '40', '-annotate', '0', '♪', tmpFile]).catch(() => {});
|
||||
});
|
||||
}
|
||||
// Store extraction result for caller
|
||||
this._lastCoverExtracted = coverExtracted;
|
||||
}
|
||||
else if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') {
|
||||
let customThumb = cfg.websrv.swf_thumb;
|
||||
// Resolve web paths (/s/...) to the filesystem (public/s/...)
|
||||
if (customThumb && customThumb.startsWith('/')) {
|
||||
customThumb = path.join(path.resolve(), 'public', customThumb);
|
||||
}
|
||||
let usedCustom = false;
|
||||
if (customThumb) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(customThumb).catch(() => null);
|
||||
if (stat && stat.size > 0) {
|
||||
await this.spawn('magick', [customThumb, tmpFile]);
|
||||
usedCustom = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!usedCustom) {
|
||||
await this.spawn('magick', [
|
||||
'-size', '256x256', 'xc:#1a1a2e',
|
||||
'-gravity', 'center',
|
||||
'-fill', '#e040fb',
|
||||
'-pointsize', '48',
|
||||
'-annotate', '0', 'SWF',
|
||||
tmpFile
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
await this.spawn('magick', [tmpFile, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, itemid + '.webp')]);
|
||||
await fs.promises.unlink(tmpFile).catch(_ => { });
|
||||
await fs.promises.unlink(tmpJpg).catch(_ => { });
|
||||
return true;
|
||||
};
|
||||
|
||||
async genBlurredThumbnail(itemid, pending = false) {
|
||||
const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const src = path.join(tDir, `${itemid}.webp`);
|
||||
const dst = path.join(tDir, `${itemid}_blur.webp`);
|
||||
try {
|
||||
await this.spawn('magick', [src, '-blur', '0x20', dst]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[QUEUE] Failed to generate blurred thumbnail for ${itemid}:`, err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract metadata tags from a media file using ffprobe
|
||||
* @param {string} source Path to the source file
|
||||
* @returns {object|null} Format tags object or null on failure
|
||||
*/
|
||||
async getVideoMetadata(source) {
|
||||
try {
|
||||
const { stdout } = await this.spawn('ffprobe', [
|
||||
'-v', 'quiet',
|
||||
'-show_entries', 'format_tags',
|
||||
'-of', 'json',
|
||||
source
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
|
||||
if (!stdout || !stdout.trim()) return null;
|
||||
|
||||
const data = JSON.parse(stdout);
|
||||
return data?.format?.tags || null;
|
||||
} catch (err) {
|
||||
// Only log if it's not a simple spawn/parse failure which is expected for some truncated chunks
|
||||
if (err.name !== 'SyntaxError') {
|
||||
console.error(`[QUEUE] Metadata extraction failed for ${source}:`, err.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// tags
|
||||
async tagSFW(itemid) {
|
||||
return await db`
|
||||
insert into "tags_assign" ${db({
|
||||
item_id: itemid,
|
||||
tag_id: 1,
|
||||
user_id: 1
|
||||
})
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
async tagNSFW(itemid) {
|
||||
return await db`
|
||||
insert into "tags_assign" ${db({
|
||||
item_id: itemid,
|
||||
tag_id: 2,
|
||||
user_id: 1
|
||||
})
|
||||
}
|
||||
`;
|
||||
};
|
||||
|
||||
async notifyAdmins(itemid) {
|
||||
try {
|
||||
const admins = await db`select id from "user" where admin = true or is_moderator = true`;
|
||||
const notifications = admins.map(admin => ({
|
||||
user_id: admin.id,
|
||||
type: 'admin_pending',
|
||||
reference_id: 0,
|
||||
item_id: itemid
|
||||
}));
|
||||
|
||||
if (notifications.length > 0) {
|
||||
await db`INSERT INTO notifications ${db(notifications)} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[QUEUE] Failed to notify admins/mods:', err);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
1190
src/inc/routeinc/f0cklib.mjs
Normal file
1190
src/inc/routeinc/f0cklib.mjs
Normal file
File diff suppressed because it is too large
Load Diff
35
src/inc/routeinc/search.mjs
Normal file
35
src/inc/routeinc/search.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
export default (obj, word) => {
|
||||
if(typeof obj !== "object")
|
||||
return false;
|
||||
return obj.map(tmp => {
|
||||
let rscore = 0
|
||||
, startat = 0
|
||||
, string = tmp.tag
|
||||
, cscore
|
||||
, score;
|
||||
for(let i = 0; i < word.length; i++) {
|
||||
const idxOf = string.toLowerCase().indexOf(word.toLowerCase()[i], startat);
|
||||
if(-1 === idxOf)
|
||||
return { score: 0 };
|
||||
if(startat === idxOf)
|
||||
cscore = 0.7;
|
||||
else {
|
||||
cscore = 0.1;
|
||||
if(string[idxOf - 1] === ' ')
|
||||
cscore += 0.8;
|
||||
}
|
||||
if(string[idxOf] === word[i])
|
||||
cscore += 0.1;
|
||||
rscore += cscore;
|
||||
startat = idxOf + 1;
|
||||
}
|
||||
score = 0.5 * (rscore / string.length + rscore / word.length);
|
||||
if(word.toLowerCase()[0] === string.toLowerCase()[0] && score < 0.85)
|
||||
score += 0.15;
|
||||
|
||||
return {
|
||||
...tmp,
|
||||
score: score
|
||||
};
|
||||
}).filter(t => t.score > 0).sort((a, b) => b.score - a.score);
|
||||
};
|
||||
1142
src/inc/routes/admin.mjs
Normal file
1142
src/inc/routes/admin.mjs
Normal file
File diff suppressed because it is too large
Load Diff
272
src/inc/routes/ajax.mjs
Normal file
272
src/inc/routes/ajax.mjs
Normal file
@@ -0,0 +1,272 @@
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import url from "url";
|
||||
import cfg from "../config.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
// flummpress uses req.url.qs for query string parameters
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
let contextUrl = `/${req.params.itemid}`;
|
||||
if (query.tag) contextUrl = `/tag/${encodeURIComponent(query.tag)}/${req.params.itemid}`;
|
||||
if (query.hall) contextUrl = `/h/${encodeURIComponent(query.hall)}/${req.params.itemid}`;
|
||||
if (query.userHall && query.userHallOwner) {
|
||||
contextUrl = `/user/${encodeURIComponent(query.userHallOwner)}/hall/${encodeURIComponent(query.userHall)}/${req.params.itemid}`;
|
||||
} else if (query.user) {
|
||||
contextUrl = query.fav === 'true'
|
||||
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
|
||||
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
|
||||
}
|
||||
if (query.mime) {
|
||||
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
|
||||
}
|
||||
|
||||
console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: itemid,
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
session: !!req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
hall: query.hall,
|
||||
userHall: query.userHall || null,
|
||||
userHallOwner: query.userHallOwner || null,
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
fav: query.fav === 'true',
|
||||
random: isRandom,
|
||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||
explicitStrict: query.strict === '1' || query.strict === 'true',
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id
|
||||
});
|
||||
const tAjaxFetch = Date.now();
|
||||
|
||||
if (!data.success) {
|
||||
const errorHtml = tpl.render('error-partial', {
|
||||
message: data.message || '404 - Not f0cked',
|
||||
tmp: null
|
||||
}, req);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: errorHtml, pagination: '', error: true })
|
||||
});
|
||||
}
|
||||
|
||||
// Load xD score server-side (needed for badge), but do NOT embed comments into the response.
|
||||
// Comments are always loaded async by the client via /api/comments/:id to avoid
|
||||
// blocking the browser's main thread on posts with huge comment payloads.
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
// Mark notifications as read
|
||||
if (req.session?.id) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
data.isSubscribed = sub;
|
||||
// xD Score for item view badge
|
||||
const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false);
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
// Do NOT set commentsJSON — client will fetch async
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
} else {
|
||||
data.isSubscribed = false;
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
const tAjaxAux = Date.now();
|
||||
|
||||
// Inject session into data for the template
|
||||
if (req.session) {
|
||||
data.session = { ...req.session };
|
||||
} else {
|
||||
data.session = false;
|
||||
}
|
||||
|
||||
// Inject missing variables normally provided by req or middleware
|
||||
data.url = { pathname: contextUrl }; // Template expects url.pathname
|
||||
data.fullscreen = req.cookies.fullscreen || 0; // Index.mjs uses req.cookies.fullscreen
|
||||
data.hidePagination = true;
|
||||
|
||||
// Precompute hall display data for the template
|
||||
if (data.item && data.item.halls && data.item.halls.length) {
|
||||
const currentHallSlug = data.tmp && data.tmp.hall
|
||||
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
|
||||
: null;
|
||||
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
|
||||
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
|
||||
} else if (data.item) {
|
||||
data.item.primaryHall = null;
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
|
||||
// Precompute boolean helpers for template @if() — flummpress uses non-greedy regex
|
||||
// that stops at the first ')' inside @if(...), so any method call with parens
|
||||
// (indexOf, .some, etc.) must be precomputed here as plain booleans.
|
||||
if (data.item) {
|
||||
const session = data.session;
|
||||
const item = data.item;
|
||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
|
||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
|
||||
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
|
||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||
data.item_username_lower = (item.username || '').toLowerCase();
|
||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
}
|
||||
|
||||
// Render both the item content and the pagination
|
||||
const itemHtml = tpl.render('ajax-item', data, req);
|
||||
const paginationHtml = tpl.render('snippets/pagination', data, req);
|
||||
const tAjaxRender = Date.now();
|
||||
|
||||
console.log(`[${new Date().toISOString()}] [AJAX] Complete request for ${req.params.itemid} in ${tAjaxRender - tAjaxStart}ms
|
||||
- getf0ck: ${tAjaxFetch - tAjaxStart}ms
|
||||
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
|
||||
- Render: ${tAjaxRender - tAjaxAux}ms`);
|
||||
|
||||
res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/ajax/halls', async (req, res) => {
|
||||
try {
|
||||
const halls = await f0cklib.getHalls();
|
||||
res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(halls)
|
||||
});
|
||||
} catch (err) {
|
||||
res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ error: true, msg: 'Failed to fetch halls' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Infinite scroll endpoint for index thumbnails
|
||||
router.get(/\/ajax\/items/, async (req, res) => {
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const page = parseInt(query.page) || 1;
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: page,
|
||||
tag: query.tag || null,
|
||||
hall: query.hall || null,
|
||||
user: query.user || null,
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
session: !!req.session,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id,
|
||||
fav: query.fav === 'true',
|
||||
random: isRandom,
|
||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||
explicitStrict: query.strict === '1' || query.strict === 'true',
|
||||
newer: query.newer || null,
|
||||
minXdScore: req.session?.min_xd_score || 0
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
html: '',
|
||||
hasMore: false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Render just the thumbnail items
|
||||
const itemsHtml = tpl.render('snippets/items-grid', {
|
||||
items: data.items,
|
||||
link: data.link
|
||||
}, req);
|
||||
|
||||
// Render pagination
|
||||
const paginationHtml = tpl.render('snippets/pagination', {
|
||||
pagination: data.pagination,
|
||||
link: data.link
|
||||
}, req);
|
||||
|
||||
// Render title/header (for tags/users)
|
||||
const titleHtml = tpl.render('snippets/page-title', {
|
||||
tmp: {
|
||||
user: query.user ? String(query.user).toLowerCase() : null,
|
||||
tag: query.tag ? String(query.tag).toLowerCase() : null,
|
||||
hall: query.hall ? String(query.hall).toLowerCase() : null,
|
||||
mime: query.mime ? String(query.mime).toLowerCase() : null,
|
||||
mode: query.mode,
|
||||
view_mode: query.fav === 'true' ? 'favs' : 'f0cks'
|
||||
},
|
||||
total: data.total || 0,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false
|
||||
}, req);
|
||||
|
||||
const hasMore = data.pagination.next !== null;
|
||||
|
||||
return res.reply({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
html: itemsHtml,
|
||||
titleHtml: titleHtml,
|
||||
pagination: paginationHtml,
|
||||
hasMore: hasMore,
|
||||
nextPage: data.pagination.next,
|
||||
currentPage: data.pagination.page
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
1058
src/inc/routes/apiv2/index.mjs
Normal file
1058
src/inc/routes/apiv2/index.mjs
Normal file
File diff suppressed because it is too large
Load Diff
626
src/inc/routes/apiv2/settings.mjs
Normal file
626
src/inc/routes/apiv2/settings.mjs
Normal file
@@ -0,0 +1,626 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
|
||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||
// These routes remain for other settings API endpoints
|
||||
|
||||
export default router => {
|
||||
router.group(/^\/api\/v2\/settings/, group => {
|
||||
group.put(/\/setAvatar/, lib.loggedin, async (req, res) => {
|
||||
if (!req.post.avatar) {
|
||||
return res.json({
|
||||
msg: 'no avatar provided',
|
||||
debug: req.post
|
||||
}, 400); // bad request
|
||||
}
|
||||
|
||||
const avatar = +req.post.avatar;
|
||||
|
||||
const itemid = (await db`
|
||||
select id
|
||||
from "items"
|
||||
where id = ${+avatar} and active = true
|
||||
`)?.[0]?.id;
|
||||
|
||||
if (!itemid) {
|
||||
return res.json({
|
||||
msg: 'itemid not found'
|
||||
}, 404); // not found
|
||||
}
|
||||
|
||||
const q = await db`
|
||||
update "user_options" set ${db({
|
||||
avatar
|
||||
}, 'avatar')
|
||||
}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
return res.json({
|
||||
msg: q
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Switch to custom avatar (sets avatar ID to 0 so avatar_file is used)
|
||||
group.put(/\/useCustomAvatar/, lib.loggedin, async (req, res) => {
|
||||
// Check if user has a custom avatar file
|
||||
const userOpts = (await db`
|
||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
|
||||
if (!userOpts?.avatar_file) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'No custom avatar uploaded'
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Set avatar to 0 so avatar_file takes priority
|
||||
await db`
|
||||
update user_options
|
||||
set avatar = 0
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
avatar_file: userOpts.avatar_file,
|
||||
msg: 'Switched to custom avatar'
|
||||
}, 200);
|
||||
});
|
||||
|
||||
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
|
||||
const tags = await db`
|
||||
select t.id, t.tag, t.normalized
|
||||
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
|
||||
join tags t on t.id = et.id
|
||||
`;
|
||||
return res.json({ success: true, tags }, 200);
|
||||
});
|
||||
|
||||
group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => {
|
||||
const tagname = req.post.tagname;
|
||||
if (!tagname) return res.json({ success: false, msg: 'No tag provided' }, 400);
|
||||
|
||||
const tag = (await db`select id, tag, normalized from tags where normalized = slugify(${tagname})`)[0];
|
||||
|
||||
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set excluded_tags = array_append(excluded_tags, ${tag.id})
|
||||
where user_id = ${+req.session.id} and not (${tag.id} = any(excluded_tags))
|
||||
`;
|
||||
|
||||
// Return updated list
|
||||
const tags = await db`
|
||||
select t.id, t.tag, t.normalized
|
||||
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
|
||||
join tags t on t.id = et.id
|
||||
`;
|
||||
|
||||
return res.json({ success: true, tags }, 200);
|
||||
});
|
||||
|
||||
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
|
||||
const tagname = decodeURIComponent(req.params.tag);
|
||||
const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0];
|
||||
|
||||
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set excluded_tags = array_remove(excluded_tags, ${tag.id})
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
const tags = await db`
|
||||
select t.id, t.tag, t.normalized
|
||||
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
|
||||
join tags t on t.id = et.id
|
||||
`;
|
||||
|
||||
return res.json({ success: true, tags }, 200);
|
||||
});
|
||||
|
||||
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
|
||||
group.post(/\/link\/token/, lib.loggedin, async (req, res) => {
|
||||
// 6-char alphanumeric code
|
||||
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
|
||||
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
|
||||
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
|
||||
`;
|
||||
return res.json({ success: true, token, type }, 200);
|
||||
} catch (e) {
|
||||
console.error('Token gen error:', e);
|
||||
// Fallback for schema if link_token doesn't have type yet (optional, but good for safety)
|
||||
// If migration failed or not applied to link_token... wait, check schema for link_token first.
|
||||
// Schema for link_token: user_id, token, created_at. NO TYPE.
|
||||
// Ah, I need to add 'type' to link_token too OR just rely on the bot to know which type it is verifying?
|
||||
// Actually, the bot trigger knows its type. When !link <token> is sent to Discord bot, it checks token.
|
||||
// If I use same table for both, a token generated for Matrix could be used on Discord if not careful.
|
||||
// It's safer to add type to link_token OR just rely on who claims it.
|
||||
// If I don't add type to link_token, then a token is just "allow linking".
|
||||
// If I send !link TOKEN to Matrix bot, it links Matrix account.
|
||||
// If I send !link TOKEN to Discord bot, it links Discord account.
|
||||
// This seems fine without adding type to link_token, because the USER triggers the action on the specific platform.
|
||||
// So I will stick to the existing schema for link_token for now to avoid another migration if possible.
|
||||
// BUT, I should check if I really need date restriction or type.
|
||||
// Let's keep it simple: Token is just a key. Authenticated user generated it.
|
||||
// Whoever consumes it (Discord bot or Matrix bot) links THEIR account to that user_id.
|
||||
// So NO CHANGE needed for link_token table schema.
|
||||
|
||||
// Reverting to original simple insert (ignoring type in DB, just returning it for frontend convenience if needed)
|
||||
await db`
|
||||
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
|
||||
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
|
||||
`;
|
||||
return res.json({ success: true, token, type }, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Get linked accounts (Discord & Matrix)
|
||||
group.get(/\/link\/accounts/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const aliases = await db`
|
||||
SELECT alias, type FROM user_alias
|
||||
WHERE userid = ${req.session.id}
|
||||
ORDER BY type DESC, alias ASC
|
||||
`;
|
||||
// Sanitize aliases
|
||||
const sanitized = aliases.map(a => ({
|
||||
alias: a.alias.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
|
||||
type: a.type || 'discord' // Default to discord if null (though migration sets default)
|
||||
}));
|
||||
return res.json({ success: true, aliases: sanitized }, 200);
|
||||
} catch (e) {
|
||||
console.error('Get linked error:', e);
|
||||
return res.json({ success: false, msg: 'Error fetching linked accounts' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Unlink account
|
||||
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const alias = decodeURIComponent(req.params.alias);
|
||||
const type = req.params.type;
|
||||
|
||||
const result = await db`
|
||||
DELETE FROM user_alias
|
||||
WHERE lower(alias) = lower(${alias})
|
||||
AND userid = ${req.session.id}
|
||||
AND type = ${type}
|
||||
RETURNING alias
|
||||
`;
|
||||
|
||||
if (result.length > 0) {
|
||||
return res.json({ success: true, msg: 'Account unlinked' }, 200);
|
||||
} else {
|
||||
return res.json({ success: false, msg: 'Account not found' }, 404);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Unlink error:', e);
|
||||
return res.json({ success: false, msg: 'Error unlinking account' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Backward compatibility routes for Discord (Deprecated)
|
||||
// Discord Token Generation (Redirect to generic)
|
||||
group.post(/\/discord\/token/, lib.loggedin, async (req, res) => {
|
||||
// Just call the logic inline
|
||||
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO link_token (user_id, token) VALUES (${req.session.id}, ${token})
|
||||
ON CONFLICT (token) DO UPDATE SET token = EXCLUDED.token
|
||||
`;
|
||||
return res.json({ success: true, token }, 200);
|
||||
} catch (e) { return res.json({ success: false }, 500); }
|
||||
});
|
||||
|
||||
// Get linked Discord accounts (Legacy)
|
||||
group.get(/\/discord\/linked/, lib.loggedin, async (req, res) => {
|
||||
const aliases = await db`SELECT alias FROM user_alias WHERE userid = ${req.session.id} AND type = 'discord'`;
|
||||
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
|
||||
});
|
||||
|
||||
// Unlink Discord account (Legacy)
|
||||
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
|
||||
const alias = decodeURIComponent(req.params.alias);
|
||||
await db`DELETE FROM user_alias WHERE lower(alias) = lower(${alias}) AND userid = ${req.session.id} AND type = 'discord'`;
|
||||
return res.json({ success: true, msg: 'Account unlinked' }, 200);
|
||||
});
|
||||
|
||||
// Update MOTD visibility preference
|
||||
group.put(/\/motd/, lib.loggedin, async (req, res) => {
|
||||
const show = req.post.show === true || req.post.show === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set show_motd = ${show}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.show_motd = show;
|
||||
return res.json({ success: true, show }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update MOTD pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Autoplay (on load) preference
|
||||
group.put(/\/autoplay/, lib.loggedin, async (req, res) => {
|
||||
const disable_autoplay = req.post.disable_autoplay === true || req.post.disable_autoplay === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set disable_autoplay = ${disable_autoplay}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.disable_autoplay = disable_autoplay;
|
||||
return res.json({ success: true, disable_autoplay }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Autoplay pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Swiping preference
|
||||
group.put(/\/swiping/, lib.loggedin, async (req, res) => {
|
||||
const disable_swiping = req.post.disable_swiping === true || req.post.disable_swiping === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set disable_swiping = ${disable_swiping}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.disable_swiping = disable_swiping;
|
||||
return res.json({ success: true, disable_swiping }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Swiping pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update New Layout visibility preference
|
||||
group.put(/\/layout/, lib.loggedin, async (req, res) => {
|
||||
const use_new_layout = req.post.use_new_layout === true || req.post.use_new_layout === 'true';
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set use_new_layout = ${use_new_layout}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.use_new_layout = use_new_layout;
|
||||
return res.json({ success: true, use_new_layout }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Layout pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Username Color preference
|
||||
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
||||
const { color } = req.post;
|
||||
|
||||
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
|
||||
return res.json({ success: false, msg: 'Invalid color format' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set username_color = ${color}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.username_color = color;
|
||||
return res.json({ success: true, color }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Username Color error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating color' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update password
|
||||
group.put(/\/password/, lib.loggedin, async (req, res) => {
|
||||
const { current_password, new_password, new_password_confirm } = req.post;
|
||||
|
||||
if (!new_password || !new_password_confirm) {
|
||||
return res.json({ success: false, msg: 'New password and confirmation are required' }, 400);
|
||||
}
|
||||
|
||||
const user = (await db`select password, force_password_change from "user" where id = ${+req.session.id}`)[0];
|
||||
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
|
||||
|
||||
if (!user.force_password_change) {
|
||||
if (!current_password) {
|
||||
return res.json({ success: false, msg: 'Current password is required' }, 400);
|
||||
}
|
||||
const valid = await lib.verify(current_password, user.password);
|
||||
if (!valid) {
|
||||
return res.json({ success: false, msg: 'Incorrect current password' }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
if (new_password !== new_password_confirm) {
|
||||
return res.json({ success: false, msg: 'New passwords do not match' }, 400);
|
||||
}
|
||||
|
||||
if (new_password.length < 20) {
|
||||
return res.json({ success: false, msg: 'New password must be at least 20 characters long' }, 400);
|
||||
}
|
||||
|
||||
const hash = await lib.hash(new_password);
|
||||
await db`update "user" set password = ${hash}, force_password_change = false where id = ${+req.session.id}`;
|
||||
|
||||
// Clear flag in session too
|
||||
if (req.session) req.session.force_password_change = false;
|
||||
|
||||
// Invalidate all other sessions (Issue 21 fix)
|
||||
await db`delete from "user_sessions" where user_id = ${+req.session.id} and id != ${+req.session.sess_id}`;
|
||||
|
||||
return res.json({ success: true, msg: 'Password updated successfully' }, 200);
|
||||
});
|
||||
|
||||
// Update email
|
||||
group.put(/\/email/, lib.loggedin, async (req, res) => {
|
||||
const { email } = req.post;
|
||||
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
|
||||
if (!email.includes('@')) return res.json({ success: false, msg: 'Invalid email address' }, 400);
|
||||
|
||||
await db`update "user" set email = ${email.trim()} where id = ${+req.session.id}`;
|
||||
return res.json({ success: true, msg: 'Email updated successfully' }, 200);
|
||||
});
|
||||
|
||||
// Update Display Name
|
||||
group.put(/\/display_name/, lib.loggedin, async (req, res) => {
|
||||
const { display_name } = req.post;
|
||||
|
||||
if (display_name !== undefined && typeof display_name !== 'string') {
|
||||
return res.json({ success: false, msg: 'Invalid display name format' }, 400);
|
||||
}
|
||||
|
||||
const cleanDisplayName = display_name ? display_name.trim().substring(0, 32) : null;
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set display_name = ${cleanDisplayName}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.display_name = cleanDisplayName;
|
||||
return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Display Name error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating display name' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Description
|
||||
group.put(/\/description/, lib.loggedin, async (req, res) => {
|
||||
if (!cfg.websrv.enable_profile_description) {
|
||||
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
|
||||
}
|
||||
const { description } = req.post;
|
||||
|
||||
if (description !== undefined && typeof description !== 'string') {
|
||||
return res.json({ success: false, msg: 'Invalid description format' }, 400);
|
||||
}
|
||||
|
||||
const cleanDescription = description ? description.trim().substring(0, 255) : null;
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set description = ${cleanDescription}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.description = cleanDescription;
|
||||
return res.json({ success: true, description: cleanDescription }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Description error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating description' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Font preference
|
||||
group.put(/\/font/, lib.loggedin, async (req, res) => {
|
||||
const { font } = req.post;
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set font = ${font || null}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
// Sync session immediately
|
||||
if (req.session) req.session.font = font || null;
|
||||
return res.json({ success: true, font: font || null }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Font error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating font' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Lightweight "who am I right now" endpoint — reads directly from DB (not session cache)
|
||||
// Used by the frontend to sync display_name after it may have been changed by an admin
|
||||
group.get(/\/me$/, lib.loggedin, async (req, res) => {
|
||||
const row = (await db`
|
||||
SELECT u.login, u.user, uo.display_name
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE u.id = ${+req.session.id}
|
||||
LIMIT 1
|
||||
`)[0];
|
||||
if (!row) return res.json({ success: false }, 404);
|
||||
return res.json({
|
||||
success: true,
|
||||
login: row.login,
|
||||
user: row.user,
|
||||
display_name: row.display_name || null
|
||||
}, 200);
|
||||
});
|
||||
|
||||
// Update min xD score filter preference
|
||||
group.put(/\/min_xd_score/, lib.loggedin, async (req, res) => {
|
||||
const raw = req.post.min_xd_score;
|
||||
const min_xd_score = parseInt(raw, 10);
|
||||
if (isNaN(min_xd_score) || min_xd_score < 0 || min_xd_score > 999) {
|
||||
return res.json({ success: false, msg: 'Invalid value: must be 0–999' }, 400);
|
||||
}
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set min_xd_score = ${min_xd_score}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.min_xd_score = min_xd_score;
|
||||
return res.json({ success: true, min_xd_score }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update min_xd_score error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update background blur preference
|
||||
group.put(/\/background/, lib.loggedin, async (req, res) => {
|
||||
const show_background = req.post.show_background === 'true' || req.post.show_background === true;
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set show_background = ${show_background}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.show_background = show_background;
|
||||
return res.json({ success: true, show_background }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update background error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update Ruffle (Flash) preferences
|
||||
group.put(/\/ruffle/, lib.loggedin, async (req, res) => {
|
||||
const ruffle_volume = parseFloat(req.post.ruffle_volume);
|
||||
const ruffle_background = req.post.ruffle_background === 'true' || req.post.ruffle_background === true;
|
||||
|
||||
if (isNaN(ruffle_volume) || ruffle_volume < 0 || ruffle_volume > 1) {
|
||||
return res.json({ success: false, msg: 'Invalid volume: must be 0-1' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set ruffle_volume = ${ruffle_volume},
|
||||
ruffle_background = ${ruffle_background}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) {
|
||||
req.session.ruffle_volume = ruffle_volume;
|
||||
req.session.ruffle_background = ruffle_background;
|
||||
}
|
||||
return res.json({ success: true, ruffle_volume, ruffle_background }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update Ruffle pref error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update quote_emojis preference (render :emoji: inside quote replies)
|
||||
group.put(/\/quote_emojis/, lib.loggedin, async (req, res) => {
|
||||
const quote_emojis = req.post.quote_emojis === true || req.post.quote_emojis === 'true';
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set quote_emojis = ${quote_emojis}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.quote_emojis = quote_emojis;
|
||||
return res.json({ success: true, quote_emojis }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update quote_emojis error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update embed_youtube_in_comments preference
|
||||
group.put(/\/embed_youtube_in_comments/, lib.loggedin, async (req, res) => {
|
||||
const embed_youtube_in_comments = req.post.embed_youtube_in_comments === true || req.post.embed_youtube_in_comments === 'true';
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set embed_youtube_in_comments = ${embed_youtube_in_comments}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.embed_youtube_in_comments = embed_youtube_in_comments;
|
||||
return res.json({ success: true, embed_youtube_in_comments }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update embed_youtube_in_comments error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update hide_koepfe preference (hide the Köpfe background images if enabled in config)
|
||||
group.put(/\/hide_koepfe/, lib.loggedin, async (req, res) => {
|
||||
const hide_koepfe = req.post.hide_koepfe === true || req.post.hide_koepfe === 'true';
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set hide_koepfe = ${hide_koepfe}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.hide_koepfe = hide_koepfe;
|
||||
return res.json({ success: true, hide_koepfe }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update hide_koepfe error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update per-user language preference
|
||||
group.put(/\/language/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.allow_language_change === false) {
|
||||
return res.json({ success: false, msg: 'Language change is disabled by the site administrator' }, 403);
|
||||
}
|
||||
const { language } = req.post;
|
||||
// NULL means "use site default"; only allow known locale codes
|
||||
const ALLOWED = ['en', 'de', 'nl', 'zange', null, ''];
|
||||
const lang = (language === '' || language === null || language === undefined) ? null : language;
|
||||
if (!ALLOWED.includes(lang)) {
|
||||
return res.json({ success: false, msg: 'Unsupported language' }, 400);
|
||||
}
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set language = ${lang}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (req.session) req.session.language = lang;
|
||||
return res.json({ success: true, language: lang }, 200);
|
||||
} catch (e) {
|
||||
console.error('Update language error:', e);
|
||||
return res.json({ success: false, msg: 'Error updating preference' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
245
src/inc/routes/apiv2/tags.mjs
Normal file
245
src/inc/routes/apiv2/tags.mjs
Normal file
@@ -0,0 +1,245 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import audit from "../../audit.mjs";
|
||||
import queue from "../../queue.mjs";
|
||||
import cfg from "../../config.mjs";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
export default router => {
|
||||
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
|
||||
group.get(/$/, lib.loggedin, async (req, res) => {
|
||||
// get tags
|
||||
if (!req.params.postid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'missing postid'
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
tags: await lib.getTags(+req.params.postid)
|
||||
});
|
||||
});
|
||||
|
||||
group.post(/$/, lib.loggedin, async (req, res) => {
|
||||
// assign and/or create tag
|
||||
if (!req.params.postid || !req.post.tagname) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'missing postid or tag'
|
||||
});
|
||||
}
|
||||
|
||||
const postid = +req.params.postid;
|
||||
const tagname = req.post.tagname?.trim();
|
||||
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
|
||||
|
||||
if (protectedTags.includes(tagname.toLowerCase())) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: `The tag "${tagname}" is reserved for system rating modes.`
|
||||
});
|
||||
}
|
||||
|
||||
if (tagname.length > 70) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'tag is too long!'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
let tagid = (await db`
|
||||
select id
|
||||
from "tags"
|
||||
where normalized = slugify(${tagname})
|
||||
`)?.[0]?.id;
|
||||
|
||||
if (!tagid) { // create new tag
|
||||
tagid = (await db`
|
||||
insert into "tags" ${db({
|
||||
tag: tagname
|
||||
})
|
||||
}
|
||||
returning id
|
||||
`)[0].id;
|
||||
}
|
||||
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
tag_id: +tagid,
|
||||
item_id: +postid,
|
||||
user_id: +req.session.id
|
||||
})
|
||||
}
|
||||
`;
|
||||
} catch (err) {
|
||||
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: isDuplicate ? 'Tag already exists' : err.message,
|
||||
tags: await lib.getTags(postid)
|
||||
});
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(postid);
|
||||
console.log(`[API] Notifying 'tags' for item ${postid} with ${freshTags.length} tags`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
postid: postid,
|
||||
tag: tagname,
|
||||
tags: freshTags
|
||||
});
|
||||
});
|
||||
|
||||
group.put(/\/cycle-rating$/, lib.modAuth, async (req, res) => {
|
||||
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
|
||||
|
||||
const postid = +req.params.postid;
|
||||
const nsflId = cfg.nsfl_tag_id || 3;
|
||||
// Cycle: SFW(1) → NSFW(2) → NSFL(nsflId) → SFW(1); untagged items jump straight to SFW
|
||||
const cycle = [1, 2, nsflId];
|
||||
const currentTags = await lib.getTags(postid);
|
||||
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
|
||||
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
|
||||
const nextTagId = cycle[(cycleIdx + 1) % cycle.length];
|
||||
|
||||
try {
|
||||
// Remove any existing rating tag
|
||||
await db`DELETE FROM tags_assign WHERE item_id = ${postid} AND tag_id = ANY(ARRAY[1, 2, ${nsflId}]::int[])`;
|
||||
if (nextTagId > 0) {
|
||||
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
|
||||
}
|
||||
|
||||
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
|
||||
const { label, cls } = labels[nextTagId];
|
||||
|
||||
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId });
|
||||
const freshTags = await lib.getTags(postid);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
group.put(/\/toggle$/, lib.modAuth, async (req, res) => {
|
||||
// xD
|
||||
if (!req.params.postid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'missing postid'
|
||||
});
|
||||
}
|
||||
|
||||
const postid = +req.params.postid;
|
||||
const currentTags = await lib.getTags(postid);
|
||||
const hasSFW = currentTags.some(tag => tag.id === 1);
|
||||
const hasNSFW = currentTags.some(tag => tag.id === 2);
|
||||
|
||||
let auditDetails = { tag: 'sfw' };
|
||||
|
||||
if (!hasSFW && !hasNSFW) {
|
||||
// insert
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
item_id: +postid,
|
||||
tag_id: 1,
|
||||
user_id: +req.session.id
|
||||
})
|
||||
}
|
||||
`;
|
||||
auditDetails = { tag: 'sfw', action: 'added' };
|
||||
}
|
||||
else {
|
||||
// update
|
||||
await db`
|
||||
update "tags_assign"
|
||||
set tag_id = (array[2,1])[tag_id]
|
||||
where tag_id = any(array[1,2])
|
||||
and item_id = ${+postid}
|
||||
`;
|
||||
if (hasSFW) auditDetails = { tag: 'nsfw', from: 'sfw' };
|
||||
if (hasNSFW) auditDetails = { tag: 'sfw', from: 'nsfw' };
|
||||
}
|
||||
|
||||
await audit.log(req.session.id, 'toggle_tag', 'item', postid, auditDetails);
|
||||
|
||||
// Generate blurred thumbnail if toggling TO NSFW
|
||||
if (hasSFW && !hasNSFW) {
|
||||
// Was SFW, now NSFW - check if blur exists and generate if not
|
||||
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
|
||||
try {
|
||||
await fs.promises.access(blurPath);
|
||||
} catch {
|
||||
// Doesn't exist - generate it
|
||||
await queue.genBlurredThumbnail(postid, false);
|
||||
}
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(postid);
|
||||
console.log(`[API] Notifying 'tags' (toggle) for item ${postid}`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
tags: freshTags
|
||||
});
|
||||
});
|
||||
|
||||
group.delete(/\/(?<tagname>.*)/, lib.modAuth, async (req, res) => {
|
||||
// delete tag
|
||||
if (!req.params.postid || !req.params.tagname) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'missing postid or tagname'
|
||||
});
|
||||
}
|
||||
|
||||
const postid = +req.params.postid;
|
||||
const tagname = decodeURIComponent(req.params.tagname);
|
||||
|
||||
const tags = await lib.getTags(postid);
|
||||
|
||||
const tagid = tags.filter(t => t.tag === tagname)[0]?.id ?? null;
|
||||
|
||||
if (!tagid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'tag is not assigned',
|
||||
tags: await lib.getTags(postid)
|
||||
});
|
||||
}
|
||||
|
||||
let q = await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = ${+tagid}
|
||||
and item_id = ${+postid}
|
||||
`;
|
||||
const reply = !!q;
|
||||
|
||||
if (reply) {
|
||||
const reason = req.post.reason || req.url.qs.reason || 'No reason provided';
|
||||
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, reason });
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(postid);
|
||||
console.log(`[API] Notifying 'tags' (delete) for item ${postid}`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
return res.json({
|
||||
success: reply,
|
||||
tagid,
|
||||
tags: freshTags
|
||||
})
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
565
src/inc/routes/apiv2/upload.mjs
Normal file
565
src/inc/routes/apiv2/upload.mjs
Normal file
@@ -0,0 +1,565 @@
|
||||
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;
|
||||
};
|
||||
|
||||
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
|
||||
|
||||
// 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 => {
|
||||
|
||||
const saveComment = async (itemid, userid, content) => {
|
||||
if (!content || !content.trim()) return;
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: userid,
|
||||
parent_id: null,
|
||||
content: content.trim()
|
||||
}, 'item_id', 'user_id', 'parent_id', 'content')}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Failed to save upload comment:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Find-or-create a tag and assign it to an item (no-op on duplicate)
|
||||
const assignTag = async (itemid, tagName, userId) => {
|
||||
try {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
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`;
|
||||
}
|
||||
const tagId = tagRow[0].id;
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: userId })} on conflict do nothing`;
|
||||
} catch (err) {
|
||||
console.error(`[UPLOAD] Failed to assign tag "${tagName}":`, err);
|
||||
}
|
||||
};
|
||||
|
||||
// Derive automatic tags from a URL:
|
||||
// - registered domain (e.g. "barkaka.net" from "www.foo.barkaka.net")
|
||||
// - "youtube" for YouTube URLs
|
||||
const autoTagsFromUrl = (urlString) => {
|
||||
const tags = [];
|
||||
try {
|
||||
const { hostname } = new URL(urlString);
|
||||
// Strip port if present
|
||||
const host = hostname.replace(/:\d+$/, '').toLowerCase();
|
||||
const parts = host.split('.');
|
||||
|
||||
// Known short second-level domains (add more as needed)
|
||||
const shortSlds = new Set(['co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne']);
|
||||
|
||||
let domain;
|
||||
if (parts.length >= 3 && shortSlds.has(parts[parts.length - 2])) {
|
||||
// e.g. foo.co.uk → co.uk is the tld → registered = foo.co.uk
|
||||
domain = parts.slice(-3).join('.');
|
||||
} else {
|
||||
// Normal: strip all subdomains, keep last two labels
|
||||
domain = parts.slice(-2).join('.');
|
||||
}
|
||||
|
||||
if (domain) tags.push(domain);
|
||||
|
||||
// YouTube-specific tag
|
||||
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
|
||||
tags.push('youtube');
|
||||
}
|
||||
} catch (e) {
|
||||
// Malformed URL — skip auto-tags
|
||||
}
|
||||
return [...new Set(tags)];
|
||||
};
|
||||
|
||||
group.post(/\/upload-url$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
if (!cfg.websrv.web_url_upload) {
|
||||
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
|
||||
}
|
||||
|
||||
const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc } = req.post || {};
|
||||
|
||||
if (!inputUrl || !inputUrl.trim()) {
|
||||
return res.json({ success: false, msg: 'URL is required' }, 400);
|
||||
}
|
||||
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
|
||||
return res.json({ success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400);
|
||||
}
|
||||
if (rating === 'nsfl' && !cfg.enable_nsfl) {
|
||||
return res.json({ success: false, msg: 'NSFL mode is currently disabled' }, 400);
|
||||
}
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||
const minTags = getMinTags();
|
||||
if (tags.length < minTags) {
|
||||
return res.json({ success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
|
||||
}
|
||||
|
||||
// Upload limit check
|
||||
if (!req.session.admin && !req.session.is_moderator) {
|
||||
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
|
||||
const uploadCount = await db`
|
||||
SELECT count(*) as count FROM items
|
||||
WHERE username = ${req.session.user} AND stamp > ${twelveHoursAgo} AND is_deleted = false
|
||||
`;
|
||||
if (parseInt(uploadCount[0].count) >= 69) {
|
||||
return res.json({ success: false, msg: 'Upload limit reached (69 per 12 hours)' }, 429);
|
||||
}
|
||||
}
|
||||
|
||||
const url = inputUrl.trim();
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
const ytMatch = url.match(ytRegex);
|
||||
|
||||
// Check repost by source URL (skip for YouTube — reposts are allowed and harmless)
|
||||
if (!ytMatch && !getBypassDuplicateCheck()) {
|
||||
const repostLink = await queue.checkrepostlink(url);
|
||||
if (repostLink) {
|
||||
return res.json({ success: false, msg: 'This URL has already been uploaded', repost: repostLink }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
const isApprovalRequired = getManualApproval();
|
||||
|
||||
if (ytMatch && cfg.websrv.enable_youtube_upload !== false) {
|
||||
// ===== YOUTUBE EMBED =====
|
||||
const videoId = ytMatch[1];
|
||||
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
|
||||
// YouTube reposts are allowed — same video can be posted multiple times
|
||||
|
||||
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
|
||||
const filename = `yt:${videoId}`;
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
size: 0,
|
||||
checksum: `yt_${videoId}_${Date.now()}`,
|
||||
phash: null,
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// Auto-subscribe uploader
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
|
||||
|
||||
// Download YouTube thumbnail as our thumbnail
|
||||
try {
|
||||
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const tmpThumb = path.join(cfg.paths.tmp, `${itemid}_yt.jpg`);
|
||||
await queue.spawn('wget', ['-q', thumbUrl, '-O', tmpThumb]);
|
||||
await queue.spawn('magick', [tmpThumb, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', path.join(tDir, `${itemid}.webp`)]);
|
||||
await fs.unlink(tmpThumb).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] YouTube thumbnail error:', err);
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
// Assign rating tag
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
|
||||
if (rating === 'nsfw' || rating === 'nsfl') {
|
||||
await queue.genBlurredThumbnail(itemid, isApprovalRequired).catch(() => {});
|
||||
}
|
||||
|
||||
// Assign user tags + auto-tags
|
||||
const autoTags = autoTagsFromUrl(ytUrl); // always includes 'youtube' + 'youtube.com'
|
||||
const allTags = [...new Set([...tags, ...autoTags])];
|
||||
for (const tagName of allTags) {
|
||||
await assignTag(itemid, tagName, req.session.id);
|
||||
}
|
||||
|
||||
if (isApprovalRequired) await queue.notifyAdmins(itemid).catch(() => {});
|
||||
|
||||
// Save upload comment
|
||||
await saveComment(itemid, req.session.id, comment);
|
||||
|
||||
// Assign OC tags if the uploader ticked the OC checkbox
|
||||
if (is_oc) {
|
||||
const ocTags = ['oc', 'original content'];
|
||||
for (const tagname of ocTags) {
|
||||
await assignTag(itemid, tagname, req.session.id);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
msg: isApprovalRequired ? 'YouTube video embedded! Pending admin approval.' : 'YouTube video embedded!',
|
||||
itemid: itemid,
|
||||
manual_approval: isApprovalRequired
|
||||
});
|
||||
} else {
|
||||
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
|
||||
const session = {
|
||||
id: req.session.id,
|
||||
user: req.session.user,
|
||||
admin: req.session.admin,
|
||||
is_moderator: req.session.is_moderator,
|
||||
display_name: req.session.display_name
|
||||
};
|
||||
|
||||
// Return immediately to avoid proxy timeouts
|
||||
res.json({
|
||||
success: true,
|
||||
pending: true,
|
||||
msg: 'URL processing started in background. You will receive a notification when it is finished.'
|
||||
});
|
||||
|
||||
// Background processing block
|
||||
(async () => {
|
||||
try {
|
||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
||||
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
|
||||
let maxfilesize = cfg.main.maxfilesize;
|
||||
if (session.admin) maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
|
||||
|
||||
const uuid = await queue.genuuid();
|
||||
const isInstagram = /instagram\.com/i.test(url);
|
||||
|
||||
const dlError = (err) => {
|
||||
if (!err) return `Failed to download from ${url}`;
|
||||
const errStr = String(err.stderr || err.message || '');
|
||||
const httpCode = errStr.match(/HTTP Error (\d+)/i)?.[1]
|
||||
|| errStr.match(/\b(4\d{2}|5\d{2})\b/)?.[1]
|
||||
|| null;
|
||||
if (httpCode) return `Failed to download from ${url} (HTTP ${httpCode})`;
|
||||
if (err.code != null) return `Failed to download from ${url} (code ${err.code})`;
|
||||
return `Failed to download from ${url}`;
|
||||
};
|
||||
|
||||
let source;
|
||||
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
|
||||
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [
|
||||
...proxyArgs, ...ytdlpArgs,
|
||||
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
|
||||
url,
|
||||
'--max-filesize', `${maxfilesize / 1024}k`,
|
||||
'--postprocessor-args', 'ffmpeg:-bitexact',
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath',
|
||||
'--merge-output-format', 'mp4'
|
||||
])).stdout.trim();
|
||||
} catch (err) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
|
||||
if (isInstagram) throw err;
|
||||
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [
|
||||
...proxyArgs, ...ytdlpArgs,
|
||||
url,
|
||||
'--max-filesize', `${maxfilesize / 1024}k`,
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath'
|
||||
])).stdout.trim();
|
||||
} catch (err2) {
|
||||
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
|
||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
let referer = url;
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
let host = parsedUrl.hostname;
|
||||
if (host.includes('imgur.com')) host = 'imgur.com';
|
||||
referer = `${parsedUrl.protocol}//${host}/`;
|
||||
} catch (e) {}
|
||||
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L', url, '-o', fallbackTmp,
|
||||
'--max-filesize', `${maxfilesize}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300',
|
||||
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'--referer', referer,
|
||||
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'-H', 'Accept-Language: en-US,en;q=0.9'
|
||||
];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
await queue.spawn('curl', curlArgs);
|
||||
|
||||
const fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim();
|
||||
const extension = cfg.mimes[fallbackMime];
|
||||
if (extension) {
|
||||
const finalPath = path.join(cfg.paths.tmp, `${uuid}.${extension}`);
|
||||
await fs.rename(fallbackTmp, finalPath);
|
||||
source = finalPath;
|
||||
} else {
|
||||
await fs.unlink(fallbackTmp).catch(() => {});
|
||||
throw new Error(dlError(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!source || source.match(/larger than/)) throw new Error('File too large or download failed');
|
||||
|
||||
const { stat } = await import('fs/promises');
|
||||
const size = (await stat(source)).size;
|
||||
if (size > maxfilesize) {
|
||||
await fs.unlink(source).catch(() => {});
|
||||
throw new Error(`File too large. Max: ${lib.formatSize(maxfilesize)}, Got: ${lib.formatSize(size)}`);
|
||||
}
|
||||
|
||||
let mime = (await queue.spawn('file', ['--mime-type', '-b', source])).stdout.trim();
|
||||
const expectedExt = cfg.mimes[mime];
|
||||
if (expectedExt) {
|
||||
const currentExt = path.extname(source).slice(1).toLowerCase();
|
||||
if (currentExt === 'unknown_video' || (currentExt !== expectedExt && !((currentExt === 'jpg' && expectedExt === 'jpeg') || (currentExt === 'jpeg' && expectedExt === 'jpg')))) {
|
||||
const newSource = path.join(path.dirname(source), path.basename(source, path.extname(source)) + '.' + expectedExt);
|
||||
await fs.rename(source, newSource);
|
||||
source = newSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (mime === 'video/x-matroska') {
|
||||
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.mkv$/, '.mp4')]);
|
||||
await fs.unlink(source).catch(() => {});
|
||||
source = source.replace(/\.mkv$/, '.mp4');
|
||||
mime = 'video/mp4';
|
||||
}
|
||||
if (source.match(/\.opus$/)) {
|
||||
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]);
|
||||
await fs.unlink(source).catch(() => {});
|
||||
source = source.replace(/\.opus$/, '.ogg');
|
||||
mime = 'audio/ogg';
|
||||
}
|
||||
|
||||
if (!Object.keys(cfg.mimes).includes(mime)) {
|
||||
await fs.unlink(source).catch(() => {});
|
||||
throw new Error(`Unsupported file type: ${mime}`);
|
||||
}
|
||||
|
||||
const checksum = (await queue.spawn('sha256sum', [source])).stdout.trim().split(' ')[0];
|
||||
if (!getBypassDuplicateCheck()) {
|
||||
const repostSum = await queue.checkrepostsum(checksum);
|
||||
if (repostSum) {
|
||||
await fs.unlink(source).catch(() => {});
|
||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ url, msg: 'Duplicate detected (Checksum)' })})`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let phash = null;
|
||||
try {
|
||||
phash = await queue.generatePHash(source);
|
||||
if (phash && !getBypassDuplicateCheck()) {
|
||||
const phashMatch = await queue.checkrepostphash(phash);
|
||||
if (phashMatch) {
|
||||
await fs.unlink(source).catch(() => {});
|
||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ url, msg: 'Visual duplicate detected (PHash)' })})`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) { console.error('[UPLOAD-URL-ASYNC] PHash error:', e); }
|
||||
|
||||
const filename = path.basename(source);
|
||||
const isApprovalRequired = getManualApproval();
|
||||
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
|
||||
let linkedToExistingUrl = false;
|
||||
if (getBypassDuplicateCheck()) {
|
||||
const existing = await db`SELECT dest FROM items WHERE checksum = ${checksum} OR checksum LIKE ${checksum + '_bypass_%'} ORDER BY id DESC LIMIT 1`;
|
||||
if (existing.length > 0) {
|
||||
try {
|
||||
const realTarget = await fs.realpath(path.join(cfg.paths.b, existing[0].dest));
|
||||
await fs.symlink(realTarget, path.resolve(path.join(destDir, filename)));
|
||||
linkedToExistingUrl = true;
|
||||
} catch (e) { }
|
||||
}
|
||||
}
|
||||
if (!linkedToExistingUrl) await fs.copyFile(source, path.join(destDir, filename));
|
||||
await fs.unlink(source).catch(() => { });
|
||||
|
||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
src: url,
|
||||
dest: filename,
|
||||
mime: mime,
|
||||
size: size,
|
||||
checksum: insertChecksum,
|
||||
phash: phash,
|
||||
username: session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) { }
|
||||
|
||||
try {
|
||||
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
||||
if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
|
||||
} catch (err) {
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })}`;
|
||||
const autoTags = autoTagsFromUrl(url);
|
||||
const allTags = [...new Set([...tags, ...autoTags])];
|
||||
for (const tagName of allTags) {
|
||||
await assignTag(itemid, tagName, session.id);
|
||||
}
|
||||
|
||||
if (isApprovalRequired) await queue.notifyAdmins(itemid).catch(() => {});
|
||||
await saveComment(itemid, session.id, comment);
|
||||
if (is_oc) {
|
||||
for (const tagname of ['oc', 'original content']) {
|
||||
await assignTag(itemid, tagname, session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: mime,
|
||||
username: session.user,
|
||||
display_name: session.display_name || null,
|
||||
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
|
||||
is_oc: !!is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Push to Matrix Channel (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
try {
|
||||
const self = router.self;
|
||||
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||
const clients = await Promise.all(self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[UPLOAD-URL] Matrix notification sent for item ${itemid}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] Matrix notification error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Completion notification
|
||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
|
||||
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
|
||||
// Error notification
|
||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: err.message })})`;
|
||||
}
|
||||
})();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL ERROR]', err);
|
||||
return res.json({ success: false, msg: 'Upload failed: ' + (err.message || 'Unknown error') }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
23
src/inc/routes/banned.mjs
Normal file
23
src/inc/routes/banned.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
import cfg from "../config.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/banned\/?$/, async (req, res) => {
|
||||
if (!req.session || !req.session.banned) {
|
||||
return res.writeHead(302, {
|
||||
"Location": "/"
|
||||
}).end();
|
||||
}
|
||||
|
||||
res.reply({
|
||||
body: tpl.render("banned", {
|
||||
session: req.session,
|
||||
reason: req.session.ban_reason,
|
||||
expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent',
|
||||
ban_video: cfg.websrv.ban_video,
|
||||
hideNavbar: true
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
251
src/inc/routes/chat.mjs
Normal file
251
src/inc/routes/chat.mjs
Normal file
@@ -0,0 +1,251 @@
|
||||
import db from "../sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
|
||||
const MAX_MSG_LEN = 500;
|
||||
const RATE_LIMIT_MS = 1000; // 1s between messages
|
||||
const userLastMsg = new Map();
|
||||
|
||||
// Cached state — loaded from DB on first use, written through on every change
|
||||
let chatBackground = null;
|
||||
let chatTopic = null;
|
||||
let _settingsLoaded = false;
|
||||
|
||||
async function loadSettings() {
|
||||
if (_settingsLoaded) return;
|
||||
_settingsLoaded = true;
|
||||
try {
|
||||
const rows = await db`SELECT key, value FROM global_chat_settings WHERE key IN ('background','topic')`;
|
||||
for (const row of rows) {
|
||||
if (row.key === 'background') chatBackground = row.value || null;
|
||||
if (row.key === 'topic') chatTopic = row.value || null;
|
||||
}
|
||||
} catch (err) {
|
||||
// Table may not exist yet (migration not applied) — degrade gracefully
|
||||
console.warn('[Chat] Could not load settings (run global_chat_settings.sql migration):', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSetting(key, value) {
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO global_chat_settings (key, value) VALUES (${key}, ${value})
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
`;
|
||||
} catch (err) {
|
||||
console.warn('[Chat] Could not persist setting:', key, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Build allowed-host regex from config
|
||||
function buildBgHostRegex() {
|
||||
const siteHost = new URL(cfg.main.url.base || `http://${cfg.main.url.domain}`).host;
|
||||
const escaped = [siteHost, ...(cfg.websrv.allowed_comment_images || [])].map(h =>
|
||||
h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
);
|
||||
return new RegExp(`^https?://(?:${escaped.join('|')})/`, 'i');
|
||||
}
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// GET /api/chat — fetch recent messages
|
||||
router.get('/api/chat', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
if (!req.session) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
|
||||
}
|
||||
try {
|
||||
const messages = await db`
|
||||
SELECT gc.id, gc.user_id, gc.message, gc.created_at,
|
||||
u.user as username, uo.avatar, uo.avatar_file,
|
||||
uo.username_color, uo.display_name
|
||||
FROM global_chat gc
|
||||
JOIN "user" u ON u.id = gc.user_id
|
||||
LEFT JOIN user_options uo ON uo.user_id = gc.user_id
|
||||
ORDER BY gc.created_at DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, messages: messages.reverse() })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Chat] GET error:', err);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/chat — send a message
|
||||
router.post('/api/chat', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
if (!req.session) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Login required' }) });
|
||||
}
|
||||
|
||||
const message = (req.post?.message || '').trim();
|
||||
if (!message || message.length > MAX_MSG_LEN) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Invalid message' }) });
|
||||
}
|
||||
|
||||
// Rate limit
|
||||
const now = Date.now();
|
||||
const lastMs = userLastMsg.get(req.session.id) || 0;
|
||||
if (now - lastMs < RATE_LIMIT_MS) {
|
||||
return res.reply({ code: 429, body: JSON.stringify({ success: false, msg: 'Slow down!' }) });
|
||||
}
|
||||
userLastMsg.set(req.session.id, now);
|
||||
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO global_chat (user_id, message)
|
||||
VALUES (${req.session.id}, ${message})
|
||||
`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Chat] POST error:', err);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/chat/background — return current background CSS
|
||||
router.get('/api/chat/background', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
await loadSettings();
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, background: chatBackground })
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/chat/background — admin: set chat panel background
|
||||
router.post('/api/chat/background', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
|
||||
}
|
||||
|
||||
const { url, opts } = req.post || {};
|
||||
if (!url) {
|
||||
// Clear background
|
||||
chatBackground = null;
|
||||
await saveSetting('background', null);
|
||||
await db`SELECT pg_notify('global_chat_background', ${JSON.stringify({ background: null })})`;
|
||||
return res.reply({ headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ success: true }) });
|
||||
}
|
||||
|
||||
// Validate URL against allowed hosts
|
||||
const hostRegex = buildBgHostRegex();
|
||||
if (!hostRegex.test(url)) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL not from an allowed host' }) });
|
||||
}
|
||||
|
||||
// Build the CSS background shorthand
|
||||
const safeOpts = (opts || 'center / cover no-repeat')
|
||||
.replace(/[;<>{}]/g, '');
|
||||
const css = `url(${JSON.stringify(url)}) ${safeOpts}`;
|
||||
|
||||
chatBackground = css;
|
||||
await saveSetting('background', css);
|
||||
try {
|
||||
await db`SELECT pg_notify('global_chat_background', ${JSON.stringify({ background: css })})`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, background: css })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Chat] BACKGROUND error:', err);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/chat/topic — return current topic text
|
||||
router.get('/api/chat/topic', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
await loadSettings();
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, topic: chatTopic })
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/chat/topic — admin: set or clear the pinned topic
|
||||
router.post('/api/chat/topic', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
|
||||
}
|
||||
const raw = (req.post?.topic || '').trim();
|
||||
chatTopic = raw || null;
|
||||
await saveSetting('topic', chatTopic);
|
||||
try {
|
||||
await db`SELECT pg_notify('global_chat_topic', ${JSON.stringify({ topic: chatTopic })})`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, topic: chatTopic })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Chat] TOPIC error:', err);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/chat/:id — admin: delete a single message
|
||||
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
|
||||
}
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
try {
|
||||
await db`DELETE FROM global_chat WHERE id = ${id}`;
|
||||
await db`SELECT pg_notify('global_chat_delete', ${JSON.stringify({ id })})`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Chat] DELETE message error:', err);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/chat — admin: clear all messages
|
||||
router.delete('/api/chat', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
if (!req.session || (!req.session.admin && !req.session.is_moderator)) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Forbidden' }) });
|
||||
}
|
||||
try {
|
||||
await db`TRUNCATE global_chat RESTART IDENTITY`;
|
||||
await db`SELECT pg_notify('global_chat_clear', '')`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Chat] CLEAR error:', err);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
687
src/inc/routes/comments.mjs
Normal file
687
src/inc/routes/comments.mjs
Normal file
@@ -0,0 +1,687 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
|
||||
|
||||
// Get 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'
|
||||
|
||||
// Require login unless comments are public
|
||||
if (!req.session && cfg.main.hide_comments_from_public) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
comments: [],
|
||||
require_login: true,
|
||||
user_id: null,
|
||||
is_admin: false
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Check locked status
|
||||
const item = await db`SELECT is_comments_locked FROM items WHERE id = ${itemId}`;
|
||||
const is_locked = item.length > 0 ? item[0].is_comments_locked : false;
|
||||
|
||||
const comments = await f0cklib.getComments(itemId, sort, false);
|
||||
|
||||
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} AND is_subscribed = true`;
|
||||
if (sub.length > 0) is_subscribed = true;
|
||||
}
|
||||
|
||||
// Transform for frontend if needed, or send as is
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
comments,
|
||||
is_subscribed,
|
||||
is_locked,
|
||||
user_id: req.session ? req.session.user : null,
|
||||
is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
body: JSON.stringify({ success: false, message: "Database error" })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Browse User Comments
|
||||
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
|
||||
const user = decodeURIComponent(req.params.user);
|
||||
|
||||
try {
|
||||
// Check if user exists and get ID + avatar
|
||||
const u = await db`
|
||||
SELECT "user".id, "user".user, user_options.avatar, user_options.avatar_file, user_options.username_color
|
||||
FROM "user"
|
||||
LEFT JOIN user_options ON "user".id = user_options.user_id
|
||||
WHERE "user".user ILIKE ${user}
|
||||
`;
|
||||
if (!u.length) {
|
||||
return res.reply({ code: 404, body: "User not found" });
|
||||
}
|
||||
const userId = u[0].id;
|
||||
const sort = req.url.qs?.sort || 'new';
|
||||
const page = +(req.url.qs?.page || 1);
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
const isJson = req.url.qs?.json === 'true';
|
||||
|
||||
if (!req.session || !req.session.user) {
|
||||
if (cfg.main.hide_comments_from_public) {
|
||||
if (isJson) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: false, require_login: true })
|
||||
});
|
||||
} else {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
/* <mode-override> */
|
||||
// prioritize query mode (from AJAX) over session default
|
||||
let mode = req.mode;
|
||||
if (req.url.qs && req.url.qs.mode && (req.url.qs.mode === '0' || req.url.qs.mode === '1' || req.url.qs.mode === '2' || req.url.qs.mode === '3')) {
|
||||
mode = parseInt(req.url.qs.mode);
|
||||
}
|
||||
/* </mode-override> */
|
||||
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
|
||||
|
||||
const comments = await db`
|
||||
SELECT c.*, i.mime, i.id as item_id
|
||||
FROM comments c
|
||||
LEFT JOIN items i ON c.item_id = i.id
|
||||
WHERE c.user_id = ${userId} AND c.is_deleted = false
|
||||
AND i.active = true AND i.is_deleted = false
|
||||
AND ${db.unsafe(modequery)}
|
||||
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
// Process mentions for user comments page too
|
||||
// Note: Since we need to modify 'comments', we do it before any map/HTML escaping
|
||||
// However, f0cklib.processMentions returns a new array with modified content.
|
||||
// But we actually need to do this BEFORE the existing 'processedComments' map which does HTML escaping.
|
||||
// Wait, f0cklib.processMentions adds Markdown links: [@user](/user/user).
|
||||
// HTML escaping later will break this: ["@user...
|
||||
// So we need to ensure formatting happens appropriately.
|
||||
|
||||
// Actually, let's use processMentions here.
|
||||
// But notice below 'processedComments' logic manually escapes HTML and handles emojis.
|
||||
// If we add Markdown links now, 'escapeHtml' will destroy them.
|
||||
// We should probably rely on marked.js on the client side?
|
||||
// The client 'user_comments.js' uses marked.js!
|
||||
// So if we inject Markdown links, they will be rendered as links by marked.js.
|
||||
// BUT 'processedComments' escapes HTML.
|
||||
// Ideally, we should let marked handle everything or be careful.
|
||||
|
||||
// Let's modify comments content in-place (or new array) before mapping
|
||||
const mentionsProcessed = await f0cklib.processMentions(comments);
|
||||
const processedComments = mentionsProcessed.map(c => {
|
||||
return {
|
||||
...c,
|
||||
content: c.content
|
||||
};
|
||||
});
|
||||
|
||||
if (isJson) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, comments: processedComments, user: u[0] })
|
||||
});
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: u[0],
|
||||
comments: processedComments,
|
||||
hidePagination: true,
|
||||
tmp: null // for header/footer
|
||||
};
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ body: tpl.render('comments_user-partial', data, req) });
|
||||
}
|
||||
|
||||
return res.reply({ body: tpl.render('comments_user', data, req) });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: "Error" });
|
||||
}
|
||||
});
|
||||
|
||||
// In-memory rate limiter for comment posting.
|
||||
// Tracks timestamps of recent posts per user_id in a sliding window.
|
||||
// Map: userId -> number[] (unix ms timestamps)
|
||||
const commentRateLimiter = new Map();
|
||||
const COMMENT_RATE_LIMIT = 25; // max comments
|
||||
const COMMENT_RATE_WINDOW = 60_000; // per 60 seconds
|
||||
|
||||
const isCommentRateLimited = (userId) => {
|
||||
const now = Date.now();
|
||||
const windowStart = now - COMMENT_RATE_WINDOW;
|
||||
const timestamps = (commentRateLimiter.get(userId) || []).filter(t => t > windowStart);
|
||||
if (timestamps.length >= COMMENT_RATE_LIMIT) return true;
|
||||
timestamps.push(now);
|
||||
commentRateLimiter.set(userId, timestamps);
|
||||
// Prune entries for users inactive for > 5 minutes to avoid unbounded growth
|
||||
if (commentRateLimiter.size > 5000) {
|
||||
const pruneWindow = now - 300_000;
|
||||
for (const [uid, ts] of commentRateLimiter) {
|
||||
if (!ts.some(t => t > pruneWindow)) commentRateLimiter.delete(uid);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// 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" }) });
|
||||
|
||||
// Rate limit regular users (admins and mods are exempt)
|
||||
if (!req.session.admin && !req.session.is_moderator) {
|
||||
if (isCommentRateLimited(req.session.id)) {
|
||||
return res.reply({ code: 429, body: JSON.stringify({ success: false, message: "You're posting too fast. Please slow down." }) });
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time)))
|
||||
? parseFloat(body.video_time)
|
||||
: null;
|
||||
|
||||
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 {
|
||||
// Check if thread is locked (admins and mods can still post)
|
||||
if (!req.session.admin && !req.session.is_moderator) {
|
||||
const lockCheck = await db`SELECT COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${item_id}`;
|
||||
if (lockCheck.length > 0 && lockCheck[0].is_locked) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "This thread is locked" }) });
|
||||
}
|
||||
}
|
||||
|
||||
const insertData = {
|
||||
item_id,
|
||||
user_id: req.session.id,
|
||||
parent_id: parent_id || null,
|
||||
content: content
|
||||
};
|
||||
if (video_time !== null) insertData.video_time = video_time;
|
||||
|
||||
const newComment = await db`
|
||||
INSERT INTO comments ${db(insertData)}
|
||||
RETURNING id, created_at, video_time
|
||||
`;
|
||||
|
||||
const commentId = parseInt(newComment[0].id, 10);
|
||||
|
||||
// Notify Subscribers (excluding the author)
|
||||
// 1. Get subscribers (active only)
|
||||
const subscribers = await db`SELECT user_id FROM comment_subscriptions WHERE item_id = ${item_id} AND is_subscribed = true`;
|
||||
|
||||
// Mentions Logic: Parse content for @username and [@User Name] (space-containing names).
|
||||
// Strip spoiler wrappers first so mentions inside [spoiler]...[/spoiler] are visible.
|
||||
const strippedContent = content.replace(/\[spoiler\]/gi, '').replace(/\[\/spoiler\]/gi, '');
|
||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)|\[@([^\]]+)\]/g;
|
||||
const matches = [...strippedContent.matchAll(mentionRegex)];
|
||||
const mentionedNames = [...new Set(matches.map(m => (m[1] || m[2]).trim()))];
|
||||
const lowerNames = mentionedNames.map(n => n.toLowerCase());
|
||||
|
||||
let mentionedUsers = [];
|
||||
if (lowerNames.length > 0) {
|
||||
// Fetch IDs via login column (lowercase)
|
||||
mentionedUsers = await db`SELECT id, user FROM "user" WHERE login IN ${db(lowerNames)}`;
|
||||
}
|
||||
|
||||
// 2. Get parent author
|
||||
let parentAuthor = [];
|
||||
if (parent_id) {
|
||||
parentAuthor = await db`SELECT user_id FROM comments WHERE id = ${parent_id}`;
|
||||
}
|
||||
|
||||
// 3. Prepare notifications with priority: Mention > Reply > Subscription
|
||||
// Use a Map to ensure one notification per user
|
||||
const notificationsMap = new Map(); // UserId -> { type, ... }
|
||||
|
||||
// A. Mentions
|
||||
mentionedUsers.forEach(u => {
|
||||
if (u.id !== req.session.id) {
|
||||
notificationsMap.set(u.id, 'mention');
|
||||
}
|
||||
});
|
||||
|
||||
// B. Reply (Parent Author)
|
||||
if (parentAuthor.length > 0) {
|
||||
const pid = parentAuthor[0].user_id;
|
||||
// Only if not already mentioned
|
||||
if (pid !== req.session.id && !notificationsMap.has(pid)) {
|
||||
notificationsMap.set(pid, 'comment_reply');
|
||||
}
|
||||
}
|
||||
|
||||
// C. Subscribers
|
||||
const parentUserId = parentAuthor.length > 0 ? parentAuthor[0].user_id : -1;
|
||||
|
||||
// Get uploader ID to distinguish notification type
|
||||
const itemInfo = await db`
|
||||
SELECT u.id as uploader_id
|
||||
FROM items i
|
||||
JOIN "user" u ON (i.username ILIKE u.login OR i.username ILIKE u.user)
|
||||
WHERE i.id = ${item_id}
|
||||
LIMIT 1
|
||||
`;
|
||||
const uploaderId = itemInfo.length > 0 ? itemInfo[0].uploader_id : null;
|
||||
|
||||
subscribers.forEach(s => {
|
||||
// If not self, and not already notified (as mention or reply)
|
||||
if (s.user_id !== req.session.id && !notificationsMap.has(s.user_id)) {
|
||||
// Use specialized type for uploader
|
||||
const type = (uploaderId && s.user_id === uploaderId) ? 'upload_comment' : 'subscription';
|
||||
notificationsMap.set(s.user_id, type);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Batch insert non-bundleable, handle bundleable separately
|
||||
const bundleable = [];
|
||||
const nonBundleable = [];
|
||||
|
||||
for (const [uid, type] of notificationsMap.entries()) {
|
||||
const notif = {
|
||||
user_id: uid,
|
||||
type: type,
|
||||
item_id: item_id,
|
||||
reference_id: commentId
|
||||
};
|
||||
if (type === 'upload_comment') {
|
||||
bundleable.push(notif);
|
||||
} else {
|
||||
nonBundleable.push(notif);
|
||||
}
|
||||
}
|
||||
|
||||
if (nonBundleable.length > 0) {
|
||||
await db`INSERT INTO notifications ${db(nonBundleable)}`;
|
||||
}
|
||||
|
||||
for (const n of bundleable) {
|
||||
// Try to update existing unread notification for this item/user/type
|
||||
const updated = await db`
|
||||
UPDATE notifications
|
||||
SET created_at = NOW(), reference_id = ${n.reference_id}
|
||||
WHERE user_id = ${n.user_id}
|
||||
AND item_id = ${n.item_id}
|
||||
AND type = 'upload_comment'
|
||||
AND is_read = false
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (updated.length === 0) {
|
||||
await db`INSERT INTO notifications ${db(n)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Notify for live updates
|
||||
// Fetch the trigger-updated xd_score from the DB (trigger runs synchronously before we get here)
|
||||
const [xdRow] = await db`SELECT xd_score FROM items WHERE id = ${item_id}`;
|
||||
const livePayload = {
|
||||
type: 'comment',
|
||||
id: commentId,
|
||||
item_id: item_id,
|
||||
parent_id: parent_id || null,
|
||||
body: content,
|
||||
username: req.session.user,
|
||||
user_id: req.session.id,
|
||||
avatar: req.session.avatar,
|
||||
avatar_file: req.session.avatar_file,
|
||||
created_at: new Date().toISOString(),
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.display_name || null,
|
||||
xd_score: xdRow?.xd_score ?? null,
|
||||
video_time: newComment[0]?.video_time ?? null
|
||||
};
|
||||
|
||||
// 1. Thread live update
|
||||
db.notify('comments', JSON.stringify(livePayload));
|
||||
|
||||
// 2. Sidebar activity update
|
||||
db.notify('activity', JSON.stringify({
|
||||
user_id: req.session.id,
|
||||
item_id: item_id,
|
||||
type: 'comment',
|
||||
body: content,
|
||||
id: commentId
|
||||
}));
|
||||
|
||||
// Automatically subscribe user to the thread
|
||||
const subResult = await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${req.session.id}, ${item_id})
|
||||
ON CONFLICT (user_id, item_id) DO NOTHING
|
||||
RETURNING 1
|
||||
`;
|
||||
const is_new_subscription = subResult.length > 0;
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
comment: newComment[0],
|
||||
is_new_subscription
|
||||
})
|
||||
});
|
||||
} 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 is_subscribed FROM comment_subscriptions
|
||||
WHERE user_id = ${req.session.id} AND item_id = ${itemId}
|
||||
`;
|
||||
|
||||
let subscribed = false;
|
||||
if (existing.length > 0) {
|
||||
subscribed = !existing[0].is_subscribed;
|
||||
await db`UPDATE comment_subscriptions SET is_subscribed = ${subscribed} WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
|
||||
} else {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id, is_subscribed) VALUES (${req.session.id}, ${itemId}, true)`;
|
||||
subscribed = true;
|
||||
}
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
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;
|
||||
console.log(`[DEBUG] Attempting to delete comment ${commentId} by user ${req.session.id} (mod: ${req.session.is_moderator})`);
|
||||
|
||||
try {
|
||||
const comment = await db`SELECT content, item_id, 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 && !req.session.is_moderator && comment[0].user_id !== req.session.id) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
}
|
||||
|
||||
// Log all deletions in audit log
|
||||
const reason = (req.post && req.post.reason) ? req.post.reason : (req.url.qs?.reason || 'No reason provided');
|
||||
await audit.log(req.session.id, 'delete_comment', 'comment', commentId, {
|
||||
item_id: comment[0].item_id,
|
||||
reason: reason,
|
||||
old_content: comment[0].content
|
||||
});
|
||||
|
||||
await db`UPDATE comments SET is_deleted = true, content = '[deleted]' WHERE id = ${commentId}`;
|
||||
|
||||
// Notify for live update
|
||||
db.notify('comments', JSON.stringify({
|
||||
type: 'delete',
|
||||
item_id: comment[0].item_id,
|
||||
comment_id: commentId
|
||||
}));
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (e) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Edit comment (admin/mod 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 && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
|
||||
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, user_id, item_id, content FROM comments WHERE id = ${commentId}`;
|
||||
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
||||
|
||||
const oldContent = comment[0].content;
|
||||
|
||||
await audit.log(req.session.id, 'edit_comment', 'comment', commentId, {
|
||||
item_id: comment[0].item_id,
|
||||
old_content: oldContent.substring(0, 2000),
|
||||
new_content: content.substring(0, 2000)
|
||||
});
|
||||
|
||||
await db`UPDATE comments SET content = ${content}, updated_at = NOW() WHERE id = ${commentId}`;
|
||||
|
||||
// Notify for live update
|
||||
db.notify('comments', JSON.stringify({
|
||||
type: 'edit',
|
||||
item_id: comment[0].item_id,
|
||||
comment_id: commentId,
|
||||
content: content
|
||||
}));
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
// Toggle pin comment (admin/mod only)
|
||||
router.post(/\/api\/comments\/(?<id>\d+)\/pin/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
|
||||
const commentId = req.params.id;
|
||||
|
||||
try {
|
||||
const comment = await db`SELECT id, COALESCE(is_pinned, false) as is_pinned, item_id FROM comments WHERE id = ${commentId}`;
|
||||
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
||||
|
||||
const newPinned = !comment[0].is_pinned;
|
||||
await db`UPDATE comments SET is_pinned = ${newPinned} WHERE id = ${commentId}`;
|
||||
|
||||
await audit.log(req.session.id, 'pin_comment', 'comment', commentId, { item_id: comment[0].item_id, is_pinned: newPinned });
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, is_pinned: newPinned })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
// Toggle lock thread (admin/mod only)
|
||||
router.post(/\/api\/comments\/(?<itemid>\d+)\/lock/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
if (!req.session.admin && !req.session.is_moderator) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
|
||||
|
||||
const itemId = req.params.itemid;
|
||||
|
||||
try {
|
||||
const item = await db`SELECT id, COALESCE(is_comments_locked, false) as is_locked FROM items WHERE id = ${itemId}`;
|
||||
if (!item.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
|
||||
|
||||
const newLocked = !item[0].is_locked;
|
||||
await db`UPDATE items SET is_comments_locked = ${newLocked} WHERE id = ${itemId}`;
|
||||
|
||||
await audit.log(req.session.id, newLocked ? 'lock_thread' : 'unlock_thread', 'item', itemId);
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, is_locked: newLocked })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Recent Activity Page
|
||||
router.get(/\/activity\/?/, async (req, res) => {
|
||||
try {
|
||||
const page = +(req.url.qs?.page || 1);
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
/* <mode-override> */
|
||||
// prioritize query mode (from AJAX) over session default
|
||||
let mode = req.mode;
|
||||
if (req.url.qs && req.url.qs.mode && (req.url.qs.mode === '0' || req.url.qs.mode === '1' || req.url.qs.mode === '2' || req.url.qs.mode === '3')) {
|
||||
mode = parseInt(req.url.qs.mode);
|
||||
}
|
||||
/* </mode-override> */
|
||||
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
|
||||
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
|
||||
const comments = await db`
|
||||
SELECT
|
||||
c.*,
|
||||
i.mime,
|
||||
i.id as item_id,
|
||||
i.dest as item_dest,
|
||||
u.user as username,
|
||||
uo.avatar,
|
||||
uo.avatar_file,
|
||||
uo.username_color,
|
||||
uo.display_name
|
||||
FROM comments c
|
||||
LEFT JOIN items i ON c.item_id = i.id
|
||||
LEFT JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
WHERE c.is_deleted = false
|
||||
AND i.active = true
|
||||
AND i.is_deleted = false
|
||||
AND ${db.unsafe(modequery)}
|
||||
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const processedComments = comments.map(c => {
|
||||
return {
|
||||
...c,
|
||||
content: (c.content || '').trim(),
|
||||
username_color: c.username_color
|
||||
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
|
||||
};
|
||||
});
|
||||
|
||||
if (req.url.qs?.json === 'true' || req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
comments: processedComments,
|
||||
page,
|
||||
hasMore: processedComments.length === limit
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone page no longer exists
|
||||
return res.reply({ code: 404, body: "Page not found" });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: "Error loading activity data" });
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe to all own uploads
|
||||
router.post('/api/v2/user/subscribe-all-uploads', async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
|
||||
|
||||
try {
|
||||
const result = await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
SELECT ${req.session.id}, i.id
|
||||
FROM items i
|
||||
WHERE i.username ILIKE ${req.session.login} OR i.username ILIKE ${req.session.user}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
message: `Successfully subscribed to your uploads`
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[API] Failed to subscribe to all uploads:', err);
|
||||
res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, message: "Internal server error" })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
51
src/inc/routes/emojis.mjs
Normal file
51
src/inc/routes/emojis.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
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" }) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// 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}`;
|
||||
await db`NOTIFY emojis_updated, '{}'`;
|
||||
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;
|
||||
};
|
||||
112
src/inc/routes/halls.mjs
Normal file
112
src/inc/routes/halls.mjs
Normal file
@@ -0,0 +1,112 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import url from "url";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Main Halls Overview
|
||||
router.get(/^\/halls$/, async (req, res) => {
|
||||
const mode = req.mode ?? 0;
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
|
||||
const hallsList = await f0cklib.getHallsOverview(mode, excludedTags);
|
||||
|
||||
const data = {
|
||||
hallsList: hallsList,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'Halls',
|
||||
description: `Browse curated item collections`,
|
||||
url: `https://${cfg.main.url.domain}/halls`
|
||||
}
|
||||
};
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({
|
||||
body: tpl.render('halls-partial', data, req)
|
||||
});
|
||||
}
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('halls', data, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Hall Thumbnail Route
|
||||
router.get(/^\/hall_image\/(?<hallSlug>.+)$/, async (req, res) => {
|
||||
const hallSlug = decodeURIComponent(req.params.hallSlug);
|
||||
const mode = +(req.url.qs?.m ?? 0);
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
|
||||
try {
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const customPath = path.join(CUSTOM_DIR, `${hallSlug}.webp`);
|
||||
|
||||
// Serve custom image if it exists (skip mosaic entirely)
|
||||
try {
|
||||
const stat = await fs.stat(customPath);
|
||||
const etag = '"' + stat.mtimeMs.toString(16) + '-' + stat.size.toString(16) + '"';
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
res.writeHead(304);
|
||||
return res.end();
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'no-cache', 'ETag': etag });
|
||||
return res.end(await fs.readFile(customPath));
|
||||
} catch (e) { /* no custom image, fall through to mosaic */ }
|
||||
|
||||
const hash = (await import('crypto')).createHash('md5').update(`${hallSlug}_${mode}`).digest('hex');
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}.webp`);
|
||||
|
||||
// Try cache first
|
||||
try {
|
||||
await fs.access(cachePath);
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||
return res.end(await fs.readFile(cachePath));
|
||||
} catch (e) {}
|
||||
|
||||
// Generate Mosaic
|
||||
let modeFilter = db``;
|
||||
if (mode === 0) modeFilter = db`JOIN tags_assign ta_sfw ON ta_sfw.item_id = i.id AND ta_sfw.tag_id = 1`;
|
||||
else if (mode === 1) modeFilter = db`JOIN tags_assign ta_nsfw ON ta_nsfw.item_id = i.id AND ta_nsfw.tag_id = 2`;
|
||||
|
||||
const items = await db`
|
||||
SELECT i.id
|
||||
FROM items i
|
||||
JOIN halls_assign ha ON ha.item_id = i.id
|
||||
JOIN halls h ON h.id = ha.hall_id
|
||||
${modeFilter}
|
||||
WHERE h.slug = ${hallSlug} AND i.active = true
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
if (items.length > 0) {
|
||||
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
const { execFile } = await import('child_process');
|
||||
const util = await import('util');
|
||||
const execFilePromise = util.promisify(execFile);
|
||||
|
||||
// If only 1 or 2 items, just use what we have
|
||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||
return res.end(await fs.readFile(cachePath));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HALL_IMAGE] Error:', e);
|
||||
}
|
||||
|
||||
// Default placeholder
|
||||
res.writeHead(302, { 'Location': '/s/img/favicon.gif' });
|
||||
res.end();
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
489
src/inc/routes/index.mjs
Normal file
489
src/inc/routes/index.mjs
Normal file
@@ -0,0 +1,489 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
|
||||
const auth = async (req, res, next) => {
|
||||
if (!req.session)
|
||||
return res.redirect("/login");
|
||||
return next();
|
||||
};
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/\/user\/(?<user>[^/]+)\/?$/, async (req, res) => {
|
||||
const user = decodeURIComponent(req.params.user);
|
||||
const mime = req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || null);
|
||||
|
||||
const query = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_expires, "user".created_at, user_options.*, user_options.display_name
|
||||
from "user"
|
||||
left join user_options on "user".id = user_options.user_id
|
||||
where "user".user ilike ${user} OR "user".login ilike ${user}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
let userData = query[0];
|
||||
|
||||
if (!userData) {
|
||||
// Fallback: Check if user exists as a "Ghost" (uploading in items table but no account)
|
||||
const ghostQuery = await db`
|
||||
SELECT username, MIN(stamp) as first_upload
|
||||
FROM items
|
||||
WHERE username ILIKE ${user} AND active = true
|
||||
GROUP BY username
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (ghostQuery.length) {
|
||||
userData = {
|
||||
id: null,
|
||||
user: user,
|
||||
created_at: new Date(ghostQuery[0].first_upload * 1000),
|
||||
activated: true,
|
||||
banned: false,
|
||||
is_ghost: true
|
||||
};
|
||||
} else {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: 'this user does not exists',
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let f0cks, favs;
|
||||
const count = {
|
||||
f0cks: 0,
|
||||
favs: 0,
|
||||
tags: 0
|
||||
};
|
||||
try {
|
||||
const isRandom = req.cookies.random_mode === '1';
|
||||
f0cks = await f0cklib.getf0cks({
|
||||
user: user,
|
||||
mode: req.mode,
|
||||
mime: mime,
|
||||
fav: false,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id,
|
||||
random: isRandom
|
||||
});
|
||||
if ('items' in f0cks) {
|
||||
count.f0cks = f0cks.total ?? f0cks.items.length;
|
||||
f0cks.items = f0cks.items.slice(0, 12);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PROFILE] getf0cks failed for user:', user, err);
|
||||
f0cks = false;
|
||||
count.f0cks = 0;
|
||||
}
|
||||
if (!userData.is_ghost) {
|
||||
try {
|
||||
const isRandom = req.cookies.random_mode === '1';
|
||||
favs = await f0cklib.getf0cks({
|
||||
user: user,
|
||||
mode: req.mode,
|
||||
mime: mime,
|
||||
fav: true,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id,
|
||||
random: isRandom
|
||||
});
|
||||
if (favs && 'items' in favs) {
|
||||
count.favs = favs.total ?? favs.items.length;
|
||||
favs.items = favs.items.slice(0, 12);
|
||||
}
|
||||
} catch (err) {
|
||||
favs = false;
|
||||
count.favs = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userData.is_ghost) {
|
||||
try {
|
||||
const [comms, tags, halls] = await Promise.all([
|
||||
db`
|
||||
select count(*)
|
||||
from comments c
|
||||
join items i on c.item_id = i.id
|
||||
where c.user_id = ${userData.id}
|
||||
and c.is_deleted = false
|
||||
and i.active = true
|
||||
and i.is_deleted = false
|
||||
`,
|
||||
db`
|
||||
select count(*)
|
||||
from tags_assign
|
||||
where user_id = ${userData.id}
|
||||
and tag_id > 2
|
||||
`,
|
||||
db`
|
||||
select count(*)
|
||||
from user_halls
|
||||
where user_id = ${userData.id}
|
||||
`
|
||||
]);
|
||||
count.comments = +comms[0].count;
|
||||
count.tags = +tags[0].count;
|
||||
count.halls = +halls[0].count;
|
||||
} catch (e) {
|
||||
count.comments = count.comments || 0;
|
||||
count.tags = 0;
|
||||
}
|
||||
}
|
||||
|
||||
userData.timestamp = {
|
||||
timeago: lib.timeAgo(userData.created_at),
|
||||
timefull: userData.created_at
|
||||
};
|
||||
|
||||
if (userData.banned) {
|
||||
if (!userData.ban_expires) {
|
||||
userData.ban_duration = "Permanent";
|
||||
} else {
|
||||
const diff = ~~((new Date(userData.ban_expires) - new Date()) / 1e3);
|
||||
if (diff <= 0) {
|
||||
userData.ban_duration = "Expiration pending refresh";
|
||||
} else {
|
||||
const epochs = [
|
||||
["year", 31536000],
|
||||
["month", 2592000],
|
||||
["week", 604800],
|
||||
["day", 86400],
|
||||
["hour", 3600],
|
||||
["minute", 60],
|
||||
["second", 1]
|
||||
];
|
||||
let durationStr = "Expires in ";
|
||||
for (let [name, seconds] of epochs) {
|
||||
const interval = ~~(diff / seconds);
|
||||
if (interval >= 1) {
|
||||
durationStr += `${interval} ${name}${interval === 1 ? "" : "s"}`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
userData.ban_duration = durationStr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
user: userData,
|
||||
f0cks,
|
||||
count,
|
||||
favs,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: userData.user,
|
||||
description: userData.is_ghost ? `${count.f0cks} legacy uploads` : `${count.f0cks} uploads, ${count.favs} favorites`,
|
||||
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(userData.user)}`,
|
||||
image: userData.avatar_file
|
||||
? `https://${cfg.main.url.domain}/a/${userData.avatar_file}`
|
||||
: userData.avatar
|
||||
? `https://${cfg.main.url.domain}/t/${userData.avatar}.webp`
|
||||
: `https://${cfg.main.url.domain}/a/default.png`
|
||||
}
|
||||
};
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ body: tpl.render('user-partial', data, req) });
|
||||
}
|
||||
|
||||
return res.reply({ body: tpl.render('user', data, req) });
|
||||
});
|
||||
|
||||
/* <routing-refactor> */
|
||||
const handleGenericRoute = async (req, res) => {
|
||||
const tRouteStart = Date.now();
|
||||
const mode = req.params.itemid ? 'item' : 'index';
|
||||
|
||||
// Auto-persist strict mode from URL to session if it's there
|
||||
if (req.session && (req.query?.strict !== undefined || req.url.qs?.strict !== undefined)) {
|
||||
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
|
||||
}
|
||||
|
||||
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
|
||||
user: req.params.user,
|
||||
tag: req.params.tag,
|
||||
mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null),
|
||||
page: req.params.page,
|
||||
itemid: req.params.itemid,
|
||||
hall: req.params.hall,
|
||||
fav: req.params.mode == 'favs',
|
||||
mode: req.mode,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
url: decodeURIComponent(req.url.pathname || req.url),
|
||||
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
|
||||
explicitStrict: !!(req.query?.strict || req.url.qs?.strict),
|
||||
random: req.cookies.random_mode === '1',
|
||||
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0))
|
||||
});
|
||||
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
|
||||
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
|
||||
|
||||
if (!data.success) {
|
||||
// For index/grid views with zero items (empty DB), render an empty grid instead of error
|
||||
if (mode !== 'item') {
|
||||
data.items = [];
|
||||
data.pagination = { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null };
|
||||
data.total = 0;
|
||||
data.success = true;
|
||||
if (!data.link) {
|
||||
if (req.params.hall) data.link = { main: '/h/' + req.params.hall + '/', path: 'p/', suffix: '' };
|
||||
else if (req.params.tag) data.link = { main: '/tag/' + req.params.tag + '/', path: 'p/', suffix: '' };
|
||||
else data.link = { main: '/', path: 'p/', suffix: '' };
|
||||
}
|
||||
data.tmp = data.tmp || {};
|
||||
if (req.params.hall && !data.tmp.hall) {
|
||||
const hallRow = await db`SELECT id, name, slug, description FROM halls WHERE slug = ${req.params.hall} LIMIT 1`;
|
||||
data.tmp.hall = hallRow.length ? hallRow[0] : req.params.hall;
|
||||
}
|
||||
if (req.params.tag && !data.tmp.tag) data.tmp.tag = req.params.tag;
|
||||
} else {
|
||||
// Return 200 for filtered NSFW items (has item data) so Discord parses og:image
|
||||
// Return 404 only for truly missing items
|
||||
const statusCode = data.item ? 200 : 404;
|
||||
return res.reply({
|
||||
code: statusCode,
|
||||
body: tpl.render('error', {
|
||||
message: data.message,
|
||||
item: data.item, // For OG meta tags on filtered NSFW items
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'item') {
|
||||
data.hidePagination = true;
|
||||
// Precompute hall display data for the template
|
||||
if (data.item && data.item.halls && data.item.halls.length) {
|
||||
const currentHallSlug = data.tmp && data.tmp.hall
|
||||
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
|
||||
: null;
|
||||
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
|
||||
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
|
||||
} else if (data.item) {
|
||||
data.item.primaryHall = null;
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
// Mark notifications as read
|
||||
if (req.session?.id) {
|
||||
f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
}
|
||||
// Subscription status — just a boolean, cheap to embed
|
||||
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
data.isSubscribed = sub;
|
||||
|
||||
// xD Score — fetch comments only for the score; do NOT embed into the page
|
||||
// Comments are always loaded async by the client via /api/comments/:id
|
||||
// This avoids blocking the browser's main thread when a comment has a huge xD payload.
|
||||
const commentsForScore = await f0cklib.getComments(req.params.itemid, 'old', false);
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
|
||||
// Do NOT set commentsJSON — client will fetch async
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
} else {
|
||||
data.isSubscribed = false;
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
} else {
|
||||
// Ensure total is defined for list views (to prevent template error)
|
||||
if (data.total === undefined) data.total = 0;
|
||||
}
|
||||
|
||||
// Explicitly inject session for template logic (Navbar)
|
||||
// Only inject session for authenticated users to avoid showing member UI to guests
|
||||
data.session = (req.session && req.session.user) ? { ...req.session } : false;
|
||||
|
||||
// Precompute boolean helpers for template @if() — the flummpress template engine uses a
|
||||
// non-greedy regex to parse @if(condition) and stops at the FIRST ')' it encounters.
|
||||
// This means any nested parens (e.g. indexOf('x'), .some(fn), (a || b)) inside @if()
|
||||
// will produce broken JS and a "Unexpected token '{'" parse error.
|
||||
// Solution: precompute all such conditions as plain booleans here.
|
||||
if (mode === 'item' && data.item) {
|
||||
const session = data.session;
|
||||
const item = data.item;
|
||||
// Is the current user a moderator/admin?
|
||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||
// Can the current user manage this item (owner, admin, or mod)?
|
||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||
// Is the item's MIME type suitable for metadata extraction?
|
||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
|
||||
// Has the current user favorited this item?
|
||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||
// Hall columns for display
|
||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
|
||||
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
|
||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||
data.item_username_lower = (item.username || '').toLowerCase();
|
||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
const tRenderStart = Date.now();
|
||||
|
||||
// Check if AJAX request for standard grid view
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' && mode === 'index') {
|
||||
const body = tpl.render('index-partial', data, req);
|
||||
console.log(`[${new Date().toISOString()}] [ROUTE] Render complete (partial) in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`);
|
||||
return res.reply({ body });
|
||||
}
|
||||
|
||||
const body = tpl.render(mode, data, req);
|
||||
console.log(`[${new Date().toISOString()}] [ROUTE] Render complete in ${Date.now() - tRenderStart}ms. Total route time: ${Date.now() - tRouteStart}ms`);
|
||||
|
||||
return res.reply({ body });
|
||||
};
|
||||
|
||||
// Specific route for direct item links: /user/:user/:itemid
|
||||
// This avoids ambiguity with the profile route
|
||||
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
|
||||
|
||||
// Generic router for everything else (Index, Tags, standard User Grids)
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
|
||||
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
/* </routing-refactor> */
|
||||
|
||||
router.get(/^\/(about)$/, (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render('about', {
|
||||
tmp: null,
|
||||
mail: cfg.main.mail,
|
||||
discord: cfg.main.discord,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'about',
|
||||
description: 'About w0bm.com',
|
||||
url: `https://${cfg.main.url.domain}/about`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/(terms)$/, (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render('terms', {
|
||||
tmp: null,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'terms',
|
||||
description: 'Terms of service',
|
||||
url: `https://${cfg.main.url.domain}/terms`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/(rules)$/, (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render('rules', {
|
||||
tmp: null,
|
||||
domain: cfg.main.url.domain,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'rules',
|
||||
description: 'Rules and guidelines',
|
||||
url: `https://${cfg.main.url.domain}/rules`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
router.get(/^\/mode\/(\d)/, async (req, res) => {
|
||||
const modeMatch = req.url.pathname.match(/^\/mode\/(\d)/);
|
||||
const mode = modeMatch ? +modeMatch[1] : 0;
|
||||
|
||||
if (cfg.allowedModes[mode]) {
|
||||
if (req.session) {
|
||||
req.session.mode = mode;
|
||||
const blah = {
|
||||
user_id: req.session.id,
|
||||
mode: mode,
|
||||
theme: req.theme ?? (cfg.websrv.theme || "f0ck")
|
||||
};
|
||||
|
||||
await db`
|
||||
insert into "user_options" ${db(blah, 'user_id', 'mode', 'theme')
|
||||
}
|
||||
on conflict ("user_id") do update set
|
||||
mode = excluded.mode,
|
||||
theme = excluded.theme,
|
||||
user_id = excluded.user_id
|
||||
`;
|
||||
}
|
||||
}
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': `mode=${mode}; ${lib.getCookieOptions(31536000, false)}`
|
||||
},
|
||||
body: JSON.stringify({ success: true, mode: mode })
|
||||
});
|
||||
}
|
||||
|
||||
return res.writeHead(302, {
|
||||
"Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
"Set-Cookie": `mode=${mode}; ${lib.getCookieOptions(31536000, false)}`,
|
||||
"Location": "/"
|
||||
}).end();
|
||||
});
|
||||
|
||||
router.get(/^\/strict\/(0|1)$/, async (req, res) => {
|
||||
const urlStr = req.url.pathname || req.url;
|
||||
const strict = +urlStr.split("/")[2] === 1;
|
||||
|
||||
if (req.session) {
|
||||
console.log(`[DEBUG] Setting strict mode to ${strict} for user ${req.session.id}`);
|
||||
req.session.strict_mode = strict;
|
||||
|
||||
await db`
|
||||
insert into "user_options" (user_id, strict_mode, mode, theme, fullscreen)
|
||||
values (${req.session.id}, ${strict}, ${req.session.mode ?? 0}, ${req.session.theme ?? (cfg.websrv.theme || 'f0ck')}, ${req.session.fullscreen ?? 0})
|
||||
on conflict ("user_id") do update set
|
||||
strict_mode = excluded.strict_mode
|
||||
`;
|
||||
} else {
|
||||
console.log(`[DEBUG] No session found for strict toggle!`);
|
||||
}
|
||||
|
||||
return res.reply({
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ success: true, strict: strict })
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
return router;
|
||||
};
|
||||
11
src/inc/routes/maintenance.mjs
Normal file
11
src/inc/routes/maintenance.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import db from '../sql.mjs';
|
||||
import lib from '../lib.mjs';
|
||||
import cfg from '../config.mjs';
|
||||
import queue from '../queue.mjs';
|
||||
import path from 'path';
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
|
||||
return router;
|
||||
};
|
||||
12
src/inc/routes/matrix.mjs
Normal file
12
src/inc/routes/matrix.mjs
Normal file
@@ -0,0 +1,12 @@
|
||||
import cfg from "../config.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/matrix(\/)?$/, async (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render("matrix", {
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
};
|
||||
53
src/inc/routes/meme-manager.mjs
Normal file
53
src/inc/routes/meme-manager.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import db from "../sql.mjs";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
import { parseMultipart, collectBody } from "../multipart.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// Admin View
|
||||
router.get(/^\/admin\/memes\/?$/, lib.auth, async (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render("admin/memes", { session: req.session, tmp: null }, req)
|
||||
});
|
||||
});
|
||||
|
||||
// List all memes (Public API)
|
||||
router.get('/api/v2/memes', async (req, res) => {
|
||||
try {
|
||||
const memes = await db`SELECT id, template_id, name, url, category FROM meme_templates ORDER BY name ASC`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, memes })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Delete meme template (Admin only)
|
||||
router.post(/\/api\/v2\/admin\/memes\/(?<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 meme_templates 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;
|
||||
};
|
||||
60
src/inc/routes/meme.mjs
Normal file
60
src/inc/routes/meme.mjs
Normal file
@@ -0,0 +1,60 @@
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
|
||||
// templates are now fetched from the database
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Template selection page
|
||||
router.get(/^\/meme$/, lib.userauth, async (req, res) => {
|
||||
if (!cfg.websrv.meme_creator) {
|
||||
res.writeHead(404).end('Not Found');
|
||||
return;
|
||||
}
|
||||
const templates = await db`SELECT template_id as id, name, url, category, sub_category FROM meme_templates ORDER BY created_at DESC`;
|
||||
|
||||
// Extract unique categories for filtering
|
||||
const categories = ['All', ...new Set(templates.map(t => t.category || 'General'))].sort();
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('meme-select', {
|
||||
templates: templates,
|
||||
categories: categories,
|
||||
page_meta: {
|
||||
title: 'Meme Creator - Select Template',
|
||||
description: 'Select a template to create your meme',
|
||||
url: `https://${cfg.main.url.domain}/meme`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Meme creator page
|
||||
router.get(/^\/meme\/(?<id>[a-z0-9-]+)$/, lib.userauth, async (req, res) => {
|
||||
if (!cfg.websrv.meme_creator) {
|
||||
res.writeHead(404).end('Not Found');
|
||||
return;
|
||||
}
|
||||
const templateId = req.params?.id || req.url.pathname.match(/\/meme\/([a-z0-9-]+)/)?.[1];
|
||||
const templateSearch = await db`SELECT template_id as id, name, url, category, sub_category FROM meme_templates WHERE template_id = ${templateId} LIMIT 1`;
|
||||
const template = templateSearch[0];
|
||||
|
||||
if (!template) {
|
||||
res.writeHead(404).end('Template not found');
|
||||
return;
|
||||
}
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('meme-creator', {
|
||||
template: template,
|
||||
page_meta: {
|
||||
title: `Create Meme - ${template.name}`,
|
||||
description: `Create a meme using the ${template.name} template`,
|
||||
url: `https://${cfg.main.url.domain}/meme/${templateId}`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
399
src/inc/routes/messages.mjs
Normal file
399
src/inc/routes/messages.mjs
Normal file
@@ -0,0 +1,399 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getPrivateMessages } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
const json = (res, body, code = 200) =>
|
||||
res.reply({ code, headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body) });
|
||||
|
||||
// Block all DM routes when private messaging is disabled in config
|
||||
const dmEnabled = (req, res, next) => {
|
||||
if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' });
|
||||
if (next) next();
|
||||
};
|
||||
|
||||
// ─── Public Key Management ───────────────────────────────────────────────
|
||||
|
||||
// Upload / refresh current user's public key
|
||||
router.post('/api/dm/pubkey', async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const body = req.post || {};
|
||||
const pubkey = body.pubkey;
|
||||
const fingerprint = body.fingerprint || null;
|
||||
|
||||
if (!pubkey || typeof pubkey !== 'string' || pubkey.length > 4096) {
|
||||
return json(res, { success: false, msg: 'Invalid pubkey' }, 400);
|
||||
}
|
||||
|
||||
// Basic JWK validation — must look like JSON with kty field
|
||||
try {
|
||||
const parsed = JSON.parse(pubkey);
|
||||
if (!parsed.kty || parsed.kty !== 'EC' || parsed.crv !== 'P-256') {
|
||||
return json(res, { success: false, msg: 'Invalid key type — expected EC P-256' }, 400);
|
||||
}
|
||||
} catch {
|
||||
return json(res, { success: false, msg: 'Invalid JSON' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO user_pubkeys ${db({ user_id: req.session.id, pubkey, fingerprint, updated_at: db`NOW()` })}
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET pubkey = EXCLUDED.pubkey,
|
||||
fingerprint = EXCLUDED.fingerprint,
|
||||
updated_at = NOW()
|
||||
`;
|
||||
return json(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[DM] pubkey upsert failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch a user's public key by user ID
|
||||
router.get(/\/api\/dm\/pubkey\/(?<userId>\d+)/, async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const userId = parseInt(req.params.userId, 10);
|
||||
try {
|
||||
const rows = await db`SELECT pubkey, fingerprint FROM user_pubkeys WHERE user_id = ${userId}`;
|
||||
if (!rows.length) return json(res, { success: false, msg: 'No key registered for this user' }, 404);
|
||||
return json(res, { success: true, pubkey: rows[0].pubkey, fingerprint: rows[0].fingerprint });
|
||||
} catch (err) {
|
||||
console.error('[DM] pubkey fetch failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Key Vault (seed-phrase encrypted private key backup) ────────────────
|
||||
|
||||
// Upload or replace the encrypted private key blob
|
||||
router.post('/api/dm/keyvault', async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const body = req.post || {};
|
||||
const { salt, iv, ciphertext } = body;
|
||||
|
||||
if (!salt || !iv || !ciphertext ||
|
||||
typeof salt !== 'string' || typeof iv !== 'string' || typeof ciphertext !== 'string') {
|
||||
return json(res, { success: false, msg: 'Missing salt, iv or ciphertext' }, 400);
|
||||
}
|
||||
// Length guards — salt: 24 chars (16 bytes b64), iv: 16 chars (12 bytes b64), ciphertext: privkey JWK is ~200 bytes + AES overhead → max 512
|
||||
if (salt.length > 32 || iv.length > 24 || ciphertext.length > 1024) {
|
||||
return json(res, { success: false, msg: 'Payload out of bounds' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO user_dm_keyvault ${db({ user_id: req.session.id, salt, iv, ciphertext, updated_at: db`NOW()` })}
|
||||
ON CONFLICT (user_id) DO UPDATE
|
||||
SET salt = EXCLUDED.salt,
|
||||
iv = EXCLUDED.iv,
|
||||
ciphertext = EXCLUDED.ciphertext,
|
||||
updated_at = NOW()
|
||||
`;
|
||||
return json(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[DM] keyvault upsert failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch the encrypted blob for the current user
|
||||
router.get('/api/dm/keyvault', async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
try {
|
||||
const rows = await db`SELECT salt, iv, ciphertext, version FROM user_dm_keyvault WHERE user_id = ${req.session.id}`;
|
||||
if (!rows.length) return json(res, { success: false, msg: 'No vault found' }, 404);
|
||||
return json(res, { success: true, vault: rows[0] });
|
||||
} catch (err) {
|
||||
console.error('[DM] keyvault fetch failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete the vault entry (user wants to reset)
|
||||
router.delete('/api/dm/keyvault', async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
try {
|
||||
await db`DELETE FROM user_dm_keyvault WHERE user_id = ${req.session.id}`;
|
||||
return json(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[DM] keyvault delete failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Conversations List ──────────────────────────────────────────────────
|
||||
|
||||
// Returns the list of unique users the current user has exchanged messages with,
|
||||
// plus last message metadata (no ciphertext exposed unnecessarily here — only metadata)
|
||||
router.get('/api/dm/conversations', async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
try {
|
||||
const rows = await db`
|
||||
WITH convos AS (
|
||||
SELECT
|
||||
CASE WHEN sender_id = ${req.session.id} THEN recipient_id ELSE sender_id END AS other_id,
|
||||
MAX(id) AS last_msg_id
|
||||
FROM private_messages
|
||||
WHERE sender_id = ${req.session.id} OR recipient_id = ${req.session.id}
|
||||
GROUP BY other_id
|
||||
),
|
||||
unread_counts AS (
|
||||
SELECT sender_id AS other_id, COUNT(*) AS unread
|
||||
FROM private_messages
|
||||
WHERE recipient_id = ${req.session.id} AND is_read = false
|
||||
GROUP BY sender_id
|
||||
)
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.user AS username,
|
||||
uo.avatar,
|
||||
uo.avatar_file,
|
||||
uo.username_color,
|
||||
uo.display_name,
|
||||
pm.created_at AS last_message_at,
|
||||
COALESCE(uc.unread, 0) AS unread_count
|
||||
FROM convos c
|
||||
JOIN "user" u ON u.id = c.other_id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
JOIN private_messages pm ON pm.id = c.last_msg_id
|
||||
LEFT JOIN unread_counts uc ON uc.other_id = c.other_id
|
||||
LEFT JOIN user_conversation_states ucs ON ucs.user_id = ${req.session.id} AND ucs.other_id = c.other_id
|
||||
WHERE (ucs.is_hidden IS NULL OR ucs.is_hidden = false)
|
||||
ORDER BY pm.created_at DESC
|
||||
`;
|
||||
return json(res, { success: true, conversations: rows });
|
||||
} catch (err) {
|
||||
console.error('[DM] conversations failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Message Thread ──────────────────────────────────────────────────────
|
||||
|
||||
// Fetch paginated messages between current user and another user
|
||||
// Supports ?before=id (older pages) and ?after=id (live updates since last known msg)
|
||||
router.get(/\/api\/dm\/thread\/(?<userId>\d+)/, async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const otherId = parseInt(req.params.userId, 10);
|
||||
const limit = 50;
|
||||
const before = req.url.qs?.before ? parseInt(req.url.qs.before, 10) : null;
|
||||
const after = req.url.qs?.after ? parseInt(req.url.qs.after, 10) : null;
|
||||
|
||||
// Validate other user exists
|
||||
const other = await db`SELECT id, user FROM "user" WHERE id = ${otherId} LIMIT 1`;
|
||||
if (!other.length) return json(res, { success: false, msg: 'User not found' }, 404);
|
||||
|
||||
try {
|
||||
// Unhide for the viewing user
|
||||
await db`
|
||||
INSERT INTO user_conversation_states (user_id, other_id, is_hidden)
|
||||
VALUES (${req.session.id}, ${otherId}, false)
|
||||
ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = false
|
||||
`;
|
||||
|
||||
const messages = await db`
|
||||
SELECT
|
||||
pm.id,
|
||||
pm.sender_id,
|
||||
pm.recipient_id,
|
||||
pm.ciphertext,
|
||||
pm.iv,
|
||||
pm.is_read,
|
||||
pm.created_at
|
||||
FROM private_messages pm
|
||||
WHERE (
|
||||
(pm.sender_id = ${req.session.id} AND pm.recipient_id = ${otherId})
|
||||
OR
|
||||
(pm.sender_id = ${otherId} AND pm.recipient_id = ${req.session.id})
|
||||
)
|
||||
${after ? db`AND pm.id > ${after}` : db``}
|
||||
${before ? db`AND pm.id < ${before}` : db``}
|
||||
ORDER BY pm.id ${after ? db`ASC` : db`DESC`}
|
||||
LIMIT ${after ? 200 : limit + 1}
|
||||
`;
|
||||
|
||||
// Only applies to paginated (before) fetches
|
||||
const hasMore = !after && messages.length > limit;
|
||||
if (hasMore) messages.pop();
|
||||
|
||||
return json(res, {
|
||||
success: true,
|
||||
messages: after ? messages : messages.reverse(),
|
||||
hasMore,
|
||||
other: other[0]
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[DM] thread fetch failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Send a message (store ciphertext)
|
||||
router.post(/\/api\/dm\/send\/(?<userId>\d+)/, async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const recipientId = parseInt(req.params.userId, 10);
|
||||
if (recipientId === req.session.id) return json(res, { success: false, msg: "Can't DM yourself" }, 400);
|
||||
|
||||
const body = req.post || {};
|
||||
const { ciphertext, iv } = body;
|
||||
|
||||
if (!ciphertext || !iv || typeof ciphertext !== 'string' || typeof iv !== 'string') {
|
||||
return json(res, { success: false, msg: 'Missing ciphertext or iv' }, 400);
|
||||
}
|
||||
// Sanity length bounds: ciphertext max ~64 KB encoded, iv is 16 chars (12 bytes base64url)
|
||||
if (ciphertext.length > 65536 || iv.length > 32) {
|
||||
return json(res, { success: false, msg: 'Payload too large' }, 413);
|
||||
}
|
||||
|
||||
// Verify recipient exists
|
||||
const recip = await db`SELECT id FROM "user" WHERE id = ${recipientId} LIMIT 1`;
|
||||
if (!recip.length) return json(res, { success: false, msg: 'Recipient not found' }, 404);
|
||||
|
||||
try {
|
||||
const msg = await db.begin(async sql => {
|
||||
const [m] = await sql`
|
||||
INSERT INTO private_messages ${db({
|
||||
sender_id: req.session.id,
|
||||
recipient_id: recipientId,
|
||||
ciphertext,
|
||||
iv
|
||||
})}
|
||||
RETURNING id, created_at
|
||||
`;
|
||||
|
||||
// Unhide for both parties
|
||||
await sql`
|
||||
INSERT INTO user_conversation_states (user_id, other_id, is_hidden)
|
||||
VALUES (${req.session.id}, ${recipientId}, false), (${recipientId}, ${req.session.id}, false)
|
||||
ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = false
|
||||
`;
|
||||
return m;
|
||||
});
|
||||
|
||||
// Notify recipient via SSE (pg_notify wakes the db.listen in notifications.mjs)
|
||||
await db`SELECT pg_notify('private_message', ${JSON.stringify({
|
||||
id: msg.id,
|
||||
sender_id: req.session.id,
|
||||
recipient_id: recipientId,
|
||||
created_at: msg.created_at
|
||||
})})`;
|
||||
|
||||
return json(res, { success: true, id: msg.id, created_at: msg.created_at });
|
||||
} catch (err) {
|
||||
console.error('[DM] send failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark a conversation as read
|
||||
router.post(/\/api\/dm\/read\/(?<userId>\d+)/, async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const otherId = parseInt(req.params.userId, 10);
|
||||
try {
|
||||
await db`UPDATE private_messages
|
||||
SET is_read = true
|
||||
WHERE recipient_id = ${req.session.id} AND sender_id = ${otherId} AND is_read = false`;
|
||||
return json(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[DM] read mark failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Hide a whole conversation (Close DM)
|
||||
router.post(/\/api\/dm\/conversation\/(?<userId>\d+)\/delete/, async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const otherId = parseInt(req.params.userId, 10);
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO user_conversation_states (user_id, other_id, is_hidden)
|
||||
VALUES (${req.session.id}, ${otherId}, true)
|
||||
ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = true
|
||||
`;
|
||||
return json(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[DM] hide conversation failed:', err);
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Total unread DM count (for navbar badge polling)
|
||||
router.get('/api/dm/unread', async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: true, count: 0 });
|
||||
if (!req.session) return json(res, { success: false, count: 0 }, 401);
|
||||
try {
|
||||
const [row] = await db`
|
||||
SELECT COUNT(*) AS count FROM private_messages
|
||||
WHERE recipient_id = ${req.session.id} AND is_read = false
|
||||
`;
|
||||
return json(res, { success: true, count: parseInt(row.count, 10) });
|
||||
} catch (err) {
|
||||
return json(res, { success: false, count: 0 }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Resolve username → user_id (needed for compose from user profile)
|
||||
router.get(/\/api\/dm\/resolve\/(?<username>[^/]+)/, async (req, res) => {
|
||||
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
|
||||
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
const uname = decodeURIComponent(req.params.username);
|
||||
try {
|
||||
const rows = await db`SELECT id, user FROM "user" WHERE login = ${uname.toLowerCase()} LIMIT 1`;
|
||||
if (!rows.length) return json(res, { success: false, msg: 'User not found' }, 404);
|
||||
return json(res, { success: true, user_id: rows[0].id, username: rows[0].user });
|
||||
} catch (err) {
|
||||
return json(res, { success: false, msg: 'DB error' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Pages ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Inbox page
|
||||
router.get('/messages', async (req, res) => {
|
||||
if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' });
|
||||
if (!req.session) return res.redirect('/login');
|
||||
return res.html(tpl.render('messages', {
|
||||
session: req.session,
|
||||
hidePagination: true,
|
||||
link: { main: '/messages', path: '/' },
|
||||
domain: cfg.main.url.domain
|
||||
}, req));
|
||||
});
|
||||
|
||||
// Conversation page — /messages/:username
|
||||
router.get(/\/messages\/(?<username>[^/]+)/, async (req, res) => {
|
||||
if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' });
|
||||
if (!req.session) return res.redirect('/login');
|
||||
const uname = decodeURIComponent(req.params.username);
|
||||
const rows = await db`
|
||||
SELECT u.id, u.user, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
|
||||
FROM "user" u LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE u.login = ${uname.toLowerCase()} LIMIT 1
|
||||
`;
|
||||
if (!rows.length) return res.reply({ code: 404, body: 'User not found' });
|
||||
const other = rows[0];
|
||||
|
||||
return res.html(tpl.render('messages-conversation', {
|
||||
session: req.session,
|
||||
other,
|
||||
hidePagination: true,
|
||||
link: { main: '/messages', path: '/' },
|
||||
domain: cfg.main.url.domain
|
||||
}, req));
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
805
src/inc/routes/mod.mjs
Normal file
805
src/inc/routes/mod.mjs
Normal file
@@ -0,0 +1,805 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import cfg from "../config.mjs";
|
||||
import fetch from "flumm-fetch";
|
||||
import https from "https";
|
||||
import path from "path";
|
||||
import { getManualApproval } from "../settings.mjs";
|
||||
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
|
||||
import { setMotd } from "../motd.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Moderator Dashboard
|
||||
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
|
||||
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
|
||||
|
||||
|
||||
res.reply({
|
||||
body: tpl.render("mod", {
|
||||
session: req.session,
|
||||
pendingCount: parseInt(pendingCount),
|
||||
manualApproval: getManualApproval(),
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Moderator Reports View
|
||||
router.get(/^\/mod\/reports\/?$/, lib.modAuth, async (req, res) => {
|
||||
res.reply({
|
||||
body: tpl.render("mod_reports", {
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Approval Queue (Ported/Shared from Admin)
|
||||
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||
// Quick Approve Action
|
||||
if (req.url.qs?.id) {
|
||||
const id = +req.url.qs.id;
|
||||
const f0ck = await db`
|
||||
select i.dest, i.mime, i.username, i.id, ta.tag_id
|
||||
from "items" i
|
||||
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
|
||||
where i.id = ${id} and i.active = false
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
return res.reply({ body: `f0ck ${id}: f0ck not found` });
|
||||
}
|
||||
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
const uploader = await db`select id, "user" as username from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
|
||||
}
|
||||
} catch (err) { }
|
||||
|
||||
// ACTION: Approve
|
||||
// We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
|
||||
// This prevents duplicate webhooks from double-clicks or race conditions.
|
||||
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
|
||||
|
||||
if (result.count === 1) {
|
||||
await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
|
||||
|
||||
// Notify User (WebSocket/Internal)
|
||||
try {
|
||||
const uploader = await db`select id from "user" where login = ${f0ck[0].username} or "user" = ${f0ck[0].username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
await db`
|
||||
INSERT INTO notifications (user_id, type, reference_id, item_id)
|
||||
VALUES (${uploader[0].id}, 'approve', 0, ${id})
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Failed to notify user:', err);
|
||||
}
|
||||
|
||||
// Push to Discord Webhook (Direct)
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
const payload = JSON.stringify({ content: message });
|
||||
const url = new URL(discordClient.webhook_url);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
};
|
||||
const reqDiscord = https.request(options, (resDiscord) => {
|
||||
if (resDiscord.statusCode >= 400) {
|
||||
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
|
||||
}
|
||||
});
|
||||
reqDiscord.on('error', (err) => {
|
||||
console.error('[MOD APPROVE] Webhook failed:', err);
|
||||
});
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Discord Webhook error:', err);
|
||||
}
|
||||
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
|
||||
const clients = await Promise.all(router.self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: id,
|
||||
dest: f0ck[0].dest,
|
||||
mime: f0ck[0].mime,
|
||||
username: f0ck[0].username,
|
||||
tag_id: f0ck[0].tag_id,
|
||||
is_oc: !!f0ck[0].is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Move files to public location
|
||||
const movePaths = [
|
||||
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
||||
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
||||
];
|
||||
|
||||
for (const p of movePaths) {
|
||||
try {
|
||||
await fs.access(p.b);
|
||||
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
||||
|
||||
const moveSafe = async (src, dst) => {
|
||||
try {
|
||||
const lstat = await fs.lstat(src);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(src);
|
||||
const absTarget = path.resolve(path.dirname(src), target);
|
||||
const relTarget = path.relative(path.dirname(dst), absTarget);
|
||||
await fs.symlink(relTarget, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
} else {
|
||||
await fs.copyFile(src, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
|
||||
const tDst = path.join(cfg.paths.t, `${id}.webp`);
|
||||
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
||||
|
||||
await moveSafe(p.b, bDst);
|
||||
await moveSafe(p.t, tDst);
|
||||
|
||||
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
||||
await moveSafe(blurSrc, blurDst);
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await moveSafe(p.ca, caDst);
|
||||
}
|
||||
break;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
return res.writeHead(302, { "Location": `/${id}` }).end();
|
||||
}
|
||||
|
||||
// View Queue
|
||||
const page = +req.url.qs.page || 1;
|
||||
const limit = 20;
|
||||
// Fetch Pending (not deleted)
|
||||
const pending = await db`
|
||||
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) 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
|
||||
limit ${limit} offset ${(page - 1) * limit}
|
||||
`;
|
||||
|
||||
// Fetch Trash (deleted)
|
||||
const trash = await db`
|
||||
select i.id, i.mime, i.username, i.dest,
|
||||
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
|
||||
(select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason
|
||||
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 and i.is_purged = false
|
||||
group by i.id
|
||||
order by i.id desc
|
||||
limit 20
|
||||
`;
|
||||
|
||||
const processItems = (items) => {
|
||||
return items.map(p => {
|
||||
const tags = (p.tags || [])
|
||||
.filter(t => t.tag !== null)
|
||||
.map(t => {
|
||||
let badge = "badge-light";
|
||||
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
|
||||
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
|
||||
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
|
||||
else if (t.normalized === "german") badge = "badge-german badge-light";
|
||||
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
|
||||
else if (t.normalized === "sfw") badge = "badge-success";
|
||||
else if (t.normalized === "nsfw") badge = "badge-danger";
|
||||
|
||||
return { ...t, badge };
|
||||
});
|
||||
|
||||
return {
|
||||
...p,
|
||||
tags
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('mod/approve', {
|
||||
pending: processItems(pending),
|
||||
trash: processItems(trash),
|
||||
page,
|
||||
stats: { total: pending.length + trash.length },
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Deny / Delete Item
|
||||
router.get(/^\/mod\/deny\/?/, lib.modAuth, async (req, res) => {
|
||||
if (!req.url.qs?.id) return res.reply({ success: false, msg: "No ID provided" });
|
||||
const id = +req.url.qs.id;
|
||||
|
||||
const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${id} limit 1`;
|
||||
if (f0ck.length > 0) {
|
||||
const item = f0ck[0];
|
||||
|
||||
if (item.is_deleted) {
|
||||
// PURGE LOGIC (Strict Admin)
|
||||
if (!req.session.admin) {
|
||||
return res.reply({ success: false, msg: "Only admins can purge items permanently." });
|
||||
}
|
||||
|
||||
// Delete files — respect symlink ownership
|
||||
await safeDeleteMediaFile(item.dest, id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
|
||||
}
|
||||
|
||||
// DB Flag instead of delete
|
||||
await db`update "items" set is_purged = true where id = ${id}`;
|
||||
// Delete comments permanently on purge
|
||||
await db`delete from comments where item_id = ${id}`;
|
||||
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
|
||||
}
|
||||
} catch (err) { }
|
||||
|
||||
await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, ...uploaderInfo });
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
const body = JSON.stringify({ success: true, action: "purge", msg: "Item purged" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
return res.reply({ success: true, action: "purge" });
|
||||
} else {
|
||||
// DENY LOGIC (Move to trash)
|
||||
const movePaths = [
|
||||
{ b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
||||
{ b: path.join(cfg.paths.b, item.dest), t: path.join(cfg.paths.t, `${id}.webp`), ca: path.join(cfg.paths.ca, `${id}.webp`) }
|
||||
];
|
||||
|
||||
for (const p of movePaths) {
|
||||
try {
|
||||
await fs.access(p.b);
|
||||
// Use safe move that handles symlink ownership
|
||||
await moveToDeleted(item.dest, id);
|
||||
await fs.copyFile(p.t, path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(p.t).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.copyFile(p.ca, path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(p.ca).catch(() => { });
|
||||
}
|
||||
break;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
const reason = req.url.qs?.reason || "Denied by moderator";
|
||||
|
||||
await db`update "items" set is_deleted = true, active = false where id = ${id}`;
|
||||
|
||||
// Fetch uploader details for audit log and notification
|
||||
let uploaderId = null;
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
uploaderId = uploader[0].id;
|
||||
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
|
||||
|
||||
// Send Notification to User
|
||||
await db`
|
||||
INSERT INTO notifications (user_id, type, reference_id, item_id, data)
|
||||
VALUES (${uploaderId}, 'deny', 0, ${id}, ${db.json({ reason })})
|
||||
`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[MOD DENY] Failed to notify uploader:', err);
|
||||
}
|
||||
|
||||
await audit.log(req.session.id, 'deny_item', 'item', id, { filename: item.dest, reason, ...uploaderInfo });
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
const body = JSON.stringify({ success: true, action: "deny", msg: "Item denied" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
return res.reply({ success: true, action: "deny" });
|
||||
}
|
||||
}
|
||||
return res.reply({ success: false, msg: "Item not found" });
|
||||
});
|
||||
|
||||
// Audit Log View
|
||||
router.get(/^\/mod\/audit\/?/, lib.modAuth, async (req, res) => {
|
||||
const page = +(req.url.qs?.page || 1);
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const logs = await db`
|
||||
SELECT al.*, u.user as username
|
||||
FROM audit_log al
|
||||
LEFT JOIN "user" u ON al.user_id = u.id
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const totalResult = await db`SELECT count(*) as c FROM audit_log`;
|
||||
const total = totalResult[0].c;
|
||||
const pages = Math.ceil(total / limit);
|
||||
|
||||
const processLogEntry = (l) => {
|
||||
const entry = { ...l };
|
||||
let details = l.details;
|
||||
if (typeof details === 'string') {
|
||||
try {
|
||||
details = JSON.parse(details);
|
||||
} catch (e) {
|
||||
details = {};
|
||||
}
|
||||
}
|
||||
|
||||
entry.created_at_fmt = new Date(l.created_at).toLocaleString();
|
||||
// Also keep standard created_at for client side
|
||||
entry.created_at = entry.created_at_fmt;
|
||||
|
||||
entry.reason = details ? details.reason : null;
|
||||
entry.old_content = details ? details.old_content : null;
|
||||
entry.new_content = details ? details.new_content : null;
|
||||
entry.item_id = details ? details.item_id : null;
|
||||
|
||||
if (entry.action === 'toggle_tag' && details) {
|
||||
if (details.action === 'added') {
|
||||
entry.new_content = details.tag;
|
||||
} else if (details.from) {
|
||||
entry.old_content = details.from;
|
||||
entry.new_content = details.tag;
|
||||
}
|
||||
}
|
||||
|
||||
if (details && details.uploader_name) {
|
||||
entry.uploader_info = `Uploader: ${details.uploader_name} (ID: ${details.uploader_id || '?'})`;
|
||||
}
|
||||
|
||||
let otherDetails = {};
|
||||
if (details && typeof details === 'object') {
|
||||
for (const [key, val] of Object.entries(details)) {
|
||||
const isStandard = ['reason', 'old_content', 'new_content', 'item_id', 'uploader_name', 'uploader_id'].includes(key);
|
||||
const isToggle = entry.action === 'toggle_tag' && ['tag', 'from', 'action'].includes(key);
|
||||
|
||||
if (!isStandard && !isToggle) {
|
||||
otherDetails[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
entry.details_json = Object.keys(otherDetails).length > 0 ? JSON.stringify(otherDetails) : null;
|
||||
|
||||
delete entry.details;
|
||||
|
||||
return entry;
|
||||
};
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
const processed = logs.map(processLogEntry);
|
||||
const body = JSON.stringify({
|
||||
success: true,
|
||||
logs: processed,
|
||||
page,
|
||||
pages,
|
||||
hasMore: page < pages
|
||||
});
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
const processedLogs = logs.map(processLogEntry);
|
||||
|
||||
res.reply({
|
||||
body: tpl.render("mod/audit", {
|
||||
session: req.session,
|
||||
logs: processedLogs,
|
||||
page,
|
||||
pages,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// Bulk Deny (POST)
|
||||
router.post(/^\/mod\/deny-multi\/?/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const ids = req.post.ids;
|
||||
if (!Array.isArray(ids)) throw new Error('ids must be an array');
|
||||
let count = 0;
|
||||
for (const id of ids) {
|
||||
const f0ck = await db`select id, dest, mime, is_deleted, active, username from "items" where id = ${+id} limit 1`;
|
||||
if (f0ck.length > 0) {
|
||||
const item = f0ck[0];
|
||||
if (item.is_deleted) {
|
||||
// Purge (Strict Admin)
|
||||
if (!req.session.admin) continue;
|
||||
|
||||
// Purge — respect symlink ownership
|
||||
await safeDeleteMediaFile(item.dest, +id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
await db`update "items" set is_purged = true where id = ${+id}`;
|
||||
// Delete comments permanently on purge
|
||||
await db`delete from comments where item_id = ${+id}`;
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
|
||||
}
|
||||
} catch (err) { }
|
||||
|
||||
await audit.log(req.session.id, 'purge_item_multi', 'item', +id, { filename: item.dest, ...uploaderInfo });
|
||||
} else {
|
||||
// Deny (Move to trash)
|
||||
// Deny — safe move respecting symlink ownership
|
||||
await moveToDeleted(item.dest, +id);
|
||||
await fs.copyFile(path.join(cfg.paths.t, `${item.id}.webp`), path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.copyFile(path.join(cfg.paths.ca, `${item.id}.webp`), path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
await db`update "items" set is_deleted = true, active = false where id = ${+id}`;
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
const uploader = await db`select id, "user" as username from "user" where login = ${item.username} or "user" = ${item.username} limit 1`;
|
||||
if (uploader.length > 0) {
|
||||
uploaderInfo = { uploader_id: uploader[0].id, uploader_name: uploader[0].username };
|
||||
}
|
||||
} catch (err) { }
|
||||
|
||||
await audit.log(req.session.id, 'deny_item_multi', 'item', +id, { filename: item.dest, ...uploaderInfo });
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return res.reply({ success: true, count });
|
||||
} catch (err) {
|
||||
return res.reply({ success: false, msg: lib.logError(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Serve pending files (Stream with Range support)
|
||||
// Supports /mod/pending/b/filename.ext (Binaries)
|
||||
// Supports /mod/pending/t/id.webp (Thumbnails)
|
||||
router.get(/^\/mod\/pending\/(?<type>[btca])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
|
||||
const { type, file } = req.params;
|
||||
const filePath = path.join(cfg.paths.pending, type, file);
|
||||
|
||||
try {
|
||||
const stats = await fs.stat(filePath);
|
||||
const fileSize = stats.size;
|
||||
const range = req.headers.range;
|
||||
const ext = file.split('.').pop();
|
||||
const mimeType = {
|
||||
'mp4': 'video/mp4', 'webm': 'video/webm',
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
|
||||
}[ext] || 'application/octet-stream';
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
const fileStream = (await import('fs')).createReadStream(filePath, { start, end });
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': mimeType,
|
||||
});
|
||||
fileStream.pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': mimeType,
|
||||
});
|
||||
(await import('fs')).createReadStream(filePath).pipe(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.writeHead(404).end('File not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Serve deleted files (Stream with Range support)
|
||||
// Supports /mod/deleted/b/filename.ext (Binaries)
|
||||
// Supports /mod/deleted/t/id.webp (Thumbnails)
|
||||
router.get(/^\/mod\/deleted\/(?<type>[bt])\/(?<file>.+)/, lib.modAuth, async (req, res) => {
|
||||
const file = decodeURIComponent(req.params.file);
|
||||
const type = req.params.type; // 'b' or 't'
|
||||
console.log(`[MOD_STREAM] Request: type=${type}, file=${file}, range=${req.headers.range || 'none'}`);
|
||||
const filePath = path.join(cfg.paths.deleted, type, file);
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
const fileSize = stat.size;
|
||||
const range = req.headers.range;
|
||||
const ext = file.split('.').pop();
|
||||
const mimeType = {
|
||||
'mp4': 'video/mp4', 'webm': 'video/webm',
|
||||
'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
|
||||
'png': 'image/png', 'gif': 'image/gif', 'webp': 'image/webp'
|
||||
}[ext] || 'application/octet-stream';
|
||||
|
||||
if (range) {
|
||||
const parts = range.replace(/bytes=/, "").split("-");
|
||||
const start = parseInt(parts[0], 10);
|
||||
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
|
||||
const chunksize = (end - start) + 1;
|
||||
const fileStream = (await import('fs')).createReadStream(filePath, { start, end });
|
||||
|
||||
res.writeHead(206, {
|
||||
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Length': chunksize,
|
||||
'Content-Type': mimeType,
|
||||
});
|
||||
fileStream.pipe(res);
|
||||
} else {
|
||||
res.writeHead(200, {
|
||||
'Content-Length': fileSize,
|
||||
'Content-Type': mimeType,
|
||||
});
|
||||
(await import('fs')).createReadStream(filePath).pipe(res);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.writeHead(404).end('File not found');
|
||||
}
|
||||
});
|
||||
|
||||
// Purge Trash (POST) - Strict Admin
|
||||
router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
// lib.auth already ensures session.admin
|
||||
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
|
||||
let count = 0;
|
||||
for (const item of trash) {
|
||||
try {
|
||||
await safeDeleteMediaFile(item.dest, item.id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
|
||||
await db`update "items" set is_purged = true where id = ${item.id}`;
|
||||
// Delete comments permanently on purge
|
||||
await db`delete from comments where item_id = ${item.id}`;
|
||||
count++;
|
||||
} catch (e) { }
|
||||
}
|
||||
await audit.log(req.session.id, 'purge_trash', 'system', 0, { count });
|
||||
const body = JSON.stringify({ success: true, count });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
} catch (err) {
|
||||
const body = JSON.stringify({ success: false, msg: err.message });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
});
|
||||
|
||||
// MOTD Routes
|
||||
router.get(/^\/mod\/motd\/?$/, lib.modAuth, async (req, res) => {
|
||||
const settings = await db`SELECT value FROM site_settings WHERE key = 'motd' LIMIT 1`;
|
||||
const motd = settings.length > 0 ? settings[0].value : '';
|
||||
res.reply({
|
||||
body: tpl.render("mod/motd", {
|
||||
session: req.session,
|
||||
motd: motd,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
router.post(/^\/mod\/motd\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
let motd = (req.post?.motd ?? '');
|
||||
|
||||
if (motd.trim().length > 0) {
|
||||
// Append .t name suffix (display_name if set, otherwise username)
|
||||
const name = req.session.display_name || req.session.user;
|
||||
const suffix = ` t. ${name}`;
|
||||
if (!motd.endsWith(suffix)) {
|
||||
motd = motd.trim() + suffix;
|
||||
}
|
||||
}
|
||||
|
||||
await db`INSERT INTO site_settings (key, value) VALUES ('motd', ${motd}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
|
||||
|
||||
setMotd(motd);
|
||||
|
||||
// Log it in audit
|
||||
await audit.log(req.session.id, 'update_motd', 'system', 0, { motd });
|
||||
|
||||
try {
|
||||
await db`SELECT pg_notify('motd', ${motd ? motd.substring(0, 7000) : ''})`;
|
||||
} catch (e) {
|
||||
console.error('[MOD MOTD] Notify failed:', e.message);
|
||||
}
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
const body = JSON.stringify({ success: true, motd });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
return res.writeHead(302, { "Location": "/mod/motd" }).end();
|
||||
} catch (err) {
|
||||
console.error('[MOD MOTD] Save failed:', err);
|
||||
const msg = 'Failed to save MOTD: ' + err.message;
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
const body = JSON.stringify({ success: false, msg });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
return res.reply({ code: 500, body: msg });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Pin / Unpin Item
|
||||
router.post(/^\/mod\/pin\/?/, lib.modAuth, async (req, res) => {
|
||||
if (!req.url.qs?.id) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "No ID provided" }));
|
||||
const id = +req.url.qs.id;
|
||||
|
||||
const result = await db`update "items" set is_pinned = true where id = ${id} and active = true`;
|
||||
if (result.count === 1) {
|
||||
await audit.log(req.session.id, 'pin_item', 'item', id);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, pinned: true }));
|
||||
}
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Item not found or not active" }));
|
||||
});
|
||||
|
||||
router.post(/^\/mod\/unpin\/?/, lib.modAuth, async (req, res) => {
|
||||
if (!req.url.qs?.id) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "No ID provided" }));
|
||||
const id = +req.url.qs.id;
|
||||
|
||||
const result = await db`update "items" set is_pinned = false where id = ${id}`;
|
||||
if (result.count === 1) {
|
||||
await audit.log(req.session.id, 'unpin_item', 'item', id);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, pinned: false }));
|
||||
}
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Item not found" }));
|
||||
});
|
||||
|
||||
// Hall Management
|
||||
router.post(/^\/mod\/halls\/add\/?$/, lib.modAuth, async (req, res) => {
|
||||
const { id, hall, description } = req.post;
|
||||
if (!id || !hall) {
|
||||
const body = JSON.stringify({ success: false, msg: "Missing id or hall" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
const result = await f0cklib.addItemToHall(id, hall, req.session.id, description);
|
||||
if (result.success) {
|
||||
await audit.log(req.session.id, 'add_to_hall', 'item', +id, { hall, description });
|
||||
}
|
||||
const body = JSON.stringify({ success: result.success, msg: result.message });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
});
|
||||
|
||||
router.post(/^\/mod\/halls\/remove\/?$/, lib.modAuth, async (req, res) => {
|
||||
const { id, hall } = req.post;
|
||||
if (!id || !hall) {
|
||||
const body = JSON.stringify({ success: false, msg: "Missing id or hall" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
const result = await f0cklib.removeItemFromHall(id, hall);
|
||||
if (result.success) {
|
||||
await audit.log(req.session.id, 'remove_from_hall', 'item', +id, { hall });
|
||||
}
|
||||
const body = JSON.stringify({ success: result.success, msg: result.message });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
});
|
||||
|
||||
router.post(/^\/mod\/halls\/update\/?$/, lib.modAuth, async (req, res) => {
|
||||
const { hall, description } = req.post;
|
||||
if (!hall) {
|
||||
const body = JSON.stringify({ success: false, msg: "Missing hall slug" });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
const result = await f0cklib.updateHallMetadata(hall, description);
|
||||
if (result.success) {
|
||||
await audit.log(req.session.id, 'update_hall_metadata', 'hall', 0, { hall, description });
|
||||
}
|
||||
const body = JSON.stringify({ success: result.success, msg: result.message });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
});
|
||||
|
||||
// Hall Manager page (also accessible by mods)
|
||||
router.get(/^\/mod\/halls\/?$/, lib.modAuth, async (req, res) => {
|
||||
const hallsList = await f0cklib.getHalls();
|
||||
res.reply({
|
||||
body: tpl.render('admin/halls', {
|
||||
session: req.session,
|
||||
hallsList,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
581
src/inc/routes/notifications.mjs
Normal file
581
src/inc/routes/notifications.mjs
Normal file
@@ -0,0 +1,581 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { setMotd } from "../motd.mjs";
|
||||
|
||||
export const clients = new Set();
|
||||
const activeTabs = new Map(); // sessionId -> tabId
|
||||
|
||||
function pruneInactiveClients(sessionId, currentTabId) {
|
||||
for (const client of clients) {
|
||||
if (client.sessionId === sessionId && client.tabId !== currentTabId) {
|
||||
console.log(`[SSE] Pruning inactive client ${client.tabId} for session ${sessionId}`);
|
||||
client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global listener for notifications
|
||||
db.listen('notifications', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
const userId = data.user_id;
|
||||
|
||||
for (const client of clients) {
|
||||
if (client.userId === userId) {
|
||||
client.send({ type: 'notify', data });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Notification broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen error:', err));
|
||||
|
||||
// Global listener for warnings
|
||||
db.listen('warnings', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting warning to user ${data.user_id}`);
|
||||
for (const client of clients) {
|
||||
if (client.userId === data.user_id) {
|
||||
client.send({ type: 'warning', data });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Warning broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen Warning error:', err));
|
||||
|
||||
// Global listener for profile updates (display name changes etc.)
|
||||
db.listen('profile_update', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
for (const client of clients) {
|
||||
if (client.userId === data.user_id) {
|
||||
client.send({ type: 'profile_update', data });
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Profile update broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen Profile Update error:', err));
|
||||
|
||||
// Global listener for activity
|
||||
db.listen('activity', async (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
// We need the username, avatar, and item mime for the preview
|
||||
// trigger only gave us user_id and item_id
|
||||
const [details] = await db`
|
||||
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime,
|
||||
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
LEFT JOIN items i ON i.id = ${data.item_id}
|
||||
WHERE u.id = ${data.user_id}
|
||||
`;
|
||||
|
||||
if (details) {
|
||||
data.username = details.username;
|
||||
data.avatar = details.avatar;
|
||||
data.avatar_file = details.avatar_file;
|
||||
data.mime = details.mime;
|
||||
data.username_color = details.username_color;
|
||||
data.display_name = details.display_name || null;
|
||||
data.tag_id = details.tag_id;
|
||||
} else {
|
||||
data.username = 'System';
|
||||
}
|
||||
|
||||
// Broadcast to ALL connected clients
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'activity', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Activity broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen Activity error:', err));
|
||||
|
||||
// Global listener for tags
|
||||
db.listen('tags', async (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting tag update for item ${data.item_id} to ${clients.size} clients`);
|
||||
// Broadcast to ALL connected clients
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'tags', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Tag broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen Tag error:', err));
|
||||
|
||||
// Global listener for comments
|
||||
db.listen('comments', async (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting comment update (${data.type}) for item ${data.item_id} to ${clients.size} clients`);
|
||||
// Broadcast to ALL connected clients
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'comments', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Comment broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen Comment error:', err));
|
||||
|
||||
// Global listener for favorites
|
||||
db.listen('favorites', async (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting favorite update for item ${data.item_id} to ${clients.size} clients`);
|
||||
// Broadcast to ALL connected clients
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'favorites', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Favorite broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen Favorite error:', err));
|
||||
|
||||
// Global listener for MOTD
|
||||
db.listen('motd', (payload) => {
|
||||
try {
|
||||
console.log(`[SSE] Broadcasting MOTD update to ${clients.size} clients`);
|
||||
setMotd(payload);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'motd', data: { motd: payload } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('MOTD broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen MOTD error:', err));
|
||||
|
||||
// Global listener for new items (live grid updates)
|
||||
db.listen('new_item', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'new_item', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('New item broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen new_item error:', err));
|
||||
|
||||
// Global listener for item deletions — broadcasts to all clients so they can remove the item live
|
||||
db.listen('delete_item', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting delete_item (id: ${data.id}) to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'delete_item', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Delete item broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen delete_item error:', err));
|
||||
|
||||
// Global listener for emoji updates
|
||||
db.listen('emojis_updated', () => {
|
||||
try {
|
||||
console.log(`[SSE] Broadcasting emojis_updated to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'emojis_updated' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Emoji update broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen emojis_updated error:', err));
|
||||
|
||||
// Global listener for private messages — deliver only to the recipient
|
||||
db.listen('private_message', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
// Only send to the recipient — sender already knows they sent it
|
||||
for (const client of clients) {
|
||||
if (client.userId === data.recipient_id) {
|
||||
client.send({ type: 'private_message', data: {
|
||||
id: data.id,
|
||||
sender_id: data.sender_id,
|
||||
created_at: data.created_at
|
||||
}});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Private message broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen private_message error:', err));
|
||||
|
||||
// Global listener for global chat messages
|
||||
db.listen('global_chat', async (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
// Enrich with user info
|
||||
const [user] = await db`
|
||||
SELECT u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
WHERE u.id = ${data.user_id}
|
||||
`;
|
||||
if (user) {
|
||||
data.username = user.username;
|
||||
data.avatar = user.avatar;
|
||||
data.avatar_file = user.avatar_file;
|
||||
data.username_color = user.username_color;
|
||||
data.display_name = user.display_name || null;
|
||||
}
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'global_chat', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Global chat broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen global_chat error:', err));
|
||||
|
||||
// Global listener for chat clear — broadcast to all clients immediately
|
||||
db.listen('global_chat_clear', () => {
|
||||
try {
|
||||
console.log(`[SSE] Broadcasting global_chat_clear to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'global_chat_clear' });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Global chat clear broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen global_chat_clear error:', err));
|
||||
|
||||
// Global listener for single chat message deletion
|
||||
db.listen('global_chat_delete', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting global_chat_delete (id: ${data.id}) to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'global_chat_delete', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Global chat delete broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen global_chat_delete error:', err));
|
||||
|
||||
// Global listener for chat panel background changes
|
||||
db.listen('global_chat_background', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting global_chat_background to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'global_chat_background', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Global chat background broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen global_chat_background error:', err));
|
||||
|
||||
// Global listener for chat topic changes
|
||||
db.listen('global_chat_topic', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'global_chat_topic', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Global chat topic broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen global_chat_topic error:', err));
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
async function getNotificationHistory(userId, page = 1, limit = 50) {
|
||||
const offset = (page - 1) * limit;
|
||||
const notifications = await db`
|
||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
uo.username_color,
|
||||
i.dest, i.mime
|
||||
FROM notifications n
|
||||
LEFT JOIN comments c ON n.reference_id = c.id
|
||||
LEFT JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
LEFT JOIN items i ON n.item_id = i.id
|
||||
WHERE n.user_id = ${userId}
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT ${limit + 1}
|
||||
OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const hasMore = notifications.length > limit;
|
||||
if (hasMore) notifications.pop();
|
||||
|
||||
// Pre-process for template
|
||||
const processed = notifications.map(n => {
|
||||
let reason = 'No reason provided';
|
||||
if (n.data) {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, reason };
|
||||
});
|
||||
|
||||
return {
|
||||
notifications: processed,
|
||||
hasMore,
|
||||
page
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// 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, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
uo.username_color,
|
||||
i.dest, i.mime
|
||||
FROM notifications n
|
||||
LEFT JOIN comments c ON n.reference_id = c.id
|
||||
LEFT JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
LEFT JOIN items i ON n.item_id = i.id
|
||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 20
|
||||
`;
|
||||
|
||||
const processed = notifications.map(n => {
|
||||
let reason = 'No reason provided';
|
||||
if (n.data) {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, reason };
|
||||
});
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, notifications: processed })
|
||||
});
|
||||
} 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; charset=utf-8' },
|
||||
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;
|
||||
console.log(`[NotificationRoute] Marking notification ${id} as read for user ${req.session.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; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all notifications for a specific item as read
|
||||
// Used when the user receives a live notification while already viewing that item.
|
||||
// System-type notifications (item_deleted, deny, report, admin_pending) are excluded —
|
||||
// they require explicit user acknowledgment.
|
||||
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const itemId = req.params.itemId;
|
||||
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report'];
|
||||
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
|
||||
try {
|
||||
await db`
|
||||
UPDATE notifications
|
||||
SET is_read = true
|
||||
WHERE user_id = ${req.session.id}
|
||||
AND item_id = ${+itemId}
|
||||
AND NOT (type = ANY(${SYSTEM_TYPES}))
|
||||
`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// SSE Stream
|
||||
router.get('/api/notifications/stream', (req, res) => {
|
||||
const tabId = req.url.qs?.tabId || 'unknown';
|
||||
const sessionCookie = req.cookies?.session;
|
||||
const isGuest = !sessionCookie;
|
||||
|
||||
// Use session cookie as the primary identifier.
|
||||
// For guests, we use tabId to avoid IP-based pruning collisions (CGNAT).
|
||||
const sessionId = sessionCookie || `guest-${tabId}`;
|
||||
|
||||
// Pruning/Active logic only for logged-in users
|
||||
if (!isGuest) {
|
||||
const currentActive = activeTabs.get(sessionId);
|
||||
if (currentActive && currentActive !== tabId) {
|
||||
// Check if the current active tab is actually still connected
|
||||
const activeClient = Array.from(clients).find(c => c.sessionId === sessionId && c.tabId === currentActive);
|
||||
if (activeClient) {
|
||||
// console.log(`[SSE] Denying connection for inactive tab ${tabId} (Active: ${currentActive})`);
|
||||
res.writeHead(204); // No Content
|
||||
return res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Connection': 'keep-alive',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
'X-Accel-Buffering': 'no' // Prevent Nginx from buffering
|
||||
};
|
||||
|
||||
res.writeHead(200, headers);
|
||||
res.write(': ok\n\n'); // Warmup
|
||||
|
||||
const client = {
|
||||
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
|
||||
sessionId,
|
||||
tabId,
|
||||
send: (data) => {
|
||||
try {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
} catch (err) {
|
||||
// console.error('[SSE] Failed to send to client:', err.message);
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
try {
|
||||
res.end();
|
||||
} catch (err) {}
|
||||
}
|
||||
};
|
||||
|
||||
// Send any unacknowledged warnings on connection
|
||||
if (!isGuest && req.session?.id) {
|
||||
db`
|
||||
SELECT id, reason
|
||||
FROM user_warnings
|
||||
WHERE user_id = ${req.session.id} AND acknowledged = FALSE
|
||||
ORDER BY created_at ASC
|
||||
`.then(warnings => {
|
||||
warnings.forEach(warning => {
|
||||
client.send({
|
||||
type: 'warning',
|
||||
data: {
|
||||
warning_id: warning.id,
|
||||
reason: warning.reason
|
||||
}
|
||||
});
|
||||
});
|
||||
}).catch(e => console.error('[SSE] Failed to fetch initial warnings:', e));
|
||||
}
|
||||
|
||||
|
||||
// Set as active tab and prune others (only for logged-in users)
|
||||
if (!isGuest) {
|
||||
activeTabs.set(sessionId, tabId);
|
||||
pruneInactiveClients(sessionId, tabId);
|
||||
}
|
||||
|
||||
clients.add(client);
|
||||
|
||||
// Keep-alive ping
|
||||
const pingInterval = setInterval(() => {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch (e) {
|
||||
// Connection likely closed
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
res.on('close', () => {
|
||||
clearInterval(pingInterval);
|
||||
clients.delete(client);
|
||||
if (activeTabs.get(sessionId) === tabId) {
|
||||
// activeTabs.delete(sessionId); // Keep it set so we know who was last active
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Active Signal
|
||||
router.get('/api/notifications/active', (req, res) => {
|
||||
const tabId = req.url.qs?.tabId;
|
||||
const sessionId = req.cookies?.session;
|
||||
|
||||
// Only track active tabs for logged-in users
|
||||
if (tabId && sessionId) {
|
||||
console.log(`[SSE] Tab ${tabId} became active for session ${sessionId}`);
|
||||
activeTabs.set(sessionId, tabId);
|
||||
pruneInactiveClients(sessionId, tabId);
|
||||
return res.reply({ body: JSON.stringify({ success: true }) });
|
||||
}
|
||||
|
||||
// For guests, this is a no-op
|
||||
return res.reply({ body: JSON.stringify({ success: true }) });
|
||||
});
|
||||
|
||||
// Notification History Page
|
||||
router.get('/notifications', async (req, res) => {
|
||||
if (!req.session) return res.redirect('/login');
|
||||
const data = await getNotificationHistory(req.session.id, 1);
|
||||
data.session = req.session;
|
||||
data.hidePagination = true;
|
||||
data.pagination = {
|
||||
page: 1,
|
||||
next: data.hasMore ? 2 : null
|
||||
};
|
||||
data.link = { main: '/notifications', path: '/' };
|
||||
data.domain = cfg.main.url.domain; // For header
|
||||
return res.html(tpl.render('notifications', data, req));
|
||||
});
|
||||
|
||||
// AJAX Notification History
|
||||
router.get('/ajax/notifications', async (req, res) => {
|
||||
if (!req.session) return res.json({
|
||||
success: false
|
||||
}, 401);
|
||||
const page = parseInt(req.url.qs.page) || 1;
|
||||
const data = await getNotificationHistory(req.session.id, page);
|
||||
|
||||
const html = tpl.render('snippets/notifications-list', data, req);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
html,
|
||||
hasMore: data.hasMore,
|
||||
currentPage: page,
|
||||
nextPage: data.hasMore ? page + 1 : null
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
26
src/inc/routes/picdump.mjs
Normal file
26
src/inc/routes/picdump.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
import db from "../../inc/sql.mjs";
|
||||
import cfg from "../../inc/config.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/picdump$/, async (req, res) => {
|
||||
const dump = await db`
|
||||
SELECT *
|
||||
FROM items
|
||||
WHERE (
|
||||
to_timestamp(stamp) >= date_trunc('week', CURRENT_TIMESTAMP - interval '1 week') AND
|
||||
to_timestamp(stamp) < date_trunc('week', CURRENT_TIMESTAMP)
|
||||
) AND
|
||||
mime LIKE 'image/%'
|
||||
ORDER BY stamp DESC
|
||||
`;
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('picdump', {
|
||||
dump,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
return router;
|
||||
};
|
||||
70
src/inc/routes/random.mjs
Normal file
70
src/inc/routes/random.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import cfg from "../../inc/config.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/random$/, async (req, res) => {
|
||||
let referer = req.headers.referer ?? '';
|
||||
let opts = {};
|
||||
|
||||
if (referer) {
|
||||
try {
|
||||
const refUrl = new URL(referer);
|
||||
const path = refUrl.pathname;
|
||||
const query = refUrl.search;
|
||||
|
||||
console.log("[RANDOM] Parsing referer path:", path);
|
||||
|
||||
// Regex that is less strict about start/end but captures groups
|
||||
// Captures: /h/slug, /tag/name, /user/name/(f0cks|favs), /(video|audio|image), /p/N, /ID
|
||||
const hallMatch = path.match(/\/h\/(?<hall>[^/]+)/);
|
||||
const tagMatch = path.match(/\/tag\/(?<tag>[^/]+)/);
|
||||
const userMatch = path.match(/\/user\/(?<user>[^/]+)\/(?<mode>f0cks|favs)/);
|
||||
const mimeMatch = path.match(/\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*)/);
|
||||
const pageMatch = path.match(/\/p\/(?<page>\d+)/);
|
||||
const itemMatch = path.match(/\/(?<itemid>\d+)$/);
|
||||
|
||||
if (hallMatch) opts.hall = hallMatch.groups.hall;
|
||||
if (tagMatch) opts.tag = tagMatch.groups.tag;
|
||||
if (userMatch) {
|
||||
opts.user = userMatch.groups.user;
|
||||
opts.mode = userMatch.groups.mode;
|
||||
}
|
||||
if (mimeMatch) opts.mime = mimeMatch.groups.mime;
|
||||
if (pageMatch) opts.page = pageMatch.groups.page;
|
||||
if (query.includes('strict=1')) opts.strict = true;
|
||||
|
||||
console.log("[RANDOM] Detected opts:", opts);
|
||||
} catch (e) {
|
||||
console.error("[RANDOM] Failed to parse referer URL:", referer, e);
|
||||
}
|
||||
}
|
||||
|
||||
const data = await f0cklib.getRandom({
|
||||
user: opts.user,
|
||||
tag: opts.tag,
|
||||
hall: opts.hall,
|
||||
mime: opts.mime || (req.cookies.mime || null),
|
||||
page: opts.page,
|
||||
fav: opts.mode === 'favs',
|
||||
mode: req.mode,
|
||||
strict: opts.strict,
|
||||
session: !!req.session
|
||||
});
|
||||
|
||||
console.log("data", data);
|
||||
|
||||
if (!data.success) {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: data.message,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
|
||||
res.redirect(encodeURI(`${data.link.main}${data.link.path}${data.itemid}${data.link.suffix || ''}`));
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
95
src/inc/routes/ranking.mjs
Normal file
95
src/inc/routes/ranking.mjs
Normal file
@@ -0,0 +1,95 @@
|
||||
import db from "../../inc/sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import config from "../config.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import fetch from "flumm-fetch";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/ranking$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const list = await db`
|
||||
select
|
||||
"user".user, "user".admin,
|
||||
coalesce("user_options".avatar, ${await lib.getDefaultAvatar()}) as avatar,
|
||||
"user_options".avatar_file,
|
||||
"user_options".display_name,
|
||||
count(distinct(tag_id, item_id)) as count
|
||||
from "tags_assign"
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join "user_options" on "user_options".user_id = "user".id
|
||||
group by "user".user, "user_options".avatar, "user_options".avatar_file, "user".admin, "user_options".display_name
|
||||
order by count desc
|
||||
`;
|
||||
const stats = await lib.countf0cks();
|
||||
|
||||
const hoster = await db`
|
||||
with t as (
|
||||
select
|
||||
split_part(substring(src, position('//' in src)+2), '/', 1) part
|
||||
from items
|
||||
)
|
||||
select t.part, count(t.part) as c
|
||||
from t
|
||||
group by t.part
|
||||
order by c desc
|
||||
limit 20
|
||||
`;
|
||||
|
||||
const favotop = await db`
|
||||
select favorites.item_id, count(*) favs
|
||||
from favorites
|
||||
join items on items.id = favorites.item_id
|
||||
where items.active = true
|
||||
group by favorites.item_id
|
||||
having count(*) > 1
|
||||
order by favs desc
|
||||
limit 10
|
||||
`;
|
||||
|
||||
let xdtop = [];
|
||||
if (config.websrv.enable_xd_score) {
|
||||
const xdRows = await db`
|
||||
select id, xd_score
|
||||
from items
|
||||
where active = true and is_deleted = false and xd_score > 0
|
||||
order by xd_score desc
|
||||
limit 10
|
||||
`;
|
||||
xdtop = xdRows.map(item => {
|
||||
const meta = f0cklib.xdScoreMeta(item.xd_score);
|
||||
return {
|
||||
...item,
|
||||
xd_tier: meta.tier,
|
||||
xd_label: meta.label
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
body: tpl.render('ranking', {
|
||||
list,
|
||||
stats,
|
||||
hoster,
|
||||
favotop,
|
||||
xdtop,
|
||||
tmp: null,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'ranking',
|
||||
description: 'User ranking and site statistics',
|
||||
url: `https://${config.main.url.domain}/ranking`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
} catch (err) {
|
||||
res.end(JSON.stringify(err.message));
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
193
src/inc/routes/register.mjs
Normal file
193
src/inc/routes/register.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import security from "../security.mjs";
|
||||
import { getRegistrationOpen, getDefaultLayout } from "../settings.mjs";
|
||||
import { sendMail } from "../../lib/smtp.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import crypto from "crypto";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/register(\/)?$/, async (req, res) => {
|
||||
if (req.session) {
|
||||
return res.writeHead(302, { "Location": "/?already_logged_in=1" }).end();
|
||||
}
|
||||
let url = "/#register";
|
||||
if (req.url.qs?.token) url += `:${req.url.qs.token}`;
|
||||
return res.writeHead(302, { "Location": url }).end();
|
||||
});
|
||||
|
||||
router.get(/^\/activate\/(?<token>.+)$/, async (req, res) => {
|
||||
const token = req.params.token;
|
||||
const user = await db`SELECT id FROM "user" WHERE activation_token = ${token}`;
|
||||
|
||||
if (user.length === 0) {
|
||||
const errorMsg = encodeURIComponent("Invalid or expired activation token.");
|
||||
return res.writeHead(302, { "Location": `/#register:error:${errorMsg}` }).end();
|
||||
}
|
||||
|
||||
await db`UPDATE "user" SET activated = TRUE, activation_token = NULL WHERE id = ${user[0].id}`;
|
||||
|
||||
const successMsg = encodeURIComponent("Account activated! You can now login.");
|
||||
return res.writeHead(302, { "Location": `/#register:success:${successMsg}` }).end();
|
||||
});
|
||||
|
||||
router.post(/^\/register(\/)?$/, async (req, res) => {
|
||||
let { username, email, password, password_confirm, token, email_confirm_field } = req.post;
|
||||
if (username) username = username.trim();
|
||||
const ip = security.getRealIP(req);
|
||||
|
||||
// Honeypot check
|
||||
if (email_confirm_field) {
|
||||
console.log(`[SPAM] Honeypot triggered by IP: ${ip}, User: ${username}`);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Spam detected." }));
|
||||
}
|
||||
|
||||
if (await security.isRateLimited(ip, null, 'register')) {
|
||||
const errorMsg = "Too many attempts.";
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
|
||||
}
|
||||
return res.reply({
|
||||
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
|
||||
});
|
||||
}
|
||||
|
||||
const renderError = async (msg) => {
|
||||
await security.recordAttempt(ip, username, 'register', false);
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
||||
}
|
||||
return res.reply({
|
||||
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
|
||||
});
|
||||
};
|
||||
|
||||
const renderSuccess = async (msg) => {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
|
||||
}
|
||||
return res.reply({
|
||||
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
|
||||
});
|
||||
};
|
||||
|
||||
// Input Validation
|
||||
if (!username || username.trim().length === 0) {
|
||||
return renderError("Username is required.");
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
|
||||
return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.");
|
||||
}
|
||||
|
||||
if (!password || password.length < 20) {
|
||||
return renderError("Password must be at least 20 characters long.");
|
||||
}
|
||||
|
||||
if (password !== password_confirm) {
|
||||
return renderError("Passwords do not match.");
|
||||
}
|
||||
|
||||
// Registration Logic
|
||||
let activated = true;
|
||||
let activationToken = null;
|
||||
|
||||
if (!token && !getRegistrationOpen()) {
|
||||
return renderError("Invite token is required for registration.");
|
||||
}
|
||||
|
||||
if (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");
|
||||
// Token used, so it will be activated by default
|
||||
} else {
|
||||
// No token, Open Registration
|
||||
if (!email || !email.includes('@')) return renderError("A valid email is required for no-token registration.");
|
||||
activated = false;
|
||||
activationToken = crypto.randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
// Check user existence
|
||||
const existing = await db`select id from "user" where "login" = ${username.toLowerCase()} or "user" = ${username}`;
|
||||
if (existing.length > 0) return renderError("Username taken");
|
||||
|
||||
// Create User
|
||||
const hash = await lib.hash(password);
|
||||
const ts = ~~(Date.now() / 1e3);
|
||||
|
||||
let userId;
|
||||
try {
|
||||
const newUser = await db`
|
||||
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
|
||||
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
|
||||
returning id
|
||||
`;
|
||||
userId = newUser[0].id;
|
||||
|
||||
// Assign default avatar file
|
||||
const avatarId = null;
|
||||
const avatarFile = 'default.png';
|
||||
|
||||
await db`
|
||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
||||
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(`[REGISTER] DB Error during user creation:`, err);
|
||||
if (err.code === '23505') { // Unique constraint violation
|
||||
return renderError("Username taken");
|
||||
}
|
||||
return renderError("An unexpected error occurred. Please try again later.");
|
||||
}
|
||||
|
||||
// If not activated, send email and return success message
|
||||
if (!activated) {
|
||||
const activationLink = `${cfg.main.url.full}/activate/${activationToken}`;
|
||||
const mailBody = `Hello ${username},\n\nThank you for registering. Please activate your account by clicking the link below:\n\n${activationLink}\n\nIf you did not request this, please ignore this email.`;
|
||||
|
||||
try {
|
||||
if (cfg.smtp && cfg.smtp.host) {
|
||||
await sendMail(cfg.smtp, {
|
||||
to: email,
|
||||
subject: "Activate your account",
|
||||
body: mailBody
|
||||
});
|
||||
} else {
|
||||
console.log(`[SMTP] No configuration found. Activation link: ${activationLink}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[SMTP] Send failed:`, e.message);
|
||||
// We'll still proceed since the user is created, but they'll be stuck.
|
||||
// In production they should see an error, but let's keep it simple for now.
|
||||
}
|
||||
|
||||
await renderSuccess("Registration successful! Please check your email to activate your account.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
const tokenRow = await db`select id from invite_tokens where token = ${token} and is_used = false`;
|
||||
if (tokenRow.length > 0) {
|
||||
await db`
|
||||
update invite_tokens
|
||||
set is_used = true, used_by = ${userId}
|
||||
where id = ${tokenRow[0].id}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await security.recordAttempt(ip, username, 'register', true);
|
||||
|
||||
const successMsg = "Registration successful! You can now login.";
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg }));
|
||||
}
|
||||
|
||||
// Redirect to home with login success message
|
||||
return res.writeHead(302, { "Location": "/?login=success" }).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
167
src/inc/routes/reports.mjs
Normal file
167
src/inc/routes/reports.mjs
Normal file
@@ -0,0 +1,167 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// User: Submit a new report
|
||||
router.post(/^\/api\/v2\/report\/?$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const { item_id, comment_id, reported_user_id, reason } = req.post;
|
||||
|
||||
if (!reason || reason.trim().length === 0) {
|
||||
return res.json({ success: false, msg: "Reason is required." }, 400);
|
||||
}
|
||||
|
||||
// At least one target must be specified
|
||||
if (!item_id && !comment_id && !reported_user_id) {
|
||||
return res.json({ success: false, msg: "Must specify an item, comment, or user to report." }, 400);
|
||||
}
|
||||
|
||||
const reportRes = await db`
|
||||
INSERT INTO reports (reporter_id, item_id, comment_id, user_id, reason)
|
||||
VALUES (
|
||||
${req.session.id},
|
||||
${item_id ? +item_id : null},
|
||||
${comment_id ? +comment_id : null},
|
||||
${reported_user_id ? +reported_user_id : null},
|
||||
${reason.trim()}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const report_id = reportRes[0].id;
|
||||
|
||||
// Notify all mods and admins
|
||||
try {
|
||||
const mods = await db`SELECT id FROM "user" WHERE admin = true OR is_moderator = true`;
|
||||
if (mods.length > 0) {
|
||||
let resolved_item_id = item_id ? +item_id : null;
|
||||
|
||||
// If reporting a comment, resolve the item_id it belongs to
|
||||
if (!resolved_item_id && comment_id) {
|
||||
const comm = await db`SELECT item_id FROM comments WHERE id = ${comment_id}`;
|
||||
if (comm.length > 0) resolved_item_id = comm[0].item_id;
|
||||
}
|
||||
|
||||
const notificationsToAdd = mods.map(m => ({
|
||||
user_id: m.id,
|
||||
type: 'report',
|
||||
reference_id: report_id,
|
||||
item_id: resolved_item_id // Can be null now after migration
|
||||
}));
|
||||
await db`INSERT INTO notifications ${db(notificationsToAdd)}`;
|
||||
}
|
||||
} catch (notifyErr) {
|
||||
console.error('[REPORT] Failed to send notifications:', notifyErr);
|
||||
}
|
||||
|
||||
return res.json({ success: true, msg: "Report submitted successfully." });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: lib.logError(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Mod/Admin: Get pending/all reports
|
||||
router.get(/^\/api\/v2\/mod\/reports\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const status = req.url.qs?.status || 'pending';
|
||||
const page = +(req.url.qs?.page || 1);
|
||||
const limit = 20;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const reports = await db`
|
||||
SELECT r.*,
|
||||
rep.user AS reporter_name,
|
||||
COALESCE(tgt_u.user, tgt_auth.user, comm_auth.user) AS reported_user_name,
|
||||
COALESCE(NULLIF(r.user_id, 0), tgt_auth.id, comm_auth.id) AS reported_user_id,
|
||||
COALESCE(tgt_u.admin, tgt_auth.admin, comm_auth.admin) AS reported_user_is_admin,
|
||||
i.dest AS item_dest,
|
||||
tgt_auth.id AS item_user_id,
|
||||
tgt_auth.user AS item_user_name,
|
||||
(SELECT coalesce(json_agg(json_build_object('id', t.id, 'tag', t.tag, 'normalized', t.normalized)), '[]')
|
||||
FROM tags_assign ta
|
||||
JOIN tags t ON ta.tag_id = t.id
|
||||
WHERE ta.item_id = COALESCE(r.item_id, c.item_id)) as item_tags,
|
||||
c.content AS comment_body,
|
||||
COALESCE(r.item_id, c.item_id) AS resolved_item_id,
|
||||
COALESCE(i.dest, ci.dest) AS resolved_item_dest,
|
||||
COALESCE(i.mime, ci.mime) AS resolved_item_mime
|
||||
FROM reports r
|
||||
LEFT JOIN "user" rep ON r.reporter_id = rep.id
|
||||
LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id
|
||||
LEFT JOIN items i ON r.item_id = i.id
|
||||
LEFT JOIN "user" tgt_auth ON i.username = tgt_auth.user
|
||||
LEFT JOIN comments c ON r.comment_id = c.id
|
||||
LEFT JOIN items ci ON c.item_id = ci.id
|
||||
LEFT JOIN "user" comm_auth ON c.user_id = comm_auth.id
|
||||
WHERE r.status = ${status}
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
// Compute badges for tags (sync with lib.getTags logic)
|
||||
for (const r of reports) {
|
||||
if (r.item_tags) {
|
||||
for (const t of r.item_tags) {
|
||||
if (t.tag.startsWith(">")) t.badge = "badge-greentext badge-light";
|
||||
else if (t.normalized === "ukraine") t.badge = "badge-ukraine badge-light";
|
||||
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") t.badge = "badge-russia badge-light";
|
||||
else if (t.normalized === "german") t.badge = "badge-german badge-light";
|
||||
else if (t.normalized === "dutch") t.badge = "badge-dutch badge-light";
|
||||
else if (t.normalized === "sfw") t.badge = "badge-success";
|
||||
else if (t.normalized === "nsfw") t.badge = "badge-danger";
|
||||
else t.badge = "badge-light";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalRes = await db`SELECT count(*) as c FROM reports WHERE status = ${status}`;
|
||||
const total = totalRes[0].c;
|
||||
|
||||
const emojis = await db`SELECT name, url FROM custom_emojis`;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
reports,
|
||||
emojis,
|
||||
page,
|
||||
pages: Math.ceil(total / limit),
|
||||
total
|
||||
});
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: lib.logError(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Mod/Admin: Resolve a report
|
||||
router.post(/^\/api\/v2\/mod\/reports\/(?<id>\d+)\/resolve\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const id = +req.params.id;
|
||||
const { action } = req.post; // 'resolved' or 'rejected'
|
||||
|
||||
if (!['resolved', 'rejected'].includes(action)) {
|
||||
return res.json({ success: false, msg: "Invalid action. Must be 'resolved' or 'rejected'." }, 400);
|
||||
}
|
||||
|
||||
const result = await db`
|
||||
UPDATE reports
|
||||
SET status = ${action}, resolved_by = ${req.session.id}
|
||||
WHERE id = ${id}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.json({ success: false, msg: "Report not found." }, 404);
|
||||
}
|
||||
|
||||
await audit.log(req.session.id, 'resolve_report', 'report', id, { status: action });
|
||||
|
||||
return res.json({ success: true, msg: `Report marked as ${action}.` });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: lib.logError(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
316
src/inc/routes/scroller.mjs
Normal file
316
src/inc/routes/scroller.mjs
Normal file
@@ -0,0 +1,316 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Serve the scroller page
|
||||
router.get(/^\/abyss\/?$/, async (req, res) => {
|
||||
if (cfg.websrv.private_society && !req.session) {
|
||||
return res.reply({ code: 502, body: '<html><body>502 Bad Gateway</body></html>' });
|
||||
}
|
||||
return res.reply({
|
||||
body: tpl.render('scroller', {
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
enable_nsfl: !!cfg.enable_nsfl,
|
||||
enable_swf: !!cfg.websrv.enable_swf,
|
||||
page_meta: {
|
||||
title: 'doomscroll',
|
||||
description: 'Scroll through content endlessly',
|
||||
url: `https://${cfg.main.url.domain}/abyss`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Lightweight meta refresh — returns live counts + tags for a batch of item IDs
|
||||
// GET /api/v2/scroller/meta?ids=1,2,3
|
||||
router.get(/^\/api\/v2\/scroller\/meta\/?$/, async (req, res) => {
|
||||
if (cfg.websrv.private_society && !req.session) {
|
||||
return res.reply({ code: 502, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||
}
|
||||
const qs = req.url.qs || {};
|
||||
const ids = (qs.ids || '').split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 50);
|
||||
if (!ids.length) return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||
const sid = req.session ? +req.session.id : null;
|
||||
try {
|
||||
const rows = await db`
|
||||
SELECT
|
||||
items.id,
|
||||
(SELECT string_agg(t.tag, ', ' ORDER BY ta2.tag_id)
|
||||
FROM tags_assign ta2 JOIN tags t ON t.id = ta2.tag_id
|
||||
WHERE ta2.item_id = items.id AND ta2.tag_id > 2 LIMIT 5) AS tag_list,
|
||||
(SELECT COUNT(*) FROM favorites WHERE favorites.item_id = items.id) AS fav_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE comments.item_id = items.id AND comments.is_deleted = false) AS comment_count,
|
||||
${sid ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sid})` : db`false`} AS is_faved
|
||||
FROM items
|
||||
WHERE items.id = ANY(${ids}::int[])
|
||||
`;
|
||||
const result = {};
|
||||
for (const row of rows) {
|
||||
result[row.id] = {
|
||||
tags: row.tag_list || '',
|
||||
fav_count: +row.fav_count || 0,
|
||||
comment_count: +row.comment_count || 0,
|
||||
is_faved: row.is_faved || false
|
||||
};
|
||||
}
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store, no-cache' },
|
||||
body: JSON.stringify(result)
|
||||
});
|
||||
} catch (e) {
|
||||
return res.reply({ code: 500, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({}) });
|
||||
}
|
||||
});
|
||||
|
||||
// Tag autocomplete endpoint
|
||||
router.get(/^\/api\/v2\/scroller\/tags\/?$/, async (req, res) => {
|
||||
if (cfg.websrv.private_society && !req.session) {
|
||||
return res.reply({ code: 502, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([]) });
|
||||
}
|
||||
const qs = req.url.qs || {};
|
||||
const q = (qs.q || '').trim();
|
||||
if (q.length < 1) {
|
||||
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([]) });
|
||||
}
|
||||
try {
|
||||
const slug = '%' + lib.slugify(q) + '%';
|
||||
const rows = await db`
|
||||
SELECT t.tag, t.normalized, COUNT(ta.item_id) as uses
|
||||
FROM tags t
|
||||
JOIN tags_assign ta ON ta.tag_id = t.id
|
||||
WHERE t.id > 2
|
||||
AND lower(t.normalized) ILIKE ${slug}
|
||||
GROUP BY t.tag, t.normalized
|
||||
ORDER BY uses DESC
|
||||
LIMIT 10
|
||||
`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rows.map(r => ({ tag: r.tag, normalized: r.normalized, uses: +r.uses })))
|
||||
});
|
||||
} catch (e) {
|
||||
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify([]) });
|
||||
}
|
||||
});
|
||||
|
||||
// JSON API: returns a batch of items for the scroller
|
||||
router.get(/^\/api\/v2\/scroller\/feed\/?$/, async (req, res) => {
|
||||
if (cfg.websrv.private_society && !req.session) {
|
||||
return res.reply({
|
||||
code: 502,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, items: [] })
|
||||
});
|
||||
}
|
||||
|
||||
const qs = req.url.qs || {};
|
||||
const mode = qs.mode !== undefined ? +qs.mode : req.mode;
|
||||
const limit = Math.min(+qs.limit || 12, 30);
|
||||
const after = qs.after ? +qs.after : null;
|
||||
const mime = qs.mime || null;
|
||||
const tagFilter = qs.tag ? qs.tag.trim() : null;
|
||||
const orderby = qs.orderby === 'newest' ? 'newest' : (qs.orderby === 'oldest' ? 'oldest' : 'random');
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
// exclude= is a comma-separated list of item IDs already seen by the client (random mode dedup)
|
||||
const excludeIds = qs.exclude
|
||||
? qs.exclude.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 500)
|
||||
: [];
|
||||
const modeQuery = lib.getMode(mode ?? 0);
|
||||
const nsfp = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
// anchor= is a specific item ID to include first in the batch (used for hash-based deep links)
|
||||
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
|
||||
|
||||
// MIME filter — SWF excluded unless the server has enable_swf turned on
|
||||
const swfMimes = ['application/x-shockwave-flash', 'application/vnd.adobe.flash.movie'];
|
||||
const excludeSwfSQL = !cfg.websrv.enable_swf ? db`AND items.mime != ALL(${swfMimes})` : db``;
|
||||
const mimeParts = (mime || '').split(',').filter(m => ['video', 'audio', 'image'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`AND (${mimeParts.map(m => db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} OR ${b}`)})`
|
||||
: db``;
|
||||
|
||||
// Tag filter — support comma-separated list; items matching ANY tag are included (OR)
|
||||
let tagSQL = db``;
|
||||
if (tagFilter) {
|
||||
const tagTerms = tagFilter.split(',').map(t => t.trim()).filter(Boolean);
|
||||
if (tagTerms.length === 1) {
|
||||
// Single tag: partial ILIKE match
|
||||
const slug = '%' + lib.slugify(tagTerms[0]) + '%';
|
||||
tagSQL = db`AND items.id IN (
|
||||
SELECT ta.item_id FROM tags_assign ta
|
||||
JOIN tags t ON t.id = ta.tag_id
|
||||
WHERE lower(t.normalized) ILIKE ${slug}
|
||||
)`;
|
||||
} else {
|
||||
// Multiple tags: item must match at least ONE tag (OR / union)
|
||||
const slugs = tagTerms.map(term => '%' + lib.slugify(term) + '%');
|
||||
tagSQL = db`AND items.id IN (
|
||||
SELECT ta.item_id FROM tags_assign ta
|
||||
JOIN tags t ON t.id = ta.tag_id
|
||||
WHERE ${slugs.map(s => db`lower(t.normalized) ILIKE ${s}`).reduce((a, b) => db`${a} OR ${b}`)}
|
||||
)`;
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor (pagination) — direction depends on order
|
||||
let cursorSQL = db``;
|
||||
if (after) {
|
||||
if (orderby === 'newest') cursorSQL = db`AND items.id < ${after}`;
|
||||
else if (orderby === 'oldest') cursorSQL = db`AND items.id > ${after}`;
|
||||
// random: after-param not used; use exclude list instead
|
||||
}
|
||||
// Random mode: exclude all IDs the client has already seen
|
||||
const excludeSQL = (orderby === 'random' && excludeIds.length > 0)
|
||||
? db`AND items.id != ALL(${excludeIds}::int[])`
|
||||
: db``;
|
||||
|
||||
// Order
|
||||
const orderSQL = orderby === 'newest'
|
||||
? db`ORDER BY items.id DESC`
|
||||
: orderby === 'oldest'
|
||||
? db`ORDER BY items.id ASC`
|
||||
: db`ORDER BY random()`;
|
||||
|
||||
// Reusable SELECT columns fragment helper
|
||||
const selectCols = (sessionId) => db`
|
||||
items.id, items.mime, items.dest, items.username, items.stamp, items.src, items.is_oc,
|
||||
uo.display_name, uo.avatar, uo.avatar_file, uo.username_color,
|
||||
(
|
||||
SELECT string_agg(t.tag, ', ' ORDER BY ta2.tag_id)
|
||||
FROM tags_assign ta2 JOIN tags t ON t.id = ta2.tag_id
|
||||
WHERE ta2.item_id = items.id AND ta2.tag_id > 2 LIMIT 5
|
||||
) AS tag_list,
|
||||
(SELECT ta3.tag_id FROM tags_assign ta3
|
||||
WHERE ta3.item_id = items.id AND ta3.tag_id IN (1,2,${cfg.nsfl_tag_id || 3})
|
||||
ORDER BY ta3.tag_id LIMIT 1) AS rating_tag_id,
|
||||
(SELECT COUNT(*) FROM favorites WHERE favorites.item_id = items.id) AS fav_count,
|
||||
(SELECT COUNT(*) FROM comments WHERE comments.item_id = items.id AND comments.is_deleted = false) AS comment_count,
|
||||
${sessionId ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sessionId})` : db`false`} AS is_faved
|
||||
`;
|
||||
const sid = req.session ? +req.session.id : null;
|
||||
|
||||
try {
|
||||
let rows;
|
||||
|
||||
if (anchorId) {
|
||||
// Fetch the anchor item guaranteed first, then fill the rest randomly
|
||||
const anchorRows = await db`
|
||||
SELECT ${selectCols(sid)}
|
||||
FROM items
|
||||
LEFT JOIN "user" author_u ON author_u.user = items.username
|
||||
LEFT JOIN user_options uo ON uo.user_id = author_u.id
|
||||
WHERE items.id = ${anchorId}
|
||||
AND items.active = true
|
||||
AND ${db.unsafe(modeQuery)}
|
||||
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
`;
|
||||
// If the anchor item doesn't pass the rating filter, it's inaccessible to this user.
|
||||
// Return empty so the frontend shows "This post is currently unavailable".
|
||||
if (anchorRows.length === 0) {
|
||||
rows = [];
|
||||
} else {
|
||||
const restRows = await db`
|
||||
SELECT ${selectCols(sid)}
|
||||
FROM items
|
||||
LEFT JOIN "user" author_u ON author_u.user = items.username
|
||||
LEFT JOIN user_options uo ON uo.user_id = author_u.id
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
${excludeSwfSQL}
|
||||
AND items.id != ${anchorId}
|
||||
${excludeSQL}
|
||||
${mimeSQL}
|
||||
${tagSQL}
|
||||
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||
ORDER BY random()
|
||||
LIMIT ${limit - 1}
|
||||
`;
|
||||
rows = [...anchorRows, ...restRows];
|
||||
}
|
||||
|
||||
} else {
|
||||
rows = await db`
|
||||
SELECT ${selectCols(sid)}
|
||||
FROM items
|
||||
LEFT JOIN "user" author_u ON author_u.user = items.username
|
||||
LEFT JOIN user_options uo ON uo.user_id = author_u.id
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
${excludeSwfSQL}
|
||||
${cursorSQL}
|
||||
${excludeSQL}
|
||||
${mimeSQL}
|
||||
${tagSQL}
|
||||
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||
${orderSQL}
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
}
|
||||
|
||||
const items = rows.map(row => {
|
||||
const isVideo = row.mime && row.mime.startsWith('video') && row.mime !== 'video/youtube';
|
||||
const isYouTube = row.mime === 'video/youtube';
|
||||
const isAudio = row.mime && row.mime.startsWith('audio');
|
||||
const isImage = row.mime && row.mime.startsWith('image');
|
||||
|
||||
let dest = row.dest;
|
||||
if (!isYouTube && dest) dest = `${cfg.websrv.paths.images}/${row.dest}`;
|
||||
const thumbnail = `${cfg.websrv.paths.thumbnails}/${row.id}.webp`;
|
||||
|
||||
let ratingLabel = '?'; let ratingClass = 'untagged';
|
||||
if (row.rating_tag_id == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; }
|
||||
else if (row.rating_tag_id == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; }
|
||||
else if (row.rating_tag_id == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; }
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
mime: row.mime,
|
||||
dest,
|
||||
thumbnail,
|
||||
username: row.username,
|
||||
display_name: row.display_name || row.username,
|
||||
avatar: row.avatar_file ? `/a/${row.avatar_file}` : (row.avatar ? `/t/${row.avatar}.webp` : '/a/default.png'),
|
||||
username_color: row.username_color || null,
|
||||
stamp: row.stamp,
|
||||
timeago: lib.timeAgo(new Date(row.stamp * 1e3).toISOString()),
|
||||
tags: row.tag_list || '',
|
||||
is_oc: row.is_oc || false,
|
||||
is_faved: row.is_faved || false,
|
||||
fav_count: +row.fav_count || 0,
|
||||
comment_count: +row.comment_count || 0,
|
||||
is_swf: !!(row.mime === 'application/x-shockwave-flash' || row.mime === 'application/vnd.adobe.flash.movie'),
|
||||
is_video: isVideo,
|
||||
is_youtube: isYouTube,
|
||||
is_audio: isAudio,
|
||||
is_image: isImage,
|
||||
rating_label: ratingLabel,
|
||||
rating_class: ratingClass,
|
||||
src_host: row.src ? (() => { try { return new URL(row.src).hostname; } catch { return ''; } })() : ''
|
||||
};
|
||||
});
|
||||
|
||||
// For ordered feeds, track last id for cursor
|
||||
const lastItem = items[items.length - 1];
|
||||
const nextCursor = lastItem ? lastItem.id : null;
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store, no-cache' },
|
||||
body: JSON.stringify({ success: true, items, nextCursor })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[SCROLLER] Feed error:', e);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, items: [], error: e.message })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
185
src/inc/routes/search.mjs
Normal file
185
src/inc/routes/search.mjs
Normal file
@@ -0,0 +1,185 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import search from "../routeinc/search.mjs";
|
||||
|
||||
const _eps = 20;
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/search(\/)?$/, lib.loggedin, async (req, res) => {
|
||||
let ret;
|
||||
let mode = req.url.qs.mode;
|
||||
let tag = req.url.qs.tag ?? [];
|
||||
let page = req.url.qs.page ?? 1;
|
||||
let total = 0;
|
||||
let pagination, link;
|
||||
|
||||
if (tag.length > 0) {
|
||||
if (tag.startsWith('src:')) {
|
||||
total = (await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where src ilike ${'%' + tag.substring(4) + '%'} and active = true
|
||||
group by "items".id
|
||||
`).length;
|
||||
|
||||
const pages = +Math.ceil(total / _eps);
|
||||
const act_page = Math.min(pages, page || 1);
|
||||
const offset = Math.max(0, (act_page - 1) * _eps);
|
||||
|
||||
ret = await db`
|
||||
select *
|
||||
from "items"
|
||||
where
|
||||
src ilike ${'%' + tag.substring(4) + '%'} and
|
||||
active = true
|
||||
group by "items".id
|
||||
order by "items".id desc
|
||||
offset ${offset}
|
||||
limit ${_eps}
|
||||
`;
|
||||
|
||||
const cheat = [];
|
||||
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
|
||||
cheat.push(i);
|
||||
|
||||
pagination = {
|
||||
start: 1,
|
||||
end: pages,
|
||||
prev: (act_page > 1) ? act_page - 1 : null,
|
||||
next: (act_page < pages) ? act_page + 1 : null,
|
||||
page: act_page,
|
||||
cheat: cheat,
|
||||
uff: false
|
||||
};
|
||||
link = {
|
||||
main: `/search/?tag=${tag}`,
|
||||
path: '&page='
|
||||
};
|
||||
}
|
||||
else if (mode === 'strict') {
|
||||
const tags = tag.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
|
||||
if (tags.length > 0) {
|
||||
const lowerTags = tags.map(t => t.toLowerCase());
|
||||
|
||||
const countResult = await db`
|
||||
select count(sub.id) as total from (
|
||||
select "items".id
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where lower("tags".tag) in (${db(lowerTags)})
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
having count(distinct lower("tags".tag)) = ${lowerTags.length}
|
||||
) sub
|
||||
`;
|
||||
total = countResult.length > 0 ? countResult[0].total : 0;
|
||||
|
||||
const pages = +Math.ceil(total / _eps);
|
||||
const act_page = Math.min(pages, page || 1);
|
||||
const offset = Math.max(0, (act_page - 1) * _eps);
|
||||
|
||||
const rows = await db`
|
||||
select "items".id, "items".username, "items".mime, min("tags".tag) as tag
|
||||
from "items"
|
||||
join "tags_assign" on "tags_assign".item_id = "items".id
|
||||
join "tags" on "tags".id = "tags_assign".tag_id
|
||||
where lower("tags".tag) in (${db(lowerTags)})
|
||||
and "items".active = true
|
||||
group by "items".id
|
||||
having count(distinct lower("tags".tag)) = ${lowerTags.length}
|
||||
order by "items".id desc
|
||||
offset ${offset}
|
||||
limit ${_eps}
|
||||
`;
|
||||
|
||||
ret = rows.map(r => ({ ...r, score: 1.0 }));
|
||||
|
||||
const cheat = [];
|
||||
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
|
||||
cheat.push(i);
|
||||
|
||||
pagination = {
|
||||
start: 1,
|
||||
end: pages,
|
||||
prev: (act_page > 1) ? act_page - 1 : null,
|
||||
next: (act_page < pages) ? act_page + 1 : null,
|
||||
page: act_page,
|
||||
cheat: cheat,
|
||||
uff: false
|
||||
};
|
||||
link = {
|
||||
main: `/search/?tag=${encodeURIComponent(tag)}&mode=strict`,
|
||||
path: '&page='
|
||||
};
|
||||
} else {
|
||||
total = 0;
|
||||
ret = [];
|
||||
}
|
||||
}
|
||||
else {
|
||||
total = (await db`
|
||||
select count(*) as total
|
||||
from "tags"
|
||||
left join "tags_assign" on "tags_assign".tag_id = "tags".id
|
||||
left join "items" on "items".id = "tags_assign".item_id
|
||||
where "tags".tag ilike ${'%' + tag + '%'}
|
||||
group by "items".id, "tags".tag
|
||||
`).length;
|
||||
|
||||
const pages = +Math.ceil(total / _eps);
|
||||
const act_page = Math.min(pages, page || 1);
|
||||
const offset = Math.max(0, (act_page - 1) * _eps);
|
||||
|
||||
const rows = await db`
|
||||
select "items".id, "items".username, "items".mime, "tags".tag
|
||||
from "tags"
|
||||
left join "tags_assign" on "tags_assign".tag_id = "tags".id
|
||||
left join "items" on "items".id = "tags_assign".item_id
|
||||
where "tags".tag ilike ${'%' + tag + '%'} and "items".active = true
|
||||
group by "items".id, "tags".tag
|
||||
offset ${offset}
|
||||
limit ${_eps}
|
||||
`;
|
||||
ret = search(rows, tag);
|
||||
|
||||
const cheat = [];
|
||||
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
|
||||
cheat.push(i);
|
||||
|
||||
pagination = {
|
||||
start: 1,
|
||||
end: pages,
|
||||
prev: (act_page > 1) ? act_page - 1 : null,
|
||||
next: (act_page < pages) ? act_page + 1 : null,
|
||||
page: act_page,
|
||||
cheat: cheat,
|
||||
uff: false
|
||||
};
|
||||
link = {
|
||||
main: `/search/?tag=${encodeURIComponent(tag)}`,
|
||||
path: '&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({
|
||||
body: tpl.render("search", {
|
||||
result: ret,
|
||||
totals: await lib.countf0cks(),
|
||||
searchstring: tag,
|
||||
count: total,
|
||||
pagination,
|
||||
link,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
mode: req.url.qs.mode
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
};
|
||||
64
src/inc/routes/settings.mjs
Normal file
64
src/inc/routes/settings.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import db from "../../inc/sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
|
||||
const auth = async (req, res, next) => {
|
||||
if (!req.session)
|
||||
return res.redirect("/login");
|
||||
return next();
|
||||
};
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.group(/^\/settings/, group => {
|
||||
group.get(/$/, auth, async (req, res) => {
|
||||
const sessions = await db`
|
||||
select *
|
||||
from user_sessions
|
||||
where user_id = ${+req.session.id}
|
||||
order by last_used desc
|
||||
`;
|
||||
|
||||
const excluded_tags = await db`
|
||||
select t.id, t.tag, t.normalized
|
||||
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
|
||||
join tags t on t.id = et.id
|
||||
`;
|
||||
|
||||
// Get custom avatar file if exists
|
||||
const userOptions = (await db`
|
||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
|
||||
// Get full user info
|
||||
const user = (await db`
|
||||
select email, created_at from "user" where id = ${+req.session.id}
|
||||
`)[0];
|
||||
|
||||
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');
|
||||
|
||||
console.log('Rendering settings. Excluded tags:', excluded_tags);
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('settings', {
|
||||
tmp: null,
|
||||
sessions,
|
||||
excluded_tags: excluded_tags || [],
|
||||
avatar_file: userOptions?.avatar_file || null,
|
||||
email: user?.email || '',
|
||||
joined: user?.created_at || null,
|
||||
enable_swf: cfg.enable_swf,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'settings',
|
||||
description: 'User settings',
|
||||
url: `https://${cfg.main.url.domain}/settings`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
71
src/inc/routes/static.mjs
Normal file
71
src/inc/routes/static.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import cfg from "../config.mjs";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.static({
|
||||
dir: cfg.paths.b,
|
||||
route: /^\/b\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: cfg.paths.emojis,
|
||||
route: /^\/s\/emojis\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: cfg.paths.koepfe,
|
||||
route: /^\/s\/koepfe\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'),
|
||||
route: /^\/s\/ruffle\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: cfg.paths.s,
|
||||
route: /^\/s\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: cfg.paths.t,
|
||||
route: /^\/t\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: cfg.paths.ca,
|
||||
route: /^\/ca\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: cfg.paths.memes,
|
||||
route: /^\/memes\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
dir: cfg.paths.a,
|
||||
route: /^\/a\//
|
||||
});
|
||||
|
||||
router.get(/^\/robots\.txt$/, async (req, res) => {
|
||||
res.reply({
|
||||
type: "text/plain",
|
||||
body: await fs.readFile(path.join(cfg.paths.s, "../robots.txt"), "utf-8")
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/manifest\.json$/, async (req, res) => {
|
||||
res.reply({
|
||||
type: "application/json",
|
||||
body: await fs.readFile(path.join(cfg.paths.s, "../manifest.json"), "utf-8")
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/sw\.js$/, async (req, res) => {
|
||||
res.reply({
|
||||
type: "application/javascript",
|
||||
body: await fs.readFile(path.join(cfg.paths.s, "../sw.js"), "utf-8")
|
||||
});
|
||||
});
|
||||
};
|
||||
174
src/inc/routes/subscriptions.mjs
Normal file
174
src/inc/routes/subscriptions.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
import db from "../sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import url from "url";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// Subscriptions Overview
|
||||
router.get('/subscriptions', async (req, res) => {
|
||||
if (!req.session) return res.redirect('/login');
|
||||
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const page = parseInt(query.page) || 1;
|
||||
const eps = cfg.websrv.eps || 300;
|
||||
const offset = (page - 1) * eps;
|
||||
|
||||
try {
|
||||
console.log('[DEBUG SUB] Fetching subscriptions for user', req.session.id, 'page', page);
|
||||
|
||||
const countRes = await db`
|
||||
SELECT count(*) as total
|
||||
FROM comment_subscriptions
|
||||
WHERE user_id = ${req.session.id} AND is_subscribed = true
|
||||
`;
|
||||
const total = parseInt(countRes[0].total);
|
||||
const pages = Math.ceil(total / eps);
|
||||
|
||||
const subs = await db`
|
||||
SELECT
|
||||
s.created_at as sub_date,
|
||||
i.id, i.dest, i.mime, i.username as uploader_name
|
||||
FROM comment_subscriptions s
|
||||
JOIN items i ON s.item_id = i.id
|
||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT ${eps} OFFSET ${offset}
|
||||
`;
|
||||
console.log('[DEBUG SUB] Found', subs.length, 'subscriptions out of', total);
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
}));
|
||||
|
||||
const pagination = {
|
||||
start: 1,
|
||||
end: pages,
|
||||
prev: (page > 1) ? page - 1 : null,
|
||||
next: (page < pages) ? page + 1 : null,
|
||||
page: page
|
||||
};
|
||||
|
||||
const link = { main: '/subscriptions', path: '?page=' };
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('subscriptions', {
|
||||
items,
|
||||
pagination,
|
||||
link,
|
||||
totalCount: total,
|
||||
hidePagination: true,
|
||||
page_meta: {
|
||||
title: 'subscriptions',
|
||||
description: 'Your comment subscriptions',
|
||||
url: `https://${cfg.main.url.domain}/subscriptions`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[DEBUG SUB ERROR]', e);
|
||||
return res.reply({ code: 500, body: 'Database Error' });
|
||||
}
|
||||
});
|
||||
|
||||
// AJAX Subscriptions for infinite scroll
|
||||
router.get('/ajax/subscriptions', async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const page = parseInt(query.page) || 1;
|
||||
const eps = cfg.websrv.eps || 300;
|
||||
const offset = (page - 1) * eps;
|
||||
|
||||
try {
|
||||
const countRes = await db`
|
||||
SELECT count(*) as total
|
||||
FROM comment_subscriptions
|
||||
WHERE user_id = ${req.session.id} AND is_subscribed = true
|
||||
`;
|
||||
const total = parseInt(countRes[0].total);
|
||||
const pages = Math.ceil(total / eps);
|
||||
|
||||
const subs = await db`
|
||||
SELECT
|
||||
s.created_at as sub_date,
|
||||
i.id, i.dest, i.mime, i.username as uploader_name
|
||||
FROM comment_subscriptions s
|
||||
JOIN items i ON s.item_id = i.id
|
||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT ${eps} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
}));
|
||||
|
||||
const pagination = {
|
||||
start: 1,
|
||||
end: pages,
|
||||
prev: (page > 1) ? page - 1 : null,
|
||||
next: (page < pages) ? page + 1 : null,
|
||||
page: page
|
||||
};
|
||||
|
||||
const link = { main: '/subscriptions', path: '?page=' };
|
||||
|
||||
const html = tpl.render('snippets/subscriptions-grid', { items }, req);
|
||||
const pagHtml = tpl.render('snippets/pagination', { pagination, link }, req);
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
html: html,
|
||||
pagination: pagHtml,
|
||||
hasMore: page < pages,
|
||||
currentPage: page
|
||||
})
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[DEBUG AJAX SUB ERROR]', e);
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
});
|
||||
|
||||
// Unsubscribe
|
||||
router.post(/\/api\/subscriptions\/(?<itemid>\d+)\/delete/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
|
||||
const itemId = req.params.itemid;
|
||||
|
||||
try {
|
||||
await db`UPDATE comment_subscriptions SET is_subscribed = false WHERE user_id = ${req.session.id} AND item_id = ${itemId}`;
|
||||
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;
|
||||
};
|
||||
243
src/inc/routes/tag_image.mjs
Normal file
243
src/inc/routes/tag_image.mjs
Normal file
@@ -0,0 +1,243 @@
|
||||
import crypto from 'crypto';
|
||||
import db from '../sql.mjs';
|
||||
import lib from '../lib.mjs';
|
||||
import cfg from '../config.mjs';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { execFile } from 'child_process';
|
||||
import util from 'util';
|
||||
import url from 'url';
|
||||
|
||||
const execFilePromise = util.promisify(execFile);
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../tag_cache');
|
||||
const CACHE_MAX_AGE = 3600; // 1 hour
|
||||
|
||||
// --- Reusable generation function ---
|
||||
export async function regenerateTagImage(tag, mode) {
|
||||
const hash = crypto.createHash('md5').update(tag).digest('hex');
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}_${mode}.webp`);
|
||||
|
||||
try {
|
||||
let modeFilter = db``;
|
||||
if (mode === 0) {
|
||||
modeFilter = db`JOIN tags_assign ta_sfw ON ta_sfw.item_id = i.id AND ta_sfw.tag_id = 1`;
|
||||
} else if (mode === 1) {
|
||||
modeFilter = db`JOIN tags_assign ta_nsfw ON ta_nsfw.item_id = i.id AND ta_nsfw.tag_id = 2`;
|
||||
}
|
||||
|
||||
const items = await db`
|
||||
SELECT i.id
|
||||
FROM items i
|
||||
JOIN tags_assign ta ON ta.item_id = i.id
|
||||
JOIN tags t ON t.id = ta.tag_id
|
||||
${modeFilter}
|
||||
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
if (items.length > 0) {
|
||||
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
|
||||
|
||||
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
|
||||
return cachePath;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[TAG_IMAGE] Failed to generate image for tag "${tag}" (mode ${mode}):`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Background listener: regenerate cache when tags change ---
|
||||
db.listen('tags', async (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
const itemId = data.item_id;
|
||||
if (!itemId) return;
|
||||
|
||||
// Find all tags currently assigned to this item
|
||||
const tags = await db`
|
||||
SELECT DISTINCT t.tag
|
||||
FROM tags t
|
||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||
WHERE ta.item_id = ${itemId}
|
||||
`;
|
||||
|
||||
// Also include any tags from the notification payload (covers deletions
|
||||
// where the tag may no longer be assigned to the item)
|
||||
const payloadTags = (data.tags || []).map(t => t.tag).filter(Boolean);
|
||||
const allTagNames = new Set([
|
||||
...tags.map(t => t.tag),
|
||||
...payloadTags
|
||||
]);
|
||||
|
||||
if (allTagNames.size === 0) return;
|
||||
|
||||
console.log(`[TAG_IMAGE] Tag change on item ${itemId}, regenerating cache for ${allTagNames.size} tag(s)`);
|
||||
|
||||
for (const tag of allTagNames) {
|
||||
const hash = crypto.createHash('md5').update(tag).digest('hex');
|
||||
|
||||
// Delete existing cache files for this tag (all modes)
|
||||
for (const mode of [0, 1, 3]) {
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}_${mode}.webp`);
|
||||
try {
|
||||
await fs.unlink(cachePath);
|
||||
} catch {
|
||||
// File didn't exist, that's fine
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate for all modes (fire-and-forget per tag)
|
||||
for (const mode of [0, 1, 3]) {
|
||||
regenerateTagImage(tag, mode).catch(err =>
|
||||
console.error(`[TAG_IMAGE] Background regen failed for "${tag}" mode ${mode}:`, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[TAG_IMAGE] Background listener error:', e);
|
||||
}
|
||||
}).catch(err => console.error('[TAG_IMAGE] DB Listen error:', err));
|
||||
|
||||
// --- SVG fallback helper ---
|
||||
function generateFallbackSvg(tag) {
|
||||
const hash = crypto.createHash('md5').update(tag).digest('hex');
|
||||
const escapeXml = (unsafe) => {
|
||||
return unsafe.replace(/[<>&'"]/g, (c) => {
|
||||
switch (c) {
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '&': return '&';
|
||||
case '\'': return ''';
|
||||
case '"': return '"';
|
||||
}
|
||||
});
|
||||
};
|
||||
const displayTag = escapeXml(tag);
|
||||
const c1 = '#' + hash.substring(0, 6);
|
||||
const c2 = '#' + hash.substring(6, 12);
|
||||
const c3 = '#' + hash.substring(12, 18);
|
||||
const n1 = parseInt(hash.substring(18, 20), 16);
|
||||
const n2 = parseInt(hash.substring(20, 22), 16);
|
||||
|
||||
return `
|
||||
<svg width="300" height="150" viewBox="0 0 300 150" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<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="${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">${displayTag}</text>
|
||||
</svg>
|
||||
`.trim();
|
||||
}
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/tag_image\/(?<tag>.+)$/, async (req, res) => {
|
||||
const tag = decodeURIComponent(req.params.tag);
|
||||
|
||||
// Parse query parameters
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const mode = query.m ? parseInt(query.m) : (req.mode ?? 0);
|
||||
const hash = crypto.createHash('md5').update(tag).digest('hex');
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}_${mode}.webp`);
|
||||
|
||||
// Try to serve cached image first
|
||||
try {
|
||||
const stats = await fs.stat(cachePath);
|
||||
if (stats.size > 0) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`
|
||||
});
|
||||
return res.end(await fs.readFile(cachePath));
|
||||
}
|
||||
} catch (e) {
|
||||
// Cache miss, proceed to generation
|
||||
}
|
||||
|
||||
// Generate on-demand
|
||||
const generated = await regenerateTagImage(tag, mode);
|
||||
if (generated) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`
|
||||
});
|
||||
return res.end(await fs.readFile(generated));
|
||||
}
|
||||
|
||||
// Fallback to deterministic SVG
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/svg+xml',
|
||||
'Cache-Control': `public, max-age=${CACHE_MAX_AGE}`
|
||||
});
|
||||
res.end(generateFallbackSvg(tag));
|
||||
});
|
||||
|
||||
// --- Admin: Force regeneration routes ---
|
||||
router.get('/admin/tag_image/regenerate_all', lib.auth, async (req, res) => {
|
||||
try {
|
||||
const tags = await db`SELECT DISTINCT tag FROM tags JOIN tags_assign ON tags.id = tags_assign.tag_id`;
|
||||
console.log(`[ADMIN] Triggering full tag image regeneration for ${tags.length} tags`);
|
||||
|
||||
// Fire-and-forget regeneration in batches to avoid overwhelming the system
|
||||
const batchSize = 10;
|
||||
(async () => {
|
||||
for (let i = 0; i < tags.length; i += batchSize) {
|
||||
const batch = tags.slice(i, i + batchSize);
|
||||
await Promise.all(batch.map(async (t) => {
|
||||
const hash = crypto.createHash('md5').update(t.tag).digest('hex');
|
||||
// Delete existing cache files first
|
||||
for (const m of [0, 1, 3]) {
|
||||
try { await fs.unlink(path.join(CACHE_DIR, `${hash}_${m}.webp`)); } catch {}
|
||||
await regenerateTagImage(t.tag, m).catch(e => console.error(`[ADMIN] Failed regen for ${t.tag} (mode ${m}):`, e));
|
||||
}
|
||||
}));
|
||||
}
|
||||
console.log(`[ADMIN] Full tag image regeneration completed`);
|
||||
})().catch(err => console.error('[ADMIN] Full regen background task failed:', err));
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, message: `Started regeneration for ${tags.length} tags in background.` })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] Failed to trigger full regeneration:', err);
|
||||
return res.reply({ code: 500, body: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get(/^\/admin\/tag_image\/regenerate\/(?<tag>.+)$/, lib.auth, async (req, res) => {
|
||||
const tag = decodeURIComponent(req.params.tag);
|
||||
const hash = crypto.createHash('md5').update(tag).digest('hex');
|
||||
|
||||
try {
|
||||
console.log(`[ADMIN] Regenerating images for tag: ${tag}`);
|
||||
for (const m of [0, 1, 3]) {
|
||||
try { await fs.unlink(path.join(CACHE_DIR, `${hash}_${m}.webp`)); } catch {}
|
||||
await regenerateTagImage(tag, m);
|
||||
}
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, message: `Regenerated images for tag "${tag}".` })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[ADMIN] Failed to regenerate tag "${tag}":`, err);
|
||||
return res.reply({ code: 500, body: 'Internal Server Error' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
};
|
||||
31
src/inc/routes/theme.mjs
Normal file
31
src/inc/routes/theme.mjs
Normal file
@@ -0,0 +1,31 @@
|
||||
import cfg from "../config.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/theme\//, async (req, res) => {
|
||||
let theme = req.url.pathname.split('/')[2] ?? cfg.websrv.themes[0];
|
||||
if(!cfg.websrv.themes.includes(theme))
|
||||
theme = cfg.websrv.themes[0];
|
||||
|
||||
return res.writeHead(301, {
|
||||
"Cache-Control": "no-cache, public",
|
||||
"Set-Cookie": `theme=${theme}; ${lib.getCookieOptions(null, false)}`,
|
||||
"Location": req.headers.referer ?? "/"
|
||||
}).end();
|
||||
});
|
||||
|
||||
router.get(/^\/tfull\//, async (req, res) => {
|
||||
let full = req.session.fullscreen;
|
||||
if(full == 1)
|
||||
full = 0;
|
||||
else
|
||||
full = 1;
|
||||
|
||||
return res.writeHead(301, {
|
||||
"Cache-Control": "no-cache, public",
|
||||
"Set-Cookie": `fullscreen=${full}; ${lib.getCookieOptions(null, false)}`,
|
||||
"Location": req.headers.referer ?? "/"
|
||||
}).end();
|
||||
});
|
||||
return router;
|
||||
};
|
||||
151
src/inc/routes/toptags.mjs
Normal file
151
src/inc/routes/toptags.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
import db from "../../inc/sql.mjs";
|
||||
import lib from "../../inc/lib.mjs";
|
||||
import cfg from "../../inc/config.mjs";
|
||||
import url from "url";
|
||||
|
||||
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
|
||||
|
||||
export default (router, tpl) => {
|
||||
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
|
||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||
const isGuest = !sessionObj;
|
||||
const modequery = lib.getMode(mode);
|
||||
|
||||
let restrictedFilter = db``;
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
restrictedFilter = db`
|
||||
AND t.id NOT IN ${db(cfg.nsfp)}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tags_assign ta_res
|
||||
WHERE ta_res.item_id = items.id
|
||||
AND ta_res.tag_id IN ${db(cfg.nsfp)}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const userExcludeFilter = excludedTags.length > 0
|
||||
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = items.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
|
||||
: db``;
|
||||
|
||||
// Step 1: Get tags sorted by exact count (fast, indexed)
|
||||
// Group by normalized to merge duplicates (e.g. "Music" and "music")
|
||||
const baseTags = await db`
|
||||
SELECT MIN(t.id) as id, MIN(t.tag) as tag, t.normalized, COUNT(DISTINCT items.id) AS total_items
|
||||
FROM tags t
|
||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE items.active = true
|
||||
AND t.id NOT IN (1, 2)
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
${userExcludeFilter}
|
||||
GROUP BY t.normalized
|
||||
HAVING COUNT(DISTINCT items.id) >= 1
|
||||
ORDER BY total_items DESC, MIN(t.id) DESC
|
||||
OFFSET ${offset}
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
// Step 2: In normal (non-strict) mode, replace counts with fuzzy counts
|
||||
// Only runs for the ~50 tags being displayed, not all tags
|
||||
if (!strict && baseTags.length > 0) {
|
||||
await Promise.all(baseTags.map(async (tag) => {
|
||||
if (!tag.normalized) return;
|
||||
const [row] = await db`
|
||||
SELECT COUNT(DISTINCT items.id) as total
|
||||
FROM tags t
|
||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||
AND items.active = true
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
${userExcludeFilter}
|
||||
`;
|
||||
tag.total_items = +row.total;
|
||||
}));
|
||||
}
|
||||
|
||||
return baseTags;
|
||||
};
|
||||
|
||||
const processTags = (tags) => tags.map(t => ({
|
||||
...t,
|
||||
safe_tag: t.normalized || encodeURIComponent(t.tag),
|
||||
encoded_tag: encodeURIComponent(t.tag)
|
||||
}));
|
||||
|
||||
// API endpoint for lazy loading tags
|
||||
router.get(/^\/api\/tags$/, async (req, res) => {
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const page = Math.max(1, +(query.page ?? 1));
|
||||
const offset = (page - 1) * TAGS_PER_PAGE;
|
||||
const mode = req.mode ?? 0;
|
||||
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
|
||||
|
||||
const tags = processTags(await getTagsQuery(mode, offset, TAGS_PER_PAGE, req.session, isStrict));
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
|
||||
return res.json({
|
||||
success: true,
|
||||
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||
currentPage: page,
|
||||
hasMore: tags.length === TAGS_PER_PAGE
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
tags,
|
||||
currentPage: page,
|
||||
hasMore: tags.length === TAGS_PER_PAGE
|
||||
});
|
||||
});
|
||||
|
||||
// Main tags page
|
||||
router.get(/^\/tags$/, async (req, res) => {
|
||||
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
|
||||
const mode = req.mode ?? 0;
|
||||
const query = req.url.qs || {};
|
||||
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
|
||||
|
||||
const toptags = processTags(await getTagsQuery(mode, 0, TAGS_PER_PAGE, req.session, isStrict));
|
||||
|
||||
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');
|
||||
|
||||
const data = {
|
||||
toptags: toptags,
|
||||
phrase,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'Tags',
|
||||
description: `Browse ${toptags.length}+ tags`,
|
||||
url: `https://${cfg.main.url.domain}/tags`
|
||||
}
|
||||
};
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({
|
||||
body: tpl.render('tags-partial', data, req)
|
||||
});
|
||||
}
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('tags', data, req)
|
||||
});
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
52
src/inc/routes/upload.mjs
Normal file
52
src/inc/routes/upload.mjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import lib from "../lib.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getMinTags } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/upload$/, lib.userauth, async (req, res) => {
|
||||
let maxfilesize = cfg.main.maxfilesize;
|
||||
if (req.session.admin || req.session.is_moderator) {
|
||||
maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
|
||||
}
|
||||
const max_file_size = lib.formatSize(maxfilesize);
|
||||
|
||||
// Calculate uploads remaining (admins/mods are exempt)
|
||||
let uploads_remaining = null;
|
||||
if (!req.session.admin && !req.session.is_moderator) {
|
||||
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
|
||||
const uploadCount = await db`
|
||||
SELECT count(*) as count
|
||||
FROM items
|
||||
WHERE username = ${req.session.user}
|
||||
AND stamp > ${twelveHoursAgo}
|
||||
AND is_deleted = false
|
||||
`;
|
||||
uploads_remaining = Math.max(0, cfg.main.upload_limit - parseInt(uploadCount[0].count));
|
||||
}
|
||||
|
||||
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({
|
||||
body: tpl.render('upload', {
|
||||
tmp: null,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
max_file_size: max_file_size,
|
||||
min_tags: getMinTags(),
|
||||
uploads_remaining: uploads_remaining,
|
||||
allowed_mimes: Object.keys(cfg.mimes).join(','),
|
||||
mimes_json: JSON.stringify(cfg.mimes),
|
||||
web_url_upload: !!cfg.websrv.web_url_upload,
|
||||
page_meta: {
|
||||
title: 'upload',
|
||||
description: 'Upload content to w0bm',
|
||||
url: `https://${cfg.main.url.domain}/upload`
|
||||
}
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
return router;
|
||||
};
|
||||
390
src/inc/routes/user_halls.mjs
Normal file
390
src/inc/routes/user_halls.mjs
Normal file
@@ -0,0 +1,390 @@
|
||||
import db from "../sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { createHash } from "crypto";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
const slugify = (s) =>
|
||||
s.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
|
||||
// Simple auth guard — redirects to login page for browser, 401 for API calls
|
||||
const requireLogin = (req, res) => {
|
||||
if (req.session) return true;
|
||||
const isApi = req.url.pathname.startsWith('/api/');
|
||||
if (isApi) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Login required' }));
|
||||
} else {
|
||||
res.writeHead(302, { Location: '/login' }).end();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// ── Helper: resolve user hall, checking privacy ──────────────────────────────
|
||||
const resolveHall = async (ownerName, slug, viewerSession) => {
|
||||
const hall = await f0cklib.getUserHallByOwnerName(ownerName, slug);
|
||||
if (!hall) return null;
|
||||
// Private check: only owner or admin can see private halls
|
||||
if (hall.is_private) {
|
||||
const isOwner = viewerSession && viewerSession.user?.toLowerCase() === ownerName.toLowerCase();
|
||||
const isAdmin = viewerSession && viewerSession.admin;
|
||||
if (!isOwner && !isAdmin) return null;
|
||||
}
|
||||
return hall;
|
||||
};
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// ── Public browse routes ────────────────────────────────────────────────────
|
||||
|
||||
// List halls for a user
|
||||
router.get(/^\/user\/(?<owner>[^/]+)\/halls\/?$/, async (req, res) => {
|
||||
const ownerName = decodeURIComponent(req.params.owner);
|
||||
const mode = req.mode ?? 0;
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
|
||||
// Resolve owner user record
|
||||
const ownerRow = (await db`SELECT id, "user", admin FROM "user" WHERE "user" ILIKE ${ownerName} LIMIT 1`)[0];
|
||||
if (!ownerRow) {
|
||||
return res.reply({ code: 404, body: tpl.render('error', { message: 'User not found', tmp: null }, req) });
|
||||
}
|
||||
|
||||
const viewerUserId = req.session?.id ?? null;
|
||||
const hallsList = await f0cklib.getUserHalls(ownerRow.id, mode, excludedTags, viewerUserId);
|
||||
const isOwner = viewerUserId === ownerRow.id;
|
||||
|
||||
const data = {
|
||||
hallsList,
|
||||
ownerUser: ownerRow,
|
||||
isOwner,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: `${ownerRow.user}'s Halls`,
|
||||
description: `Browse ${ownerRow.user}'s personal collections`,
|
||||
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(ownerRow.user)}/halls`
|
||||
}
|
||||
};
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ body: tpl.render('user-halls-partial', data, req) });
|
||||
}
|
||||
return res.reply({ body: tpl.render('user-halls', data, req) });
|
||||
});
|
||||
|
||||
// Item grid for a user hall
|
||||
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)(?:\/p\/(?<page>\d+))?\/?$/, async (req, res) => {
|
||||
const ownerName = decodeURIComponent(req.params.owner);
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
|
||||
const hall = await resolveHall(ownerName, slug, req.session);
|
||||
if (!hall) {
|
||||
return res.reply({ code: 404, body: tpl.render('error', { message: 'Hall not found', tmp: null }, req) });
|
||||
}
|
||||
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: req.params.page,
|
||||
mode: req.mode,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
userHallOwner: ownerName,
|
||||
mime: req.cookies.mime || null,
|
||||
random: req.cookies.random_mode === '1'
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
data.items = [];
|
||||
data.pagination = { start: 1, end: 1, current: 1, page: 1, cheat: [1], prev: null, next: null };
|
||||
data.total = 0;
|
||||
data.success = true;
|
||||
data.link = { main: `/user/${encodeURIComponent(hall.owner_name)}/hall/${encodeURIComponent(hall.slug)}/`, path: 'p/', suffix: '' };
|
||||
data.tmp = { userHall: hall, userHallOwner: hall.owner_name };
|
||||
}
|
||||
|
||||
data.session = req.session ? { ...req.session } : false;
|
||||
data.isOwner = !!(req.session && req.session.id === hall.user_id);
|
||||
data.page_meta = {
|
||||
title: `${hall.name} — ${hall.owner_name}'s Hall`,
|
||||
description: hall.description || `${hall.owner_name}'s collection`,
|
||||
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(hall.owner_name)}/hall/${encodeURIComponent(hall.slug)}`
|
||||
};
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({ body: tpl.render('index-partial', data, req) });
|
||||
}
|
||||
return res.reply({ body: tpl.render('index', data, req) });
|
||||
});
|
||||
|
||||
// Single item within a user hall
|
||||
router.get(/^\/user\/(?<owner>[^/]+)\/hall\/(?<slug>[^/]+)\/(?<itemid>\d+)\/?$/, async (req, res) => {
|
||||
const ownerName = decodeURIComponent(req.params.owner);
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
|
||||
const hall = await resolveHall(ownerName, slug, req.session);
|
||||
if (!hall) {
|
||||
return res.reply({ code: 404, body: tpl.render('error', { message: 'Hall not found', tmp: null }, req) });
|
||||
}
|
||||
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: req.params.itemid,
|
||||
mode: req.mode,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
userHallOwner: ownerName,
|
||||
mime: req.cookies.mime || null,
|
||||
random: req.cookies.random_mode === '1'
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
return res.reply({
|
||||
code: data.item ? 200 : 404,
|
||||
body: tpl.render('error', { message: data.message, item: data.item, tmp: null }, req)
|
||||
});
|
||||
}
|
||||
|
||||
data.hidePagination = true;
|
||||
data.session = req.session ? { ...req.session } : false;
|
||||
|
||||
// Precompute hall display
|
||||
if (data.item?.halls?.length) {
|
||||
data.item.primaryHall = data.item.halls[0];
|
||||
data.item.otherHalls = data.item.halls.slice(1);
|
||||
} else if (data.item) {
|
||||
data.item.primaryHall = null;
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id) f0cklib.markNotificationsRead(req.session.id, req.params.itemid).catch(() => {});
|
||||
const useLegacy = req.session
|
||||
? (req.session.use_new_layout === false)
|
||||
: (cfg.websrv.default_layout === 'legacy');
|
||||
const sort = useLegacy ? 'old' : 'new';
|
||||
data.comments = await f0cklib.getComments(req.params.itemid, sort, false);
|
||||
data.isSubscribed = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
data.commentsJSON = Buffer.from(JSON.stringify(data.comments || [])).toString('base64');
|
||||
} else {
|
||||
data.comments = [];
|
||||
data.isSubscribed = false;
|
||||
data.commentsJSON = Buffer.from('[]').toString('base64');
|
||||
}
|
||||
|
||||
return res.reply({ body: tpl.render('item', data, req) });
|
||||
});
|
||||
|
||||
// ── Thumbnail route ─────────────────────────────────────────────────────────
|
||||
router.get(/^\/user_hall_image\/(?<userId>\d+)\/(?<slug>.+)$/, async (req, res) => {
|
||||
const userId = +req.params.userId;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
const mode = +(req.url.qs?.m ?? 0);
|
||||
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
const customPath = path.join(CUSTOM_DIR, `u_${userId}_${slug}.webp`);
|
||||
|
||||
try {
|
||||
// 1. Serve custom image if present
|
||||
try {
|
||||
const stat = await fs.stat(customPath);
|
||||
const etag = '"' + stat.mtimeMs.toString(16) + '-' + stat.size.toString(16) + '"';
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
res.writeHead(304); return res.end();
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'no-cache', 'ETag': etag });
|
||||
return res.end(await fs.readFile(customPath));
|
||||
} catch (_) { /* no custom image */ }
|
||||
|
||||
// 2. Check mosaic cache
|
||||
const hash = createHash('md5').update(`uh_${userId}_${slug}_${mode}`).digest('hex');
|
||||
const cachePath = path.join(CACHE_DIR, `${hash}.webp`);
|
||||
try {
|
||||
await fs.access(cachePath);
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||
return res.end(await fs.readFile(cachePath));
|
||||
} catch (_) {}
|
||||
|
||||
// 3. Generate mosaic
|
||||
const hall = await f0cklib.getUserHall(userId, slug);
|
||||
if (!hall) { res.writeHead(302, { Location: '/s/img/favicon.gif' }); return res.end(); }
|
||||
|
||||
let modeFilter = db``;
|
||||
if (mode === 0) modeFilter = db`JOIN tags_assign ta_sfw ON ta_sfw.item_id = i.id AND ta_sfw.tag_id = 1`;
|
||||
else if (mode === 1) modeFilter = db`JOIN tags_assign ta_nsfw ON ta_nsfw.item_id = i.id AND ta_nsfw.tag_id = 2`;
|
||||
|
||||
const items = await db`
|
||||
SELECT i.id
|
||||
FROM items i
|
||||
JOIN user_halls_assign uha ON uha.item_id = i.id
|
||||
${modeFilter}
|
||||
WHERE uha.hall_id = ${hall.id} AND i.active = true
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
if (items.length > 0) {
|
||||
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
await execFile('magick', [
|
||||
...inputs, '+append', '-background', 'none',
|
||||
'-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath
|
||||
]);
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||
return res.end(await fs.readFile(cachePath));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[USER_HALL_IMAGE]', e);
|
||||
}
|
||||
res.writeHead(302, { Location: '/s/img/favicon.gif' });
|
||||
res.end();
|
||||
});
|
||||
|
||||
// ── API: list own halls (for modal) ────────────────────────────────────────
|
||||
router.get(/^\/api\/v2\/me\/halls\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
try {
|
||||
const halls = await f0cklib.getUserHalls(req.session.id, 3, [], req.session.id);
|
||||
const body = JSON.stringify({ success: true, halls });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(body);
|
||||
} catch (e) {
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
|
||||
}
|
||||
});
|
||||
|
||||
// ── API: create hall ────────────────────────────────────────────────────────
|
||||
router.post(/^\/api\/v2\/me\/halls\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
const name = (req.post.name || '').trim();
|
||||
const slug = slugify(req.post.slug || name);
|
||||
const description = (req.post.description || '').trim() || null;
|
||||
|
||||
if (!name || !slug) {
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: false, msg: 'Name is required' }));
|
||||
}
|
||||
|
||||
const result = await f0cklib.createUserHall(req.session.id, name, slug, description);
|
||||
const status = result.success ? 200 : 409;
|
||||
return res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(result));
|
||||
});
|
||||
|
||||
// ── API: update hall ────────────────────────────────────────────────────────
|
||||
router.patch(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
const { name, slug: newSlugRaw, description, is_private } = req.post;
|
||||
const newSlug = newSlugRaw ? slugify(newSlugRaw) : undefined;
|
||||
|
||||
const result = await f0cklib.updateUserHall(req.session.id, slug, {
|
||||
name,
|
||||
newSlug,
|
||||
description,
|
||||
is_private: is_private !== undefined ? (is_private === true || is_private === 'true' || is_private === 1) : undefined
|
||||
});
|
||||
const status = result.success ? 200 : 400;
|
||||
return res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(result));
|
||||
});
|
||||
|
||||
// ── API: delete hall ────────────────────────────────────────────────────────
|
||||
router.delete(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
|
||||
// Admins can also delete on behalf of any user if they pass ?user_id=
|
||||
let targetUserId = req.session.id;
|
||||
if (req.session.admin && req.url.qs?.user_id) {
|
||||
targetUserId = +req.url.qs.user_id;
|
||||
}
|
||||
|
||||
const result = await f0cklib.deleteUserHall(targetUserId, slug);
|
||||
|
||||
// Clean up custom image if it exists
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
fs.unlink(path.join(CUSTOM_DIR, `u_${targetUserId}_${slug}.webp`)).catch(() => {});
|
||||
|
||||
return res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(result));
|
||||
});
|
||||
|
||||
// ── API: add item to hall ────────────────────────────────────────────────────
|
||||
router.post(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/items\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
const itemId = +req.post.item_id;
|
||||
|
||||
if (!itemId) {
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: false, msg: 'Missing item_id' }));
|
||||
}
|
||||
|
||||
const hall = await f0cklib.getUserHall(req.session.id, slug);
|
||||
if (!hall) {
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
|
||||
}
|
||||
|
||||
const result = await f0cklib.addItemToUserHall(hall.id, itemId, req.session.id);
|
||||
return res.writeHead(result.success ? 200 : 409, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(result));
|
||||
});
|
||||
|
||||
// ── API: remove item from hall ──────────────────────────────────────────────
|
||||
router.delete(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/items\/(?<itemid>\d+)\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
const itemId = +req.params.itemid;
|
||||
|
||||
const hall = await f0cklib.getUserHall(req.session.id, slug);
|
||||
if (!hall) {
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
|
||||
}
|
||||
|
||||
const result = await f0cklib.removeItemFromUserHall(hall.id, itemId);
|
||||
return res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify(result));
|
||||
});
|
||||
|
||||
// ── API: upload custom hall image (handled via bypass middleware in index.mjs) ─
|
||||
// This stub is never reached for multipart uploads — the bypass intercepts first.
|
||||
router.post(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/image\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: false, msg: 'Send as multipart/form-data' }));
|
||||
});
|
||||
|
||||
// ── API: delete custom hall image (can go through normal router) ─────────
|
||||
router.delete(/^\/api\/v2\/me\/halls\/(?<slug>[^/]+)\/image\/?$/, async (req, res) => {
|
||||
if (!requireLogin(req, res)) return;
|
||||
const slug = decodeURIComponent(req.params.slug);
|
||||
const hall = await f0cklib.getUserHall(req.session.id, slug);
|
||||
if (!hall) {
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: false, msg: 'Hall not found' }));
|
||||
}
|
||||
|
||||
const CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
await fs.unlink(path.join(CUSTOM_DIR, `u_${req.session.id}_${slug}.webp`)).catch(() => {});
|
||||
// Clear mosaic cache entries for all modes
|
||||
for (const m of [0, 1, 2]) {
|
||||
const h = createHash('md5').update(`uh_${req.session.id}_${slug}_${m}`).digest('hex');
|
||||
await fs.unlink(path.join(CACHE_DIR, `${h}.webp`)).catch(() => {});
|
||||
}
|
||||
await db`UPDATE user_halls SET custom_image = false WHERE id = ${hall.id}`;
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
.end(JSON.stringify({ success: true }));
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
81
src/inc/routes/warnings.mjs
Normal file
81
src/inc/routes/warnings.mjs
Normal file
@@ -0,0 +1,81 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// Mod/Admin: Issue a warning to a user
|
||||
router.post(/^\/api\/v2\/mod\/warnings\/issue\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { user_id, reason } = req.post;
|
||||
|
||||
if (!user_id || !reason || reason.trim().length === 0) {
|
||||
return res.json({ success: false, msg: "User ID and reason are required." }, 400);
|
||||
}
|
||||
|
||||
const result = await db`
|
||||
INSERT INTO user_warnings (user_id, admin_id, reason)
|
||||
VALUES (${+user_id}, ${req.session.id}, ${reason.trim()})
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// Broadcast to SSE clients instantly
|
||||
if (result.length > 0) {
|
||||
await db`SELECT pg_notify('warnings', ${JSON.stringify({
|
||||
user_id: +user_id,
|
||||
warning_id: result[0].id,
|
||||
reason: reason.trim()
|
||||
})})`;
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
const targetUser = await db`SELECT login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`;
|
||||
const username = targetUser.length > 0 ? targetUser[0].user : String(user_id);
|
||||
await audit.log(req.session.id, 'issue_warning', 'user', +user_id, { reason: reason.trim(), target_user: username });
|
||||
|
||||
return res.json({ success: true, msg: "Warning issued successfully." });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: lib.logError(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// User: Fetch active (unacknowledged) warnings
|
||||
router.get(/^\/api\/v2\/user\/warnings\/?$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const warnings = await db`
|
||||
SELECT id, reason, created_at
|
||||
FROM user_warnings
|
||||
WHERE user_id = ${req.session.id} AND acknowledged = FALSE
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
|
||||
return res.json({ success: true, warnings });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: lib.logError(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// User: Acknowledge a warning
|
||||
router.post(/^\/api\/v2\/user\/warnings\/(?<id>\d+)\/acknowledge\/?$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const id = +req.params.id;
|
||||
|
||||
const result = await db`
|
||||
UPDATE user_warnings
|
||||
SET acknowledged = TRUE
|
||||
WHERE id = ${id} AND user_id = ${req.session.id}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
return res.json({ success: false, msg: "Warning not found or already acknowledged." }, 404);
|
||||
}
|
||||
|
||||
return res.json({ success: true, msg: "Warning acknowledged." });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: lib.logError(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
126
src/inc/security.mjs
Normal file
126
src/inc/security.mjs
Normal file
@@ -0,0 +1,126 @@
|
||||
import crypto from "crypto";
|
||||
import db from "./sql.mjs";
|
||||
import cfg from "./config.mjs";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MINUTES = 600; // 10 hours
|
||||
const MAX_ATTEMPTS = 5;
|
||||
|
||||
export default new class {
|
||||
/**
|
||||
* Anonymize IP address using Hmac-SHA256 with a secret salt.
|
||||
* @param {string} ip
|
||||
* @returns {string}
|
||||
*/
|
||||
hashIP(ip) {
|
||||
if (!ip) return "unknown";
|
||||
const secret = cfg.main.invite_secret;
|
||||
if (!secret) {
|
||||
throw new Error('[FATAL] invite_secret is not configured. Set it in config.json to enable IP hashing. Refusing to use a predictable fallback salt.');
|
||||
}
|
||||
return crypto.createHmac("sha256", secret).update(ip).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real IP from request headers or socket.
|
||||
* @param {object} req
|
||||
* @returns {string}
|
||||
*/
|
||||
getRealIP(req) {
|
||||
let ip = req.headers['x-real-ip'] ||
|
||||
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
||||
req.socket.remoteAddress;
|
||||
|
||||
if (!ip) return "unknown";
|
||||
|
||||
// Handle IPv6 loopback and mapped IPv4
|
||||
if (ip === "::1") ip = "127.0.0.1";
|
||||
if (ip.startsWith("::ffff:")) ip = ip.substring(7);
|
||||
|
||||
// Basic IPv6 normalization (ensure consistent case and representation if possible)
|
||||
// Note: Simple hex strings for IP are fine for hashing as long as Nginx is consistent.
|
||||
if (ip.includes(":")) ip = ip.toLowerCase();
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an attempt in the database.
|
||||
* @param {string} ip
|
||||
* @param {string} username
|
||||
* @param {string} type 'login' | 'register'
|
||||
* @param {boolean} success
|
||||
*/
|
||||
async recordAttempt(ip, username, type, success) {
|
||||
const ip_hash = this.hashIP(ip);
|
||||
console.log(`[SECURITY] Recording ${type} attempt: user=${username}, success=${success}, ip_hash=${ip_hash}`);
|
||||
await db`
|
||||
insert into login_attempts (ip_hash, username, type, success)
|
||||
values (${ip_hash}, ${username?.toLowerCase() || null}, ${type}, ${success})
|
||||
`.catch(err => console.error(`[SECURITY] Failed to record ${type} attempt:`, err));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear failed attempts for a given IP and/or username.
|
||||
* @param {string} ip
|
||||
* @param {string} username
|
||||
*/
|
||||
async clearAttempts(ip, username) {
|
||||
const ip_hash = this.hashIP(ip);
|
||||
console.log(`[SECURITY] Clearing attempts for user=${username}, ip_hash=${ip_hash}`);
|
||||
await db`
|
||||
delete from login_attempts
|
||||
where (ip_hash = ${ip_hash} OR username = ${username?.toLowerCase() || ''})
|
||||
`.catch(err => console.error(`[SECURITY] Failed to clear attempts:`, err));
|
||||
}
|
||||
|
||||
async isRateLimited(ip, username, type) {
|
||||
const ip_hash = this.hashIP(ip);
|
||||
|
||||
let windowMinutes = RATE_LIMIT_WINDOW_MINUTES;
|
||||
let maxAttempts = MAX_ATTEMPTS;
|
||||
let onlyFailures = true;
|
||||
|
||||
if (type === 'password_reset_request') {
|
||||
windowMinutes = 1440; // 24 hours
|
||||
maxAttempts = 1;
|
||||
onlyFailures = false; // Count all attempts to prevent spam
|
||||
} else if (type === 'password_reset_execution') {
|
||||
windowMinutes = 60; // 1 hour
|
||||
maxAttempts = 5;
|
||||
onlyFailures = false; // Count all efforts
|
||||
}
|
||||
|
||||
const windowStart = new Date(Date.now() - windowMinutes * 60000);
|
||||
|
||||
console.log(`[SECURITY] Checking rate limit for ${type}: user=${username}, ip_hash=${ip_hash}`);
|
||||
|
||||
// Check attempts by IP
|
||||
const ipAttempts = await db`
|
||||
select count(*) as count
|
||||
from login_attempts
|
||||
where ip_hash = ${ip_hash}
|
||||
and type = ${type}
|
||||
${onlyFailures ? db`and success = false` : db``}
|
||||
and attempted_at > ${windowStart}
|
||||
`;
|
||||
|
||||
const ipCount = +ipAttempts[0].count;
|
||||
if (ipCount >= maxAttempts) return true;
|
||||
|
||||
// Check attempts by username (if provided)
|
||||
if (username) {
|
||||
const userAttempts = await db`
|
||||
select count(*) as count
|
||||
from login_attempts
|
||||
where username = ${username.toLowerCase()}
|
||||
and type = ${type}
|
||||
${onlyFailures ? db`and success = false` : db``}
|
||||
and attempted_at > ${windowStart}
|
||||
`;
|
||||
const userCount = +userAttempts[0].count;
|
||||
if (userCount >= maxAttempts) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
};
|
||||
39
src/inc/settings.mjs
Normal file
39
src/inc/settings.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
import cfg from "./config.mjs";
|
||||
|
||||
let manual_approval = true;
|
||||
let min_tags = 3;
|
||||
let registration_open = false;
|
||||
let trusted_uploads = 0;
|
||||
let bypass_duplicate_check = false;
|
||||
let protect_files = false;
|
||||
let private_messages = true;
|
||||
let default_layout = 'modern';
|
||||
|
||||
export const getManualApproval = () => manual_approval;
|
||||
export const setManualApproval = (val) => manual_approval = !!val;
|
||||
|
||||
export const getMinTags = () => min_tags;
|
||||
export const setMinTags = (val) => min_tags = parseInt(val) || 3;
|
||||
|
||||
export const getRegistrationOpen = () => {
|
||||
if (cfg.websrv.open_registration_web_toggle === false) {
|
||||
return !!cfg.websrv.open_registration;
|
||||
}
|
||||
return registration_open;
|
||||
};
|
||||
export const setRegistrationOpen = (val) => registration_open = !!val;
|
||||
|
||||
export const getTrustedUploads = () => trusted_uploads;
|
||||
export const setTrustedUploads = (val) => trusted_uploads = Math.max(0, parseInt(val) ?? 3);
|
||||
|
||||
export const getBypassDuplicateCheck = () => bypass_duplicate_check;
|
||||
export const setBypassDuplicateCheck = (val) => bypass_duplicate_check = !!val;
|
||||
|
||||
export const getProtectFiles = () => protect_files;
|
||||
export const setProtectFiles = (val) => protect_files = !!val;
|
||||
|
||||
export const getPrivateMessages = () => private_messages;
|
||||
export const setPrivateMessages = (val) => private_messages = !!val;
|
||||
|
||||
export const getDefaultLayout = () => default_layout;
|
||||
export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');
|
||||
4
src/inc/sql.mjs
Normal file
4
src/inc/sql.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
import postgres from "postgres";
|
||||
import cfg from "./config.mjs";
|
||||
|
||||
export default postgres(cfg.sql);
|
||||
64
src/inc/trigger/debug.mjs
Normal file
64
src/inc/trigger/debug.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import { getLevel } from "../admin.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import fetch from "flumm-fetch";
|
||||
import vm from "vm";
|
||||
|
||||
let maxoutput = 750;
|
||||
let context = vm.createContext({
|
||||
e: null,
|
||||
bot: null,
|
||||
admins: null,
|
||||
fetch,
|
||||
lib,
|
||||
console,
|
||||
|
||||
a: null,
|
||||
resolve: null
|
||||
});
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "level",
|
||||
call: /^!level (.*)/i,
|
||||
active: true,
|
||||
f: async e => {
|
||||
const user = e.message.trim().substring(7);
|
||||
await e.reply( JSON.stringify( getLevel( e.self.user.get(user) || {} ) ) );
|
||||
}
|
||||
}, {
|
||||
name: "self",
|
||||
call: /^!self$/i,
|
||||
active: true,
|
||||
f: async e => {
|
||||
await e.reply( JSON.stringify( e.user ) );
|
||||
}
|
||||
}, {
|
||||
name: "sandbox_debug",
|
||||
call: /^\!f0ck debug (.*)/i,
|
||||
active: true,
|
||||
level: 100,
|
||||
f: async e => {
|
||||
const args = e.message.trim().substring(12);
|
||||
|
||||
context.e = e;
|
||||
context.bot = bot;
|
||||
context.level = getLevel;
|
||||
context.hasTag = lib.hasTag;
|
||||
context.a = null;
|
||||
|
||||
await new Promise(resolve => {
|
||||
context.resolve = resolve;
|
||||
const code = "Promise.resolve().then(async result => { a = await (async () => "+args+")(); resolve(); }).catch(err => { a = err; resolve(); })";
|
||||
const script = new vm.Script(code);
|
||||
script.runInContext(context);
|
||||
});
|
||||
|
||||
let output = JSON.stringify(context.a);
|
||||
if(output.length > maxoutput)
|
||||
return await e.reply(`fuggg, Ausgabe wäre viel zu lang! (${output.length} Zeichen :DDDDDD)`);
|
||||
else
|
||||
return await e.reply(output);
|
||||
}
|
||||
}];
|
||||
};
|
||||
113
src/inc/trigger/delete.mjs
Normal file
113
src/inc/trigger/delete.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import { promises as fs } from "fs";
|
||||
import db from "../sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import path from "path";
|
||||
import { getLevel } from "../../inc/admin.mjs";
|
||||
import { moveToDeleted } from "../lib_delete.mjs";
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "delete",
|
||||
call: /^\!(del|rm) .*/i,
|
||||
active: true,
|
||||
f: async e => {
|
||||
let deleted = [];
|
||||
|
||||
for (let id of e.args) {
|
||||
id = +id;
|
||||
if (id <= 1)
|
||||
continue;
|
||||
|
||||
const f0ck = await db`
|
||||
select dest, mime, username, userchannel, usernetwork
|
||||
from "items"
|
||||
where
|
||||
id = ${id} and
|
||||
active = 'true'
|
||||
limit 1
|
||||
`;
|
||||
const level = getLevel(e.user).level;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
await e.reply(`f0ck ${id}: f0ck not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(f0ck[0].username !== (e.user.nick || e.user.username) ||
|
||||
f0ck[0].userchannel !== e.channel ||
|
||||
f0ck[0].usernetwork !== e.network) &&
|
||||
level < 100
|
||||
) {
|
||||
await e.reply(`f0ck ${id}: insufficient permissions`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (~~(new Date() / 1e3) >= (f0ck[0].stamp + 600) && level < 100) {
|
||||
await e.reply(`f0ck ${id}: too late lol`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await db`update "items" set active = 'false', is_deleted = true where id = ${id}`;
|
||||
|
||||
await moveToDeleted(f0ck[0].dest, id);
|
||||
await fs.copyFile(path.join(cfg.paths.t, id + '.webp'), path.join(cfg.paths.deleted, 't', id + '.webp')).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.t, id + '.webp')).catch(_ => { });
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await fs.copyFile(path.join(cfg.paths.ca, id + '.webp'), path.join(cfg.paths.deleted, 'ca', id + '.webp')).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.ca, id + '.webp')).catch(_ => { });
|
||||
}
|
||||
|
||||
deleted.push(id);
|
||||
}
|
||||
|
||||
await e.reply(`deleted ${deleted.length}/${e.args.length} f0cks (${deleted.join(",")})`);
|
||||
}
|
||||
}, {
|
||||
name: "recover",
|
||||
call: /^\!(recover) .*/i,
|
||||
active: true,
|
||||
level: 100,
|
||||
f: async e => {
|
||||
let recovered = [];
|
||||
|
||||
for (let id of e.args) {
|
||||
id = +id;
|
||||
if (id <= 1)
|
||||
continue;
|
||||
|
||||
const f0ck = await db`
|
||||
select dest, mime
|
||||
from "items"
|
||||
where
|
||||
id = ${id} and
|
||||
active = 'false'
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
await e.reply(`f0ck ${id}: f0ck not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.copyFile(path.join(cfg.paths.deleted, 'b', f0ck[0].dest), path.join(cfg.paths.b, f0ck[0].dest)).catch(_ => { });
|
||||
await fs.copyFile(path.join(cfg.paths.deleted, 't', id + '.webp'), path.join(cfg.paths.t, id + '.webp')).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', f0ck[0].dest)).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', id + '.webp')).catch(_ => { });
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await fs.copyFile(path.join(cfg.paths.deleted, 'ca', id + '.webp'), path.join(cfg.paths.ca, id + '.webp')).catch(_ => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', id + '.webp')).catch(_ => { });
|
||||
}
|
||||
|
||||
await db`update "items" set active = 'true' where id = ${id}`;
|
||||
|
||||
recovered.push(id);
|
||||
}
|
||||
|
||||
await e.reply(`recovered ${recovered.length}/${e.args.length} f0cks (${recovered.join(",")})`);
|
||||
}
|
||||
}]
|
||||
};
|
||||
103
src/inc/trigger/f0ck.mjs
Normal file
103
src/inc/trigger/f0ck.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
import fetch from "flumm-fetch";
|
||||
import { promises as fs } from "fs";
|
||||
import { exec } from "child_process";
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import path from "path";
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "f0ck",
|
||||
call: /^\!f0ck .*/i,
|
||||
active: true,
|
||||
level: 100,
|
||||
f: async e => {
|
||||
switch(e.args[0]) {
|
||||
case "stats":
|
||||
const dirs = {
|
||||
b: await fs.readdir(cfg.paths.b),
|
||||
t: await fs.readdir(cfg.paths.t),
|
||||
ca: await fs.readdir(cfg.paths.ca)
|
||||
};
|
||||
const sizes = {
|
||||
b: lib.formatSize((await Promise.all(dirs.b.map( async file => (await fs.stat(path.join(cfg.paths.b, file))).size)) ).reduce((a, b) => b + a)),
|
||||
t: lib.formatSize((await Promise.all(dirs.t.map( async file => (await fs.stat(path.join(cfg.paths.t, file))).size)) ).reduce((a, b) => b + a)),
|
||||
ca: lib.formatSize((await Promise.all(dirs.ca.map(async file => (await fs.stat(path.join(cfg.paths.ca, file))).size))).reduce((a, b) => b + a)),
|
||||
};
|
||||
return await e.reply(`${dirs.b.length} f0cks: ${sizes.b}, ${dirs.t.length} thumbnails: ${sizes.t}, ${dirs.ca.length} coverarts: ${sizes.ca}`);
|
||||
case "limit":
|
||||
return await e.reply(`up to ${lib.formatSize(cfg.main.maxfilesize)} (${lib.formatSize(cfg.main.maxfilesize * cfg.main.adminmultiplier)} for admins)`);
|
||||
case "thumb":
|
||||
const rows = await db`
|
||||
select id
|
||||
from "items"
|
||||
`;
|
||||
const dir = (await fs.readdir(cfg.paths.t)).filter(d => d.endsWith(".webp")).map(e => +e.split(".")[0]);
|
||||
const tmp = [];
|
||||
for(let row of rows)
|
||||
!dir.includes(row.id) ? tmp.push(row.id) : null;
|
||||
await e.reply(`${tmp.length}, ${rows.length}, ${dir.length}`);
|
||||
break;
|
||||
case "cache":
|
||||
cfg.websrv.cache = !cfg.websrv.cache;
|
||||
return await e.reply(`Cache is ${cfg.websrv.cache ? "enabled" : "disabled"}`);
|
||||
case "uptime":
|
||||
exec('sudo systemctl status f0ck', async (err, stdout) => {
|
||||
if(!err)
|
||||
return await e.reply(stdout.split('\n')[2].trim().replace("Active: active (running)", "i'm active"));
|
||||
});
|
||||
break;
|
||||
case "restart":
|
||||
await e.reply("hay hay patron, hemen!");
|
||||
exec("sudo systemctl restart f0ck");
|
||||
break;
|
||||
case "clearTmp":
|
||||
await Promise.all((await fs.readdir(cfg.paths.tmp)).filter(d => d !== ".empty").map(async d => fs.unlink(path.join(cfg.paths.tmp, d))));
|
||||
await e.reply("cleared lol");
|
||||
break;
|
||||
case "status":
|
||||
const tmpc = await lib.countf0cks();
|
||||
await e.reply(`tagged: ${tmpc.tagged}; untagged: ${tmpc.untagged}; sfw: ${tmpc.sfw}; nsfw: ${tmpc.nsfw}; total: ${tmpc.total}`);
|
||||
break;
|
||||
/*case "autotagger":
|
||||
const body = { headers: { Authorization: `Basic ${cfg.tagger.btoa}` } };
|
||||
const res = await (await fetch(`${cfg.tagger.endpoint}/usage`, body)).json();
|
||||
if(res) {
|
||||
const processed = res.result.monthly_processed;
|
||||
const limit = res.result.monthly_limit;
|
||||
return await e.reply(`autotagger: usage/limit: ${processed}/${limit}`);
|
||||
}
|
||||
return;
|
||||
break;*/
|
||||
/*case "renameTag":
|
||||
const origTag = e.args.slice(1).join(' ');
|
||||
|
||||
if(origTag.length <= 1)
|
||||
return await e.reply("absichtliche Provokation!");
|
||||
|
||||
const origTagID = (await sql('tags').where('tag', origTag))[0].id;
|
||||
|
||||
const affected = (await sql('tags_assign')
|
||||
.update({ 'tag_id': sql.raw('(select id from tags where tag = ?)', [ origTag ]) })
|
||||
.whereIn('tag_id', sql.raw('select id from tags where normalized = slugify(?)', [ origTag ]))
|
||||
).toString();
|
||||
|
||||
const deleted = (await sql('tags')
|
||||
.where('normalized', sql.raw('slugify(?)', [ origTag ]))
|
||||
.andWhereNot('id', origTagID)
|
||||
.del()
|
||||
);
|
||||
|
||||
await e.reply(JSON.stringify({ affected, deleted }));
|
||||
break;*/
|
||||
case "help":
|
||||
await e.reply("cmds: stats, limit, thumb, cache, uptime, restart, cleanTags, clearTmp, status");
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}]
|
||||
};
|
||||
77
src/inc/trigger/f0ckgag.mjs
Normal file
77
src/inc/trigger/f0ckgag.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
|
||||
const regex = new RegExp(`(https?:\\/\\/${cfg.main.url.regex})(\\/(?:video|image|audio|tag\\/[^/\\s]+|user\\/[^/\\s]+(?:\\/favs)?))?\\/(\\d+|(?:b\\/)\\w{8}\\.(?:jpg|webm|gif|mp4|png|mov|mp3|ogg|flac))`, 'gi');
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "f0ckgag",
|
||||
call: regex,
|
||||
active: true,
|
||||
f: async e => {
|
||||
const dat = e.message.match(regex)[0].split(/\//).pop();
|
||||
const rows = await db`
|
||||
select i.id, i.mime, i.size, i.username, i.stamp,
|
||||
(select t.tag from tags_assign ta join tags t on t.id = ta.tag_id where ta.item_id = i.id and t.id in (1,2) limit 1) as rating
|
||||
from "items" i
|
||||
${dat.includes('.')
|
||||
? db`where i.dest = ${dat}`
|
||||
: db`where i.id = ${dat}`
|
||||
}
|
||||
`;
|
||||
|
||||
if (rows.length === 0)
|
||||
return await e.reply("no f0cks given! lol D:");
|
||||
|
||||
const row = rows[0];
|
||||
const rating = row.rating || 'untagged';
|
||||
|
||||
let ratingStr = rating;
|
||||
if (e.type === 'irc') {
|
||||
const color = rating === 'sfw' ? 'green' : (rating === 'nsfw' ? 'red' : 'brown');
|
||||
ratingStr = `[color=${color}]${rating}[/color]`;
|
||||
} else if (e.type === 'matrix') {
|
||||
const color = rating === 'sfw' ? '#00ff00' : (rating === 'nsfw' ? '#ff0000' : '#888888');
|
||||
ratingStr = `[b][color=${color}]${rating}[/color][/b]`;
|
||||
// matrix.mjs format() handles [b], but not [color].
|
||||
// However, matrix.mjs send() handles objects with formatted_body.
|
||||
// Let's use a simpler approach that works with the existing formatter if possible,
|
||||
// or just construct the object.
|
||||
} else if (e.type === 'tg') {
|
||||
ratingStr = `[b]${rating}[/b]`;
|
||||
}
|
||||
|
||||
const link = `${cfg.main.url.full}/${row.id}`.replace('http://', 'https://');
|
||||
const msg = [
|
||||
link,
|
||||
ratingStr,
|
||||
`user: ${row.username}`,
|
||||
`~${lib.formatSize(row.size)}`,
|
||||
row.mime,
|
||||
new Date(row.stamp * 1e3).toString().slice(0, 24)
|
||||
].join(" - ");
|
||||
|
||||
if (e.type === 'matrix') {
|
||||
const color = rating === 'sfw' ? '#00ff00' : (rating === 'nsfw' ? '#ff0000' : '#888888');
|
||||
const formattedRating = `<font color="${color}"><b>${rating}</b></font>`;
|
||||
const formattedMsg = [
|
||||
link,
|
||||
formattedRating,
|
||||
`user: ${row.username}`,
|
||||
`~${lib.formatSize(row.size)}`,
|
||||
row.mime,
|
||||
new Date(row.stamp * 1e3).toString().slice(0, 24)
|
||||
].join(" - ");
|
||||
|
||||
return await e.reply({
|
||||
body: msg.replace(/\[\/?(b|color.*?|i)\]/g, ''),
|
||||
formatted_body: formattedMsg
|
||||
});
|
||||
}
|
||||
|
||||
await e.reply(msg);
|
||||
}
|
||||
}];
|
||||
};
|
||||
73
src/inc/trigger/f0ckrand.mjs
Normal file
73
src/inc/trigger/f0ckrand.mjs
Normal file
@@ -0,0 +1,73 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "f0ckrand",
|
||||
call: /^gib f0ck/i,
|
||||
active: false,
|
||||
f: async e => {
|
||||
let args = e.args.slice(1);
|
||||
|
||||
/*let rows = sql("items").select("id", "username", "mime", "size");
|
||||
|
||||
for(let i = 0; i < args.length; i++) {
|
||||
if(args[i].charAt(0) === "!")
|
||||
rows = rows.where("username", "not ilike", args[i].slice(1));
|
||||
else
|
||||
rows = rows.where("username", "ilike", args[i]);
|
||||
}
|
||||
|
||||
rows = await rows.orderByRaw("random()").limit(1);*/
|
||||
|
||||
let rows = [];
|
||||
|
||||
if (args.length === 0) {
|
||||
// Optimized random logic for no-args (global)
|
||||
const maxIdResult = await db`select max(id) as max_id from items`;
|
||||
const maxId = maxIdResult[0]?.max_id || 0;
|
||||
|
||||
if (maxId > 0) {
|
||||
const randomId = Math.floor(Math.random() * maxId);
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where id >= ${randomId} and active = true
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where active = true
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Filtered logic
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where
|
||||
${args.map(a => a.charAt(0) === "!"
|
||||
? db`username not ilike ${a.slice(1)}`
|
||||
: db`username ilike ${a}`
|
||||
).join(' and ')}
|
||||
order by random()
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (rows.length === 0)
|
||||
return await e.reply("nothing found, f0cker");
|
||||
|
||||
return await e.reply(`f0ckrnd: ${cfg.main.url.full}/${rows[0].id} by: ${rows[0].username} (${rows[0].mime}, ~${lib.formatSize(rows[0].size)})`);
|
||||
}
|
||||
}];
|
||||
};
|
||||
62
src/inc/trigger/help.mjs
Normal file
62
src/inc/trigger/help.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getLevel } from "../admin.mjs";
|
||||
|
||||
export default async self => {
|
||||
return [{
|
||||
name: "help",
|
||||
call: /^!help$/i,
|
||||
active: true,
|
||||
f: async e => {
|
||||
const userLevel = getLevel(e.user).level;
|
||||
const availableCommands = [];
|
||||
|
||||
for (const [name, trigger] of self._trigger.entries()) {
|
||||
// Skip if not active or not for this client
|
||||
if (!trigger.active) continue;
|
||||
if (trigger.clients && !trigger.clients.includes(e.type)) continue;
|
||||
|
||||
// Skip if user level is too low
|
||||
if (trigger.level > userLevel) continue;
|
||||
|
||||
// Determine display name for the command
|
||||
let cmdDisplay = name;
|
||||
if (trigger.call instanceof RegExp) {
|
||||
// Try to extract a clean string from common RegExp patterns
|
||||
cmdDisplay = trigger.call.source
|
||||
.replace(/^(\^)/, '') // Remove ^ first
|
||||
.replace(/(\$)$/, '') // Remove $
|
||||
.replace(/^\\?\\\!/, '!') // Change \! or \\! to !
|
||||
.replace(/(\.\*)$/, '...') // Change .* to ...
|
||||
.replace(/\\s\+/, ' ') // Change \s+ to space
|
||||
.replace(/\s\.\*/, ' ...') // Change space+.* to ...
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Filter out specific commands requested by the user
|
||||
// We check both name and display name to be safe
|
||||
const ignored = ['f0ck', 'tags', 'thumb', 'self', 'level', 'del', 'rm', 'recover', 'parser', 'thumbnails', 'link'];
|
||||
if (ignored.some(i =>
|
||||
name.toLowerCase().startsWith(i) ||
|
||||
cmdDisplay.toLowerCase().replace(/^!/, '').startsWith(i)
|
||||
)) continue;
|
||||
|
||||
availableCommands.push(cmdDisplay.startsWith('!') ? cmdDisplay : `!${cmdDisplay}`);
|
||||
}
|
||||
|
||||
// Deduplicate and sort
|
||||
const uniqueCommands = [...new Set(availableCommands)].sort();
|
||||
|
||||
if (uniqueCommands.length === 0) {
|
||||
availableCommands.push(e.type === 'matrix' ? "!w -sfw/nsfw -t tag1,tag2,tag3" : "!w <url> or attachment");
|
||||
} else {
|
||||
availableCommands.push(e.type === 'matrix' ? "!w -sfw/nsfw -t tag1,tag2,tag3" : "!w <url> or attachment");
|
||||
}
|
||||
|
||||
// Re-sort to include manually added command
|
||||
const finalCommands = [...new Set(availableCommands)].sort();
|
||||
|
||||
const message = `**Available Commands:**\n\`\`\`\n${finalCommands.join('\n')}\n\`\`\``;
|
||||
|
||||
await e.reply(message);
|
||||
}
|
||||
}];
|
||||
};
|
||||
45
src/inc/trigger/matrix_invite.mjs
Normal file
45
src/inc/trigger/matrix_invite.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import db from "../sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
|
||||
export default async self => {
|
||||
return [{
|
||||
name: "invite_matrix",
|
||||
call: /^!invite$/i,
|
||||
active: true,
|
||||
f: async e => {
|
||||
console.log(`[MATRIX INVITE] Triggered. Type: ${e.type}`);
|
||||
if (e.type !== 'matrix') return;
|
||||
|
||||
const mxid = e.user.account;
|
||||
const matrixClient = self.bot.clients.find(c => c.type === 'matrix')?.client;
|
||||
|
||||
if (!matrixClient) {
|
||||
return await e.reply("Matrix client not found.");
|
||||
}
|
||||
|
||||
// Check existing
|
||||
const existing = await db`select 1 from invite_tokens where created_by_matrix = ${mxid} limit 1`;
|
||||
if (existing.length > 0) {
|
||||
await matrixClient.sendmsg(null, mxid, "You have already generated an invite token.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate
|
||||
const secret = cfg.main.invite_secret;
|
||||
const token = lib.sha256(lib.createID() + secret).substring(0, 10).toUpperCase();
|
||||
|
||||
await db`
|
||||
insert into invite_tokens (token, created_at, created_by_matrix)
|
||||
values (${token}, ${~~(Date.now() / 1e3)}, ${mxid})
|
||||
`;
|
||||
|
||||
// DM
|
||||
const msg = `**Your Invite Token:** ${token}\n**Register here:** ${cfg.main.url.full}/register?token=${token}`;
|
||||
await matrixClient.sendmsg(null, mxid, msg);
|
||||
|
||||
// Reply in room (if not DM? But we ignore DMs now anyway so this trigger only works in public rooms)
|
||||
await e.reply("I've sent you a direct message with your invite token.");
|
||||
}
|
||||
}];
|
||||
};
|
||||
100
src/inc/trigger/matrix_link.mjs
Normal file
100
src/inc/trigger/matrix_link.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import db from "../sql.mjs";
|
||||
|
||||
export default async self => {
|
||||
return [{
|
||||
name: "matrix_link",
|
||||
call: /(^!link(?:\s+(.+))?$)|(^[a-z0-9]{6}$)/i, // Accepts !link, !link <token>, or just <token>
|
||||
active: true,
|
||||
f: async e => {
|
||||
// Only for Matrix
|
||||
if (e.type !== 'matrix') return false;
|
||||
|
||||
let token = e.args[0] || e.message; // args[0] if !link <token>, message if just token match
|
||||
|
||||
// Clean up token
|
||||
token = token.replace('!link', '').trim().toUpperCase();
|
||||
|
||||
// Senerio 1: User typed "!link" only (no token)
|
||||
if (!token) {
|
||||
// Send DM
|
||||
const matrixClient = self.bot.clients.find(c => c.type === 'matrix')?.client;
|
||||
if (matrixClient) {
|
||||
try {
|
||||
await matrixClient.sendmsg(null, e.user.account, "Please paste your link token here.");
|
||||
if (e.channel !== 'DM' && !e.channelid.includes(e.user.account)) {
|
||||
// Only reply in public if not already in DM (heuristics vary)
|
||||
await e.reply("I've sent you a DM. Please paste your token there.");
|
||||
}
|
||||
} catch (err) {
|
||||
await e.reply("I couldn't DM you. Please check your privacy settings or start a chat with me first.");
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Scenario 2: Processing a potential token
|
||||
try {
|
||||
// Find token
|
||||
const linkData = (await db`
|
||||
SELECT user_id FROM link_token WHERE token = ${token}
|
||||
`)[0];
|
||||
|
||||
if (!linkData) {
|
||||
// If it looked like a token but isn't valid, and was sent as !link <token>, warn.
|
||||
// If just a random 6-char string in public, ignore silently to avoid spam.
|
||||
if (e.message.toLowerCase().startsWith('!link')) {
|
||||
return await e.reply("Invalid or expired token. Please generate a new one.");
|
||||
}
|
||||
return false; // Not a command, not a valid token. Pass.
|
||||
}
|
||||
|
||||
// Format alias for Matrix (usually the sender ID)
|
||||
const alias = e.user.account; // This should be the full Matrix ID e.g. @user:server
|
||||
|
||||
// Check if already linked to THIS user
|
||||
const existing = await db`
|
||||
SELECT 1 FROM user_alias
|
||||
WHERE userid = ${linkData.user_id}
|
||||
AND lower(alias) = lower(${alias})
|
||||
AND type = 'matrix'
|
||||
`;
|
||||
|
||||
if (existing.length > 0) {
|
||||
return await e.reply("This Matrix account is already linked to your website profile.");
|
||||
}
|
||||
|
||||
// Check if this Matrix account is linked to ANOTHER user (prevent hijacking)
|
||||
const taken = await db`
|
||||
SELECT 1 FROM user_alias
|
||||
WHERE lower(alias) = lower(${alias})
|
||||
AND type = 'matrix'
|
||||
`;
|
||||
|
||||
if (taken.length > 0) {
|
||||
return await e.reply("This Matrix account is already linked to a different website profile.");
|
||||
}
|
||||
|
||||
// Perform Link
|
||||
await db`
|
||||
INSERT INTO user_alias (userid, alias, type)
|
||||
VALUES (${linkData.user_id}, ${alias}, 'matrix')
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
// Delete used token
|
||||
await db`DELETE FROM link_token WHERE token = ${token}`;
|
||||
|
||||
return await e.reply({
|
||||
body: `Successfully linked Matrix account ${alias} to your website profile!`,
|
||||
formatted_body: `Successfully linked Matrix account <b>${alias}</b> to your website profile!`
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("[Matrix Link] Error:", err);
|
||||
if (e.message.toLowerCase().startsWith('!link')) {
|
||||
return await e.reply("An internal error occurred while linking your account.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
};
|
||||
845
src/inc/trigger/parser.mjs
Normal file
845
src/inc/trigger/parser.mjs
Normal file
@@ -0,0 +1,845 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import { getLevel } from "../admin.mjs";
|
||||
import { getManualApproval, getMinTags } from "../settings.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
import autotagger from "../autotagger.mjs";
|
||||
import fetch from "flumm-fetch";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
|
||||
const regex = {
|
||||
all: /https?:\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:/~+#-]*[\w@?^=%&/~+#-])?/gi,
|
||||
yt: /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/gi,
|
||||
imgur: /(?:https?:)?\/\/(\w+\.)?imgur\.com\/\S+/i,
|
||||
fourchan: /https?:\/\/i\.4cdn\.org\/(\w+)\/(\d+)\.(\w{3,4})/i,
|
||||
instagram: /(?:https?:\/\/www\.)?instagram\.com\S*?\/(?:p|reel)\/(\w{11})\/?/im,
|
||||
ph: /(?:https?:\/\/)?(?:\w+\.)?pornhub\.(?:com|org)\/view_video\.php\?viewkey=([\w-]+)/i
|
||||
};
|
||||
const pcUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36";
|
||||
const mediagroupids = new Set();
|
||||
const extractJSON = (stdout) => {
|
||||
try {
|
||||
const lines = stdout.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('{') || trimmed.startsWith('[')) return JSON.parse(trimmed);
|
||||
}
|
||||
} catch (err) { }
|
||||
try { return JSON.parse(stdout); }
|
||||
catch(e) {
|
||||
console.error(`[EXTRACT_JSON ERROR] Failed to parse JSON from yt-dlp. First 500 chars: ${stdout.substring(0, 500)}`);
|
||||
console.error(`[EXTRACT_JSON DEBUG] Full output length: ${stdout.length}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "parser",
|
||||
call: new RegExp(`${regex.all.source}|^!w(0bm)?\\b`, 'i'),
|
||||
active: true,
|
||||
clients: ["irc", "tg", "slack", "discord", "matrix"],
|
||||
f: async e => {
|
||||
console.log(`[PARSER START] Triggered by ${e.user.nick} in ${e.channel} (${e.type}). Msg: '${e.message}'`);
|
||||
|
||||
const links = e.message.match(regex.all)?.filter(link => !link.includes(cfg.main.url.domain)) || [];
|
||||
let repost;
|
||||
if (e.media)
|
||||
links.push(e.media);
|
||||
|
||||
|
||||
|
||||
|
||||
// Matrix: Restrict to specific room if configured
|
||||
if (e.type === 'matrix') {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
console.log(`[PARSER DEBUG] Matrix Room Check: Config=${matrixCfg?.upload_channel_id}, Event=${e.channelid}`);
|
||||
if (matrixCfg?.upload_channel_id && e.channelid !== matrixCfg.upload_channel_id) {
|
||||
console.log(`[PARSER DEBUG] Room mismatch - Aborting.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// If no links found yet, check matrix reply
|
||||
if (links.length === 0) {
|
||||
if (e.type === 'matrix' && e.replyTo) {
|
||||
console.log(`[PARSER DEBUG] Inspecting replyTo:`, JSON.stringify(e.replyTo, null, 2));
|
||||
|
||||
// Relaxed permission check:
|
||||
// Allow !w if:
|
||||
// 1. User is replying to their own message OR
|
||||
// 2. We are in the designated upload channel (where anyone with a link can help tag) OR
|
||||
// 3. User is an admin/mod (level > 10)
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
const isUploadChannel = matrixCfg?.upload_channel_id && e.channelid === matrixCfg.upload_channel_id;
|
||||
const userLevel = (await getLevel(e.user)).level;
|
||||
|
||||
if (e.user.account !== e.replyTo.sender && !isUploadChannel && userLevel <= 10) {
|
||||
console.log(`[PARSER] Permission denied: ${e.user.account} tried to !w message from ${e.replyTo.sender} in non-upload channel ${e.channelid}`);
|
||||
await e.reply("You can only !w your own messages in this room.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Native Matrix Download Path
|
||||
if (e.replyTo.mxcUrl) {
|
||||
console.log(`[PARSER] Triggering native Matrix download for ${e.replyTo.mxcUrl}`);
|
||||
links.push(e.replyTo.mxcUrl);
|
||||
}
|
||||
// Fallback to URL if no MXC (shouldn't happen for media) or text links
|
||||
else if (e.replyTo.url) {
|
||||
links.push(e.replyTo.url);
|
||||
} else if (e.replyTo.message) {
|
||||
const replyLinks = e.replyTo.message.match(regex.all)?.filter(link => !link.includes(cfg.main.url.domain));
|
||||
if (replyLinks) links.push(...replyLinks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[PARSER DEBUG] Final links count: ${links.length}`, links);
|
||||
if (links.length === 0) {
|
||||
console.log(`[PARSER DEBUG] No links found, aborting.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[PARSER DEBUG] Proceeding with links:`, links);
|
||||
|
||||
if (e.message.match(/\!i(gnore)?\b/)) {
|
||||
console.log(`[PARSER DEBUG] Ignored due to !ignore flag.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for !w command
|
||||
const isWCommand = e.message.match(/\!w(0bm)?\b/i);
|
||||
|
||||
// Matrix-specific: enforce strict !w format
|
||||
let matrixRating = null; // 'sfw' or 'nsfw'
|
||||
let matrixTags = []; // user-provided tags
|
||||
let isNSFW = false;
|
||||
if (isWCommand && e.type === 'matrix') {
|
||||
const msgLower = e.message.toLowerCase();
|
||||
const hasSfw = /(?:^|\s)-sfw\b/i.test(msgLower);
|
||||
const hasNsfw = /(?:^|\s)-nsfw\b/i.test(msgLower);
|
||||
console.log(`[PARSER DEBUG] Matrix Flags: hasSfw=${hasSfw}, hasNsfw=${hasNsfw}, msg='${msgLower}'`);
|
||||
const hasRating = hasSfw || hasNsfw;
|
||||
const tagMatch = e.message.match(/-t\s+([^\s].+)/i);
|
||||
const hasTags = !!tagMatch;
|
||||
|
||||
// Bare !w with no arguments → show help
|
||||
if (!hasRating && !hasTags) {
|
||||
await e.reply(`!w -sfw/nsfw -t tag1,tag2,tag3 (at least ${getMinTags()} tags required)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both ratings specified
|
||||
if (hasSfw && hasNsfw) {
|
||||
await e.reply('pick one: -sfw or -nsfw, not both');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Missing rating
|
||||
if (!hasRating) {
|
||||
await e.reply('missing rating: !w -sfw/nsfw -t tag1,tag2,tag3');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse rating
|
||||
matrixRating = hasNsfw ? 'nsfw' : 'sfw';
|
||||
if (hasNsfw) isNSFW = true;
|
||||
|
||||
const minTags = getMinTags();
|
||||
|
||||
// Tag parsing: try -t first, then fallback to everything after flags
|
||||
let tagsInput = "";
|
||||
if (tagMatch) {
|
||||
tagsInput = tagMatch[1];
|
||||
} else {
|
||||
// Remove links, !w and -sfw/nsfw to get tags
|
||||
tagsInput = e.message.replace(regex.all, "")
|
||||
.replace(/\!w(0bm)?\b/i, "")
|
||||
.replace(/\s*-(nsfw|sfw)\b/gi, "")
|
||||
.replace(/\s*-t\b/gi, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Split by commas only to support multi-word tags
|
||||
matrixTags = tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
|
||||
if (matrixTags.length < minTags) {
|
||||
await e.reply(`at least ${minTags} tags required (got ${matrixTags.length}): !w -sfw/nsfw tag1, tag2, tag3`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for Discord/non-TG clients where e.raw might be different or undefined
|
||||
const isForwarded = e.raw?.forward_from !== undefined;
|
||||
const mediaGroupId = e.raw?.media_group_id;
|
||||
|
||||
if ((e.type === 'matrix' || !e.channel.includes("w0bm")) && (!isWCommand && !isForwarded)) {
|
||||
console.log(`[PARSER DEBUG] Channel/Client check failed. Channel: ${e.channel}, Type: ${e.type}, IsW: ${!!isWCommand}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- LINKED ACCOUNT CHECK ---
|
||||
let websiteUser = null;
|
||||
|
||||
if (e.type === 'matrix' || e.type === 'discord') {
|
||||
// Identify lookup key
|
||||
// Matrix: Use unique ID (MXID)
|
||||
// Discord: Use Nick or Username (Legacy) - TODO: Migration to ID recommended later
|
||||
let lookupAlias = e.user.nick || e.user.username;
|
||||
let lookupType = 'discord'; // default check
|
||||
|
||||
if (e.type === 'matrix') {
|
||||
lookupAlias = e.user.account; // MXID
|
||||
lookupType = 'matrix';
|
||||
}
|
||||
|
||||
// Check DB
|
||||
try {
|
||||
// We check type match OR null type (legacy discord)
|
||||
const linked = (await db`
|
||||
SELECT "user".id, "user"."user"
|
||||
FROM user_alias
|
||||
JOIN "user" ON "user".id = user_alias.userid
|
||||
WHERE lower(user_alias.alias) = lower(${lookupAlias})
|
||||
AND (user_alias.type = ${lookupType} OR user_alias.type IS NULL)
|
||||
`);
|
||||
|
||||
if (linked && linked.length > 0) {
|
||||
websiteUser = linked[0];
|
||||
console.log(`[PARSER] Linked account found: ${websiteUser.user} (ID: ${websiteUser.id})`);
|
||||
}
|
||||
} catch(err) {
|
||||
console.error('[PARSER] DB Link check error:', err);
|
||||
}
|
||||
|
||||
// Enforce Link for !w command
|
||||
if (isWCommand && !websiteUser) {
|
||||
await e.reply(`You must link your account to use this command. Go to ${cfg.main.url.full}/settings to link your ${e.type} account.`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// -----------------------------
|
||||
|
||||
// Restrict Discord uploads to specific channel if configured
|
||||
if (e.type === 'discord') {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
const allowedChannel = discordClient?.upload_channel_id;
|
||||
if (allowedChannel && e.channelid !== allowedChannel) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.type === 'tg' && // proto: tg
|
||||
!isWCommand && // !w / !w0bm
|
||||
!e.raw?.forward_date && // is forwarded?
|
||||
!mediagroupids.has(mediaGroupId) // prepared mediagroup?
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
else if (mediaGroupId && isWCommand) {
|
||||
mediagroupids.add(mediaGroupId);
|
||||
}
|
||||
|
||||
console.log(`parsing ${links.length} link${links.length > 1 ? "s" : ""}...`);
|
||||
|
||||
// Use for..of to handle async await properly and serialize debugging
|
||||
for (const link of links) {
|
||||
console.log(`[PARSER LOOP] Processing link: ${link}`);
|
||||
// check repost (link)
|
||||
try {
|
||||
repost = await queue.checkrepostlink(link);
|
||||
} catch (e) {
|
||||
console.error(`[PARSER LOOP] Checkrepost failed:`, e);
|
||||
}
|
||||
|
||||
if (repost) {
|
||||
await e.reply(`repost motherf0cker (link): ${cfg.main.url.full}/${repost}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[PARSER LOOP] Repost check passed. Gen UUID...`);
|
||||
// generate uuid
|
||||
const uuid = await queue.genuuid();
|
||||
|
||||
const maxfilesize = (getLevel(e.user).level > 50 ? cfg.main.maxfilesize * cfg.main.adminmultiplier : cfg.main.maxfilesize);
|
||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
||||
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
|
||||
|
||||
console.log(`[PARSER LOOP] Reading metadata...`);
|
||||
// read metadata
|
||||
let ext;
|
||||
let lastErr = null;
|
||||
if (link.startsWith('mxc://')) {
|
||||
// Native Matrix download, skip metadata fetch
|
||||
// Priority 1: use mimetype from current event or replyTo
|
||||
const mxcMime = e.replyTo?.mimetype || e.mimetype || null;
|
||||
if (mxcMime && cfg.mimes[mxcMime]) {
|
||||
ext = cfg.mimes[mxcMime];
|
||||
} else {
|
||||
// Priority 2: extract extension from original filename
|
||||
const fname = e.replyTo?.filename || e.filename || '';
|
||||
const fnExt = fname.includes('.') ? fname.split('.').pop().toLowerCase() : '';
|
||||
ext = (fnExt && Object.values(cfg.mimes).includes(fnExt)) ? fnExt : 'bin';
|
||||
}
|
||||
console.log(`[PARSER] mxc:// mime=${mxcMime}, filename=${e.replyTo?.filename || e.filename}, resolved ext=${ext}`);
|
||||
}
|
||||
else if (link.match(regex.ph)) {
|
||||
// is pornhub
|
||||
isNSFW = true;
|
||||
try {
|
||||
// Added referer to help with fragment 404s and metadata extraction
|
||||
const out = await queue.spawn('yt-dlp', [...ytdlpArgs, '--referer', 'https://www.pornhub.com', '--no-progress', '--no-warnings', '--user-agent', pcUA, '--skip-download', '--dump-json', link]);
|
||||
const meta = extractJSON(out.stdout);
|
||||
ext = meta.ext;
|
||||
console.log(`[PARSER DEBUG] Pornhub metadata success. Ext: ${ext}`);
|
||||
} catch (err) {
|
||||
console.error(`[METADATA ERROR] Pornhub yt-dlp failed for ${link}:`, err.message);
|
||||
if (err.stderr) console.error(`[METADATA ERROR] stderr:`, err.stderr);
|
||||
lastErr = err;
|
||||
try {
|
||||
const headFetch = await fetch(link, {
|
||||
method: "HEAD",
|
||||
headers: { 'User-Agent': pcUA }
|
||||
});
|
||||
const tmphead = headFetch.headers["content-type"];
|
||||
const status = headFetch.status || headFetch.statusCode || (headFetch.res ? headFetch.res.statusCode : 'unknown');
|
||||
console.log(`[PARSER DEBUG] Fallback fetch for ${link} (Status: ${status}): ${tmphead}`);
|
||||
ext = cfg.mimes[tmphead];
|
||||
} catch(fErr) {
|
||||
console.error(`[METADATA ERROR] Fallback fetch failed for ${link}:`, fErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.instagram)) {
|
||||
// is instagram
|
||||
try {
|
||||
const out = await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', '--skip-download', '--dump-json', link]);
|
||||
const meta = extractJSON(out.stdout);
|
||||
ext = meta.ext;
|
||||
} catch (err) {
|
||||
console.error(`[METADATA ERROR] Instagram yt-dlp failed for ${link}:`, err.message);
|
||||
lastErr = err;
|
||||
const tmphead = (await fetch(link, { method: "HEAD" })).headers["content-type"];
|
||||
ext = cfg.mimes[tmphead];
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.imgur)) {
|
||||
// is imgur
|
||||
try {
|
||||
const out = await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '--skip-download', '--dump-json', link]);
|
||||
const meta = extractJSON(out.stdout);
|
||||
ext = meta.ext;
|
||||
} catch (err) {
|
||||
console.error(`[METADATA ERROR] Imgur yt-dlp failed for ${link}:`, err.message);
|
||||
lastErr = err;
|
||||
|
||||
// Fallback: Check MIME via curl (more robust than fetch for blocked direct links)
|
||||
try {
|
||||
let referer = link;
|
||||
try {
|
||||
const parsedUrl = new URL(link);
|
||||
let host = parsedUrl.hostname;
|
||||
if (host.includes('imgur.com')) host = 'imgur.com';
|
||||
referer = `${parsedUrl.protocol}//${host}/`;
|
||||
} catch(e) {}
|
||||
|
||||
const curlArgs = ['-I', '-s', '-f', '-L', '--user-agent', pcUA, '--referer', referer, link];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
const headOut = await queue.spawn('curl', curlArgs);
|
||||
const contentTypeMatch = headOut.stdout.match(/content-type:\s*([^\r\n]+)/i);
|
||||
if (contentTypeMatch) {
|
||||
const tmphead = contentTypeMatch[1].trim();
|
||||
ext = cfg.mimes[tmphead];
|
||||
if (ext) {
|
||||
console.log(`[PARSER DEBUG] Imgur Metadata Fallback (curl) success: ${tmphead} -> ${ext}`);
|
||||
} else {
|
||||
console.warn(`[PARSER DEBUG] Imgur Metadata Fallback (curl) unsupported MIME: "${tmphead}"`);
|
||||
}
|
||||
}
|
||||
} catch(fErr) {
|
||||
console.error(`[METADATA ERROR] Imgur Fallback curl failed for ${link}:`, fErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.yt)) {
|
||||
try {
|
||||
const out = await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', '-I', '1', '--skip-download', '--dump-json', link]);
|
||||
const meta = extractJSON(out.stdout);
|
||||
ext = meta.ext;
|
||||
} catch (err) {
|
||||
console.error(`[METADATA ERROR] YouTube yt-dlp failed for ${link}:`, err.message);
|
||||
lastErr = err;
|
||||
const tmphead = (await fetch(link, { method: "HEAD" })).headers["content-type"];
|
||||
ext = cfg.mimes[tmphead];
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.fourchan)) {
|
||||
try {
|
||||
const out = await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', '--skip-download', '--dump-json', link]);
|
||||
const meta = extractJSON(out.stdout);
|
||||
ext = meta.ext;
|
||||
} catch (err) {
|
||||
console.error(`[METADATA ERROR] 4chan yt-dlp failed for ${link}:`, err.message);
|
||||
lastErr = err;
|
||||
const tmphead = (await fetch(link, { method: "HEAD" })).headers["content-type"];
|
||||
ext = cfg.mimes[tmphead];
|
||||
}
|
||||
}
|
||||
else {
|
||||
try {
|
||||
const out = await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', '--skip-download', '--dump-json', link]);
|
||||
const meta = extractJSON(out.stdout);
|
||||
ext = meta.ext;
|
||||
} catch (err) {
|
||||
console.error(`[METADATA ERROR] General yt-dlp failed for ${link}:`, err.message);
|
||||
if (err.stderr) console.error(`[METADATA ERROR] stderr:`, err.stderr);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
||||
return await e.reply(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.values(cfg.mimes).includes(ext?.toLowerCase()) && !link.startsWith('mxc://')) {
|
||||
const errMsg = `lol, go f0ck yourself (mime schmime: ${ext || 'undefined'} for ${link})`.slice(0, 1024);
|
||||
await e.reply(errMsg);
|
||||
console.error(`[PARSER] Mime check failed for ${link}. Detected ext: ${ext}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = await e.reply(`[charging my lazor] downloading`, {
|
||||
disable_notification: true
|
||||
});
|
||||
|
||||
// <download data>
|
||||
const start = new Date();
|
||||
console.log(`[PARSER] Downloading ${link}...`);
|
||||
let source;
|
||||
|
||||
if (link.startsWith('mxc://')) {
|
||||
try {
|
||||
console.log(`[PARSER] Handling Native Matrix Download: ${link}`);
|
||||
const buffer = await e.self.download(link);
|
||||
console.log(`[PARSER] Downloaded ${buffer.length} bytes`);
|
||||
|
||||
// ext was resolved above from replyTo.mimetype; use it for the temp file so
|
||||
// downstream MIME detection (via 'file') also works on non-.bin files naturally.
|
||||
const tmpExt = (ext && ext !== 'bin') ? ext : 'bin';
|
||||
const destPath = path.join(cfg.paths.tmp, `${uuid}.${tmpExt}`);
|
||||
await fs.promises.writeFile(destPath, buffer);
|
||||
source = destPath;
|
||||
} catch(err) {
|
||||
console.error('Matrix native dl error:', err);
|
||||
return await e.reply(`Matrix download failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.ph)) {
|
||||
try {
|
||||
// Added referer to fix fragment 404 errors, removed -vU to avoid exit code 100
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, '--no-playlist', '--referer', 'https://www.pornhub.com', '--user-agent', pcUA, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
} catch (err) {
|
||||
console.error('Pornhub dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
||||
return await e.reply(errorMsg);
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.instagram)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
} catch (err) {
|
||||
console.error('Instagram dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
||||
return await e.reply(errorMsg);
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.imgur)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / b[height<=1080]', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
} catch (err) {
|
||||
console.warn(`[PARSER] Imgur Stage 1 (yt-dlp) failed: ${err.message}. Retrying with curl...`);
|
||||
|
||||
// Stage 2: Robust curl fallback (same as API upload logic)
|
||||
try {
|
||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
let referer = link;
|
||||
try {
|
||||
const parsedUrl = new URL(link);
|
||||
let host = parsedUrl.hostname;
|
||||
if (host.includes('imgur.com')) host = 'imgur.com';
|
||||
referer = `${parsedUrl.protocol}//${host}/`;
|
||||
} catch(e) {}
|
||||
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L', link, '-o', fallbackTmp,
|
||||
'--max-filesize', `${maxfilesize}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300',
|
||||
'--user-agent', pcUA,
|
||||
'--referer', referer,
|
||||
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'-H', 'Accept-Language: en-US,en;q=0.9'
|
||||
];
|
||||
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
|
||||
await queue.spawn('curl', curlArgs);
|
||||
|
||||
// Detect MIME and rename
|
||||
const fallbackMime = (await queue.spawn('file', ['--mime-type', '-b', fallbackTmp])).stdout.trim();
|
||||
const extension = cfg.mimes[fallbackMime];
|
||||
|
||||
if (extension) {
|
||||
const finalPath = path.join(cfg.paths.tmp, `${uuid}.${extension}`);
|
||||
await fs.promises.rename(fallbackTmp, finalPath);
|
||||
source = finalPath;
|
||||
console.log(`[PARSER] Imgur Stage 2 (curl) success: ${source} (${fallbackMime})`);
|
||||
} else {
|
||||
if (fallbackMime === 'text/html') {
|
||||
const content = (await fs.promises.readFile(fallbackTmp)).toString().substring(0, 500);
|
||||
console.warn(`[PARSER] Imgur fallback received HTML instead of media. Start of content: "${content}"`);
|
||||
}
|
||||
console.error(`[PARSER] Imgur fallback downloaded unsupported MIME type: "${fallbackMime}" from ${link}`);
|
||||
await fs.promises.unlink(fallbackTmp).catch(() => {});
|
||||
throw new Error(`Unsupported fallback MIME: ${fallbackMime}`);
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
console.error(`[PARSER] All Imgur download stages failed for ${link}:`, fallbackErr.message);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
||||
return await e.reply(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.yt)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '-I', '1', '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
} catch (err) {
|
||||
console.error('YouTube dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
||||
return await e.reply(errorMsg);
|
||||
}
|
||||
}
|
||||
else if (link.match(regex.fourchan)) {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...proxyArgs, ...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
} catch (err) {
|
||||
console.error('4chan dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
||||
return await e.reply(errorMsg);
|
||||
}
|
||||
}
|
||||
else {
|
||||
try {
|
||||
source = (await queue.spawn('yt-dlp', [...ytdlpArgs, '-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w', link, '--max-filesize', `${maxfilesize / 1024}k`, '--postprocessor-args', 'ffmpeg:-bitexact', '-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`), '--print', 'after_move:filepath', '--merge-output-format', 'mp4'])).stdout.trim();
|
||||
} catch (err) {
|
||||
console.error('General dl error:', err);
|
||||
const errorMsg = `something went wrong lol`.slice(0, 1024);
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, errorMsg);
|
||||
return await e.reply(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, "something went wrong lol");
|
||||
return await e.reply("something went wrong lol");
|
||||
}
|
||||
|
||||
if (source.match(/larger than/)) {
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, "too large lol");
|
||||
return await e.reply("too large lol");
|
||||
}
|
||||
const end = ~~((new Date() - start) / 1e3);
|
||||
|
||||
// filesize check
|
||||
const size = fs.statSync(source).size;
|
||||
if (size > maxfilesize) {
|
||||
await fs.promises.unlink(source).catch(_ => { });
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, `too large lol. (${lib.formatSize(size)} / ${lib.formatSize(maxfilesize)})`);
|
||||
return await e.reply(`too large lol. (${lib.formatSize(size)} / ${lib.formatSize(maxfilesize)})`);
|
||||
}
|
||||
|
||||
// mime check
|
||||
let mime = (await queue.spawn('file', ['--mime-type', '-b', source])).stdout.trim();
|
||||
console.log(`[PARSER] Downloaded. MIME: ${mime}`);
|
||||
try {
|
||||
if (mime == 'video/x-matroska') { // mkv failsafe
|
||||
await queue.spawn('ffmpeg', ['-i', path.join(cfg.paths.tmp, `${uuid}.mkv`), '-codec', 'copy', path.join(cfg.paths.tmp, `${uuid}.mp4`)]);
|
||||
await fs.promises.unlink(source).catch(_ => { });
|
||||
source = source.replace(/\.mkv$/, '.mp4');
|
||||
mime = 'video/mp4';
|
||||
}
|
||||
if (source.match(/\.opus$/)) { // opus failsafe
|
||||
await queue.spawn('ffmpeg', ['-i', path.join(cfg.paths.tmp, `${uuid}.opus`), '-codec', 'copy', path.join(cfg.paths.tmp, `${uuid}.ogg`)]);
|
||||
await fs.promises.unlink(source);
|
||||
source = source.replace(/\.opus$/, '.ogg');
|
||||
mime = 'audio/ogg';
|
||||
}
|
||||
} catch (err) {
|
||||
await fs.promises.unlink(source).catch(_ => { });
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, "something went wrong lol");
|
||||
return await e.reply("something went wrong lol");
|
||||
}
|
||||
|
||||
if (!Object.keys(cfg.mimes).includes(mime)) {
|
||||
await fs.promises.unlink(source).catch(_ => { });
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, `lol, go f0ck yourself (${mime})`);
|
||||
return await e.reply(`lol, go f0ck yourself (${mime})`);
|
||||
}
|
||||
|
||||
// generate checksum
|
||||
const checksum = (await queue.spawn('sha256sum', [source])).stdout.trim().split(" ")[0];
|
||||
|
||||
// check repost (checksum)
|
||||
repost = await queue.checkrepostsum(checksum);
|
||||
if (repost) {
|
||||
console.log(`[PARSER] Checksum match found: ${repost}`);
|
||||
}
|
||||
|
||||
// PHash check (if strict checksum passed)
|
||||
let phash = null;
|
||||
if (!repost) {
|
||||
console.log(`[PARSER] Checksum valid. Generating PHash...`);
|
||||
phash = await queue.generatePHash(source);
|
||||
if (phash) {
|
||||
console.log(`[PARSER] PHash generated (Temporal). Length: ${phash.length}`);
|
||||
console.log(`[PARSER] Checking PHash against database...`);
|
||||
const phashMatch = await queue.checkrepostphash(phash);
|
||||
if (phashMatch) {
|
||||
repost = phashMatch;
|
||||
console.log(`[PARSER] PHash match found: ${repost} (Visual duplicate)`);
|
||||
} else {
|
||||
console.log(`[PARSER] No PHash duplicates found.`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[PARSER] Failed to generate PHash.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (repost) {
|
||||
await fs.promises.unlink(source).catch(_ => { });
|
||||
if (e.type == 'tg')
|
||||
return await e.editMessageText(msg.result.chat.id, msg.result.message_id, `repost motherf0cker (checksum): ${cfg.main.url.full}/${repost}`);
|
||||
|
||||
if (e.type === 'discord') {
|
||||
// For Discord, we EDIT the original "charging" message
|
||||
// This ensures buttons are never shown because we return early
|
||||
return await msg.edit(`repost motherf0cker (checksum): ${cfg.main.url.full}/${repost}`);
|
||||
}
|
||||
|
||||
return await e.reply(`repost motherf0cker (checksum): ${cfg.main.url.full}/${repost}`);
|
||||
}
|
||||
|
||||
const filename = path.basename(source);
|
||||
|
||||
let speed = lib.calcSpeed(size, end);
|
||||
speed = !Number.isFinite(speed) ? "yes" : `${speed.toFixed(2)} Mbit/s`;
|
||||
|
||||
const manualApproval = getManualApproval();
|
||||
let outputmsgirc = `${manualApproval ? '[approval pending] ' : ''}size: ${lib.formatSize(size)} | speed: ${speed}`;
|
||||
|
||||
if (e.type == 'tg') {
|
||||
// Telegram Logic
|
||||
const tgDestDir = manualApproval ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
await fs.promises.copyFile(source, path.join(tgDestDir, filename));
|
||||
await fs.promises.unlink(source).catch(_ => { });
|
||||
|
||||
await db`
|
||||
insert into items ${db({
|
||||
src: e.media ? "" : link,
|
||||
dest: filename,
|
||||
mime: mime,
|
||||
size: size,
|
||||
checksum: checksum,
|
||||
phash: phash,
|
||||
username: websiteUser ? websiteUser.user : (e.user.username || e.user.nick),
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
// Auto-subscribe uploader
|
||||
try {
|
||||
if (websiteUser?.id) {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${websiteUser.id}, ${itemid})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
console.log(`[PARSER] Auto-subscribed user ${websiteUser.id} to item ${itemid}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PARSER] Failed to auto-subscribe uploader:', err);
|
||||
}
|
||||
|
||||
// Generate Thumbnail
|
||||
try {
|
||||
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
|
||||
if (isNSFW) await queue.genBlurredThumbnail(itemid, manualApproval);
|
||||
} catch (err) {
|
||||
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);
|
||||
}
|
||||
|
||||
// Notify Admins
|
||||
if (manualApproval) await queue.notifyAdmins(itemid);
|
||||
|
||||
// Notify Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && bot.bot?.clients) {
|
||||
const matrixWrapper = bot.bot.clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const matrixMsg = `${websiteUser ? websiteUser.user : (e.user.username || e.user.nick)} uploaded a new video ${cfg.main.url.full}/${itemid}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, matrixMsg);
|
||||
console.log(`[PARSER] Matrix notification sent for item ${itemid}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PARSER] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
await e.deleteMessage(msg.result.chat.id, msg.result.message_id);
|
||||
await e.reply(`${outputmsgirc} | link: ${cfg.main.url.full}/${itemid}`);
|
||||
}
|
||||
else {
|
||||
// General Logic (IRC, Matrix, Slack, Legacy/No-JS Discord)
|
||||
const genDestDir = manualApproval ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
await fs.promises.copyFile(source, path.join(genDestDir, filename));
|
||||
await fs.promises.unlink(source).catch(_ => { });
|
||||
|
||||
await db`
|
||||
insert into items ${db({
|
||||
src: e.media ? "" : link,
|
||||
dest: filename,
|
||||
mime: mime,
|
||||
size: size,
|
||||
checksum: checksum,
|
||||
phash: phash,
|
||||
username: websiteUser ? websiteUser.user : (e.user.nick || e.user.username),
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
// Auto-subscribe uploader
|
||||
try {
|
||||
if (websiteUser?.id) {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${websiteUser.id}, ${itemid})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
console.log(`[PARSER] Auto-subscribed user ${websiteUser.id} to item ${itemid}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PARSER] Failed to auto-subscribe uploader:', err);
|
||||
}
|
||||
|
||||
// Matrix: assign rating + user tags
|
||||
if (e.type === 'matrix' && matrixRating && matrixTags.length >= getMinTags()) {
|
||||
try {
|
||||
const userId = websiteUser ? websiteUser.id : 1;
|
||||
|
||||
// Assign rating tag (sfw=1, nsfw=2)
|
||||
const ratingTagId = matrixRating === 'sfw' ? 1 : 2;
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
tag_id: ratingTagId,
|
||||
item_id: itemid,
|
||||
user_id: userId
|
||||
})}
|
||||
`;
|
||||
|
||||
// Assign user-provided tags
|
||||
for (const tagName of matrixTags) {
|
||||
let tagid;
|
||||
const tag_exists = await db`
|
||||
select id from "tags" where tag = ${tagName}
|
||||
`;
|
||||
if (tag_exists.length === 0) {
|
||||
tagid = (await db`
|
||||
insert into "tags" ${db({ tag: tagName })}
|
||||
returning id
|
||||
`)[0].id;
|
||||
} else {
|
||||
tagid = tag_exists[0].id;
|
||||
}
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
tag_id: tagid,
|
||||
item_id: itemid,
|
||||
user_id: userId
|
||||
})}
|
||||
`;
|
||||
}
|
||||
console.log(`[PARSER] Matrix tags assigned: ${matrixRating}, [${matrixTags.join(', ')}] to item ${itemid}`);
|
||||
} catch (err) {
|
||||
console.error('[PARSER] Failed to assign Matrix tags:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Thumbnail
|
||||
try {
|
||||
await queue.genThumbnail(filename, mime, itemid, link, manualApproval);
|
||||
if (isNSFW) await queue.genBlurredThumbnail(itemid, manualApproval);
|
||||
} catch (err) {
|
||||
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['./mugge.png', path.join(tDir, `${itemid}.webp`)]);
|
||||
}
|
||||
|
||||
// Notify Admins
|
||||
if (manualApproval) await queue.notifyAdmins(itemid);
|
||||
|
||||
// Notify Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && bot.bot?.clients) {
|
||||
const matrixWrapper = bot.bot.clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const matrixMsg = `${websiteUser ? websiteUser.user : (e.user.nick || e.user.username)} uploaded a new video ${cfg.main.url.full}/${itemid}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, matrixMsg);
|
||||
console.log(`[PARSER] Matrix notification sent for item ${itemid}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[PARSER] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
await e.reply(outputmsgirc);
|
||||
}
|
||||
}
|
||||
}
|
||||
}];
|
||||
};
|
||||
133
src/inc/trigger/tags.mjs
Normal file
133
src/inc/trigger/tags.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import db from "../sql.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import { getLevel } from "../admin.mjs";
|
||||
|
||||
export default async bot => {
|
||||
return [{
|
||||
name: "tags show",
|
||||
call: /^\!tags show \d+$/i,
|
||||
active: true,
|
||||
level: 100,
|
||||
f: async e => {
|
||||
const id = +e.args[1];
|
||||
if(!id)
|
||||
return await e.reply("lol no");
|
||||
const tags = (await lib.getTags(id)).map(t => t.tag);
|
||||
if(tags.length === 0)
|
||||
return await e.reply(`item ${cfg.main.url.full}/${id} has no tags!`);
|
||||
return await e.reply(`item ${cfg.main.url.full}/${id} is tagged as: ${tags.join(', ')}`);
|
||||
}
|
||||
}, {
|
||||
name: "tags add",
|
||||
call: /^\!tags add \d+ .*/i,
|
||||
active: false,
|
||||
level: 100,
|
||||
f: async e => {
|
||||
const id = +e.args[1];
|
||||
if(!id)
|
||||
return await e.reply("lol no");
|
||||
|
||||
const tags = (await lib.getTags(id)).map(t => t.tag);
|
||||
|
||||
const newtags = (e.message.includes(",")
|
||||
? e.args.splice(2).join(" ").trim().split(",")
|
||||
: e.args.splice(2)).filter(t => !tags.includes(t) && t.length > 0);
|
||||
|
||||
if(newtags.length === 0)
|
||||
return await e.reply("no (new) tags provided");
|
||||
|
||||
await Promise.all(newtags.map(async ntag => {
|
||||
try {
|
||||
let tagid;
|
||||
const tag_exists = await db`
|
||||
select id, tag
|
||||
from "tags"
|
||||
where tag = ${ntag}
|
||||
`;
|
||||
if(tag_exists.length === 0) { // create new tag
|
||||
tagid = (await db`
|
||||
insert into "tags" ${
|
||||
db({
|
||||
tag: ntag
|
||||
}, 'tag')
|
||||
}
|
||||
`)[0];
|
||||
}
|
||||
else {
|
||||
tagid = tag_exists[0].id;
|
||||
}
|
||||
return await db`
|
||||
insert into "tags_assign" ${
|
||||
db({
|
||||
tag_id: tagid,
|
||||
item_id: id,
|
||||
prefix: `${e.user.prefix}${e.channel}`
|
||||
}, 'tag_id', 'item_id', 'prefix')
|
||||
}
|
||||
`;
|
||||
} catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
}));
|
||||
|
||||
const ntags = (await lib.getTags(id)).map(t => t.tag);
|
||||
if(ntags.length === 0)
|
||||
return await e.reply(`item ${cfg.main.url.full}/${id} has no tags!`);
|
||||
return await e.reply(`item ${cfg.main.url.full}/${id} is now tagged as: ${ntags.join(', ')}`);
|
||||
}
|
||||
}, {
|
||||
name: "tags remove",
|
||||
call: /^\!tags remove \d+ .*/i,
|
||||
active: false,
|
||||
level: 100,
|
||||
f: async e => {
|
||||
const id = +e.args[1];
|
||||
if(!id)
|
||||
return await e.reply("lol no");
|
||||
|
||||
const tags = await lib.getTags(id);
|
||||
|
||||
const removetags = (e.message.includes(",")
|
||||
? e.args.splice(2).join(" ").trim().split(",")
|
||||
: e.args.splice(2)).filter(t => t.length > 0);
|
||||
|
||||
if(removetags.length === 0)
|
||||
return await e.reply("no tags provided");
|
||||
|
||||
const res = await Promise.all(removetags.map(async rtag => {
|
||||
const tagid = tags.filter(t => t.tag === rtag)[0]?.id ?? null;
|
||||
if(!tagid || tagid?.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
tag: rtag,
|
||||
msg: "tag is not assigned"
|
||||
};
|
||||
}
|
||||
|
||||
const q = await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = ${+tagid}
|
||||
and item_id = ${+id}
|
||||
${ getLevel(e.user.level < 50)
|
||||
? db`and prefix = ${e.user.prefix + e.channel}`
|
||||
: db``
|
||||
}
|
||||
`;
|
||||
|
||||
return {
|
||||
success: !!q,
|
||||
tag: rtag,
|
||||
tagid: tagid
|
||||
};
|
||||
}));
|
||||
|
||||
await e.reply(JSON.stringify(res));
|
||||
|
||||
const ntags = (await lib.getTags(id)).map(t => t.tag);
|
||||
if(ntags.length === 0)
|
||||
return await e.reply(`item ${cfg.main.url.full}/${id} has no tags!`);
|
||||
return await e.reply(`item ${cfg.main.url.full}/${id} is now tagged as: ${ntags.join(', ')}`);
|
||||
}
|
||||
}]
|
||||
};
|
||||
42
src/inc/trigger/thumbnails.mjs
Normal file
42
src/inc/trigger/thumbnails.mjs
Normal file
@@ -0,0 +1,42 @@
|
||||
import queue from '../queue.mjs';
|
||||
import db from '../sql.mjs';
|
||||
|
||||
export default async bot => {
|
||||
|
||||
return [{
|
||||
name: "thumbnailer",
|
||||
call: /^\!thumb .*/i,
|
||||
active: true,
|
||||
level: 100,
|
||||
f: async e => {
|
||||
let processed = [];
|
||||
|
||||
for(let id of e.args) {
|
||||
id = +id;
|
||||
if(id <= 1)
|
||||
continue;
|
||||
|
||||
const f0ck = await db`
|
||||
select id, dest, mime, src
|
||||
from "items"
|
||||
where
|
||||
id = ${id} and
|
||||
active = 'true'
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if(f0ck.length === 0) {
|
||||
await e.reply(`f0ck ${id}: f0ck not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// gen thumb
|
||||
await queue.genThumbnail(f0ck[0].dest, f0ck[0].mime, f0ck[0].id, f0ck[0].src);
|
||||
|
||||
processed.push(id);
|
||||
}
|
||||
|
||||
return await e.reply(`thumbnails: ${processed.length}/${e.args.length} (${processed.join(",")})`);
|
||||
}
|
||||
}];
|
||||
};
|
||||
830
src/index.mjs
Normal file
830
src/index.mjs
Normal file
@@ -0,0 +1,830 @@
|
||||
import cfg from "./inc/config.mjs";
|
||||
import db from "./inc/sql.mjs";
|
||||
import lib from "./inc/lib.mjs";
|
||||
import cuffeo from "cuffeo";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { getMotd, setMotd } from "./inc/motd.mjs";
|
||||
import { getAboutText, setAboutText, getRulesText, setRulesText, getTermsText, setTermsText } from "./inc/page_texts.mjs";
|
||||
import flummpress from "flummpress";
|
||||
import { handleUpload } from "./upload_handler.mjs";
|
||||
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
|
||||
import { handleRethumbUpload } from "./rethumb_handler.mjs";
|
||||
import { handleMemeUpload } from "./meme_upload_handler.mjs";
|
||||
import { handleEmojiUpload } from "./emoji_upload_handler.mjs";
|
||||
import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleHallUpdate, handleHallCreate } from "./hall_image_handler.mjs";
|
||||
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout } from "./inc/settings.mjs";
|
||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||
import { createI18n } from "./inc/i18n.mjs";
|
||||
|
||||
const nginx502 = `<html>
|
||||
<head><title>502 Bad Gateway</title></head>
|
||||
<body bgcolor="white">
|
||||
<center><h1>502 Bad Gateway</h1></center>
|
||||
<hr><center>nginx</center>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const origLog = console.log;
|
||||
|
||||
console.log = function(...args) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (typeof args[0] === 'string' && args[0].includes('[BOOT]')) {
|
||||
origLog.apply(console, args);
|
||||
}
|
||||
return;
|
||||
}
|
||||
origLog.apply(console, args);
|
||||
};
|
||||
|
||||
const logCrash = (type, err) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
const errMsg = err instanceof Error ? `${err.stack || err.message}` : JSON.stringify(err);
|
||||
const logEntry = `[${timestamp}] ${type}: ${errMsg}\n\n`;
|
||||
|
||||
console.error(`[CRASH] ${type}:`, err);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(cfg.paths.logs)) {
|
||||
fs.mkdirSync(cfg.paths.logs, { recursive: true });
|
||||
}
|
||||
fs.appendFileSync(path.join(cfg.paths.logs, 'crash.log'), logEntry);
|
||||
} catch (e) {
|
||||
console.error('Failed to write to crash.log:', e);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('unhandledRejection', err => {
|
||||
if (err && err.code === 'ERR_HTTP_HEADERS_SENT') return;
|
||||
logCrash('Unhandled Rejection', err);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// In production, we might want to restart, but let's see if we can stay alive
|
||||
// throw err;
|
||||
}
|
||||
});
|
||||
|
||||
process.on('uncaughtException', err => {
|
||||
logCrash('Uncaught Exception', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
const self = {
|
||||
_trigger: new Map(),
|
||||
trigger: function trigger(args) {
|
||||
this.call = args.call;
|
||||
this.help = args.help || false;
|
||||
this.level = args.level || 0;
|
||||
this.name = args.name;
|
||||
this.active = args.hasOwnProperty("active") ? args.active : true;
|
||||
this.clients = args.clients || ["irc", "tg", "slack", "matrix"];
|
||||
this.f = args.f;
|
||||
},
|
||||
bot: await new cuffeo(cfg.clients)
|
||||
};
|
||||
|
||||
// Ensure storage directories exist
|
||||
const initDirs = [
|
||||
cfg.paths.a, cfg.paths.b, cfg.paths.t, cfg.paths.ca, cfg.paths.emojis, cfg.paths.memes, cfg.paths.tmp, cfg.paths.logs,
|
||||
path.join(cfg.paths.pending, 'b'), path.join(cfg.paths.pending, 't'), path.join(cfg.paths.pending, 'ca'),
|
||||
path.join(cfg.paths.deleted, 'b'), path.join(cfg.paths.deleted, 't'), path.join(cfg.paths.deleted, 'ca')
|
||||
];
|
||||
for (const dir of initDirs) {
|
||||
if (!fs.existsSync(dir)) {
|
||||
console.log(`[BOOT] Creating directory: ${dir}`);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = 10000; // 10s module load timeout
|
||||
|
||||
// Ensure clients are resolved (Safe-guard for Docker/Node versions)
|
||||
if (self.bot.clients) {
|
||||
self.bot.clients = await Promise.all(self.bot.clients);
|
||||
}
|
||||
|
||||
console.time("loading");
|
||||
const modules = {
|
||||
events: (await fs.promises.readdir("./src/inc/events")).filter(f => f.endsWith(".mjs")),
|
||||
trigger: (await fs.promises.readdir("./src/inc/trigger")).filter(f => f.endsWith(".mjs"))
|
||||
};
|
||||
|
||||
console.timeLog("loading", "directories");
|
||||
const blah = (await Promise.all(Object.entries(modules).map(async ([dir, mods]) => ({
|
||||
[dir]: (await Promise.all(mods.map(async mod => {
|
||||
const res = await Promise.race([
|
||||
(await import(`./inc/${dir}/${mod}`)).default(self),
|
||||
new Promise((_, rej) => setTimeout(() => rej(new Error(`Module loading timed out: ${dir}/${mod}`)), timeout))
|
||||
]);
|
||||
console.timeLog("loading", `${dir}/${mod}`);
|
||||
return res;
|
||||
}))).flat(2)
|
||||
})))).reduce((a, b) => ({ ...a, ...b }));
|
||||
|
||||
blah.events.forEach(event => {
|
||||
console.timeLog("loading", `registering event > ${event.name}`);
|
||||
self.bot.on(event.listener, event.f);
|
||||
});
|
||||
|
||||
blah.trigger.forEach(trigger => {
|
||||
console.timeLog("loading", `registering trigger > ${trigger.name}`);
|
||||
self._trigger.set(trigger.name, new self.trigger(trigger));
|
||||
});
|
||||
|
||||
// Initial halls cache
|
||||
await updateHallsCache();
|
||||
|
||||
//console.timeEnd("loading");
|
||||
|
||||
|
||||
// websrv
|
||||
const app = new flummpress();
|
||||
const router = app.router;
|
||||
const tpl = app.tpl;
|
||||
|
||||
|
||||
// Security headers — applied to every response before any route logic
|
||||
app.use(async (req, res) => {
|
||||
const isSecure = cfg.main.url?.full?.startsWith('https');
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||||
if (isSecure) {
|
||||
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
});
|
||||
|
||||
// Block source map requests early — flummpress crashes on unknown MIME types (e.g. .map)
|
||||
app.use(async (req, res) => {
|
||||
if (req.url?.pathname?.endsWith('.map')) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' }).end('Not Found');
|
||||
}
|
||||
});
|
||||
|
||||
app.use(async (req, res) => {
|
||||
// if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`);
|
||||
// const ua = (req.headers['user-agent'] || '').toLowerCase();
|
||||
// if (ua.includes('discordbot')) {
|
||||
// const isOembed = req.url.pathname.endsWith('.json');
|
||||
// if (isOembed) {
|
||||
// res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
// type: "link",
|
||||
// version: "1.0",
|
||||
// title: "STOP USING DISCORD",
|
||||
// provider_name: "STOP USING DISCORD",
|
||||
// provider_url: "https://example.com"
|
||||
// }));
|
||||
// req.url.pathname = '/discordbot';
|
||||
// return;
|
||||
// }
|
||||
|
||||
// res.writeHead(200, { 'Content-Type': 'text/html' }).end(`<!DOCTYPE html>
|
||||
// <html>
|
||||
// <head>
|
||||
// <meta charset="utf-8" />
|
||||
// <meta name="theme-color" content="#FF0000" />
|
||||
// <meta property="og:site_name" content="STOP USING DISCORD" />
|
||||
// <meta property="og:title" content="STOP USING DISCORD" />
|
||||
// <meta property="og:description" content="DISCORD IS EVIL, DO YOURSELF A FAVOUR AND STOP USING IT! THERE ARE ALTERNATIVES OUT THERE THAT DO NOT FUCK YOU OVER!" />
|
||||
// <meta property="og:image" content="https://i.imgur.com/erSILuG.png" />
|
||||
// <meta property="og:type" content="article" />
|
||||
// <meta property="twitter:card" content="summary_large_image" />
|
||||
// <title>STOP USING DISCORD</title>
|
||||
// </head>
|
||||
// <body></body>
|
||||
// </html>`);
|
||||
// req.url.pathname = '/discordbot';
|
||||
// return;
|
||||
// }
|
||||
|
||||
// sessionhandler
|
||||
req.session = false;
|
||||
if (req.url.pathname.match(/^\/(s)\//) && !req.url.pathname.startsWith('/s/emojis/'))
|
||||
return;
|
||||
if (req.url.pathname === '/manifest.json' || req.url.pathname === '/sw.js')
|
||||
return;
|
||||
if (req.url.pathname.match(/^\/(b|t|ca|a|memes)\//) || req.url.pathname.startsWith('/s/emojis/')) {
|
||||
if (cfg.websrv.private_society && !req.cookies?.session) {
|
||||
res.writeHead(502, { 'Content-Type': 'text/html' }).end(nginx502);
|
||||
req.url.pathname = '/private_society_media_bypass';
|
||||
return;
|
||||
}
|
||||
if (getProtectFiles() && !req.cookies?.session) {
|
||||
res.writeHead(401).end('Unauthorized');
|
||||
req.url.pathname = '/protect_files_bypass';
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||
const defaultTheme = cfg.websrv.theme || 'amoled';
|
||||
req.theme = (req.cookies?.theme && availableThemes.includes(req.cookies.theme)) ? req.cookies.theme : defaultTheme;
|
||||
req.fullscreen = req.cookies.fullscreen || 0;
|
||||
|
||||
if (req.cookies.session) {
|
||||
const user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language
|
||||
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.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (user.length === 0) {
|
||||
res.writeHead(307, { // delete session
|
||||
"Cache-Control": "no-cache, public",
|
||||
"Set-Cookie": `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`,
|
||||
"Location": req.url.originalUrl || req.url.pathname + (req.url.search || "")
|
||||
}).end();
|
||||
req.url.pathname = '/session_invalid_bypass';
|
||||
return;
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
// csrf_token is loaded from user_sessions table via the session query above
|
||||
|
||||
// Ban check
|
||||
if (req.session.banned && !req.url.pathname.match(/^\/(banned|logout)(\/)?$/)) {
|
||||
const now = new Date();
|
||||
if (req.session.ban_expires && new Date(req.session.ban_expires) < now) {
|
||||
// Ban expired, lift it
|
||||
await db`update "user" set banned = false, ban_reason = null, ban_expires = null where id = ${+req.session.id}`;
|
||||
req.session.banned = false;
|
||||
} else {
|
||||
res.writeHead(307, {
|
||||
"Location": "/banned"
|
||||
}).end();
|
||||
req.url.pathname = '/ban_redirect_bypass';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Password change check - Lockdown all other actions
|
||||
if (req.session.force_password_change && !req.url.pathname.match(/^\/(api\/v2\/settings\/password|logout)(\/)?$/)) {
|
||||
// If it's an API request or something other than a main page load, block it
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.url.pathname.startsWith('/api/')) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }));
|
||||
req.url.pathname = '/force_password_change_bypass';
|
||||
return;
|
||||
}
|
||||
// For standard GET requests, we let it through so the header.html can render the modal,
|
||||
// but we'll use CSS to hide the content in header.html.
|
||||
}
|
||||
|
||||
// log last action (Fire-and-Forget)
|
||||
if (!req.url.pathname.startsWith('/api/notifications')) {
|
||||
db`
|
||||
update "user_sessions" set ${db({
|
||||
last_used: ~~(Date.now() / 1e3),
|
||||
last_action: req.url.pathname,
|
||||
browser: req.headers['user-agent']
|
||||
}, 'last_used', 'last_action', 'browser')
|
||||
}
|
||||
where id = ${+user[0].sess_id}
|
||||
`.catch(e => console.error('[MIDDLEWARE] Session update failed:', e));
|
||||
|
||||
// Update last_seen on user table (Fire-and-Forget) — feeds the 30-day orakel pool
|
||||
db`update "user" set last_seen = ${~~(Date.now() / 1e3)} where id = ${+user[0].id}`
|
||||
.catch(e => console.error('[MIDDLEWARE] last_seen update failed:', e));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Calculate uploads remaining globally for the modal
|
||||
if (!req.session.admin && !req.session.is_moderator) {
|
||||
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
|
||||
const uploadCount = await db`
|
||||
SELECT count(*) as count
|
||||
FROM items
|
||||
WHERE username = ${req.session.user}
|
||||
AND stamp > ${twelveHoursAgo}
|
||||
AND is_deleted = false
|
||||
`;
|
||||
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - parseInt(uploadCount[0].count));
|
||||
} else {
|
||||
req.session.uploads_remaining = undefined; // Unlimited for admins/mods
|
||||
}
|
||||
|
||||
req.session.theme = req.theme;
|
||||
req.session.fullscreen = req.cookies.fullscreen;
|
||||
|
||||
// Global upload settings for shared templates
|
||||
const { getMinTags } = await import("./inc/settings.mjs");
|
||||
req.session.min_tags = getMinTags();
|
||||
req.session.mimes_json = JSON.stringify(cfg.mimes);
|
||||
req.session.allowed_mimes = Object.keys(cfg.mimes).join(',');
|
||||
req.session.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session.admin ? cfg.main.adminmultiplier : 1));
|
||||
req.session.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (req.session.admin ? cfg.main.adminmultiplier : 1));
|
||||
|
||||
// update userprofile (Fire-and-Forget)
|
||||
// NOTE: mode is included in the INSERT for first-time row creation (NOT NULL col),
|
||||
// but intentionally excluded from ON CONFLICT DO UPDATE — mode is only updated
|
||||
// by the explicit /mode/:n route to prevent cross-device race conditions.
|
||||
db`
|
||||
insert into "user_options" ${db({
|
||||
user_id: +user[0].id,
|
||||
mode: user[0].mode ?? 0,
|
||||
theme: req.theme,
|
||||
fullscreen: req.session.fullscreen || 0,
|
||||
excluded_tags: req.session.excluded_tags || [],
|
||||
font: req.session.font || null,
|
||||
disable_autoplay: req.session.disable_autoplay ?? (cfg.websrv.enable_autoplay === false),
|
||||
disable_swiping: req.session.disable_swiping ?? (cfg.websrv.enable_swiping === false),
|
||||
show_background: req.session.show_background ?? (cfg.websrv.background !== false),
|
||||
ruffle_volume: user[0].ruffle_volume ?? null,
|
||||
ruffle_background: user[0].ruffle_background ?? true,
|
||||
quote_emojis: user[0].quote_emojis ?? true,
|
||||
embed_youtube_in_comments: user[0].embed_youtube_in_comments ?? (cfg.websrv.embed_youtube_in_comments !== false),
|
||||
hide_koepfe: user[0].hide_koepfe ?? false,
|
||||
language: (user[0].language && user[0].language.trim()) ? user[0].language.trim() : null
|
||||
}, 'user_id', 'mode', 'theme', 'fullscreen', 'excluded_tags', 'font', 'disable_autoplay', 'disable_swiping', 'show_background', 'ruffle_volume', 'ruffle_background', 'quote_emojis', 'embed_youtube_in_comments', 'hide_koepfe', 'language')
|
||||
}
|
||||
on conflict ("user_id") do update set
|
||||
theme = excluded.theme,
|
||||
fullscreen = excluded.fullscreen,
|
||||
excluded_tags = excluded.excluded_tags,
|
||||
font = excluded.font,
|
||||
disable_autoplay = excluded.disable_autoplay,
|
||||
disable_swiping = excluded.disable_swiping,
|
||||
show_background = excluded.show_background,
|
||||
ruffle_volume = excluded.ruffle_volume,
|
||||
ruffle_background = excluded.ruffle_background,
|
||||
quote_emojis = excluded.quote_emojis,
|
||||
embed_youtube_in_comments = excluded.embed_youtube_in_comments,
|
||||
hide_koepfe = excluded.hide_koepfe,
|
||||
language = excluded.language,
|
||||
user_id = excluded.user_id
|
||||
`.catch(e => console.error('[MIDDLEWARE] Options sync failed:', e));
|
||||
}
|
||||
|
||||
const queryMode = req.url.qs?.mode !== undefined ? +req.url.qs.mode : undefined;
|
||||
req.mode = queryMode !== undefined ? queryMode : (req.session ? +(req.session.mode ?? 0) : +(req.cookies?.mode ?? 0));
|
||||
|
||||
// Guest protection: Strictly enforce SFW mode (0) for non-logged-in users
|
||||
if (!req.session) {
|
||||
req.mode = 0;
|
||||
}
|
||||
|
||||
// Private Society gate — require login for all content when enabled
|
||||
if (cfg.websrv.private_society && !req.session) {
|
||||
const publicPaths = /^\/(s|login|logout|register|activate|forgot-password|reset-password|banned|api\/v2\/auth|manifest\.json|sw\.js|robots\.txt|favicon\.(ico|png|gif)|s\/img\/duck-icon-(192|512)\.png)(\/.*)?$/;
|
||||
if (!publicPaths.test(req.url.pathname)) {
|
||||
// For AJAX requests, return 502 so it looks like the backend is down
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Bad Gateway' }));
|
||||
req.url.pathname = '/private_society_bypass';
|
||||
return;
|
||||
}
|
||||
// For page requests, return 502 Bad Gateway for all paths except homepage (which shows the gate)
|
||||
if (req.url.pathname !== '/') {
|
||||
res.writeHead(502, { 'Content-Type': 'text/html' }).end(nginx502);
|
||||
req.url.pathname = '/private_society_bypass';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// CSRF validation helper — used by route handlers that have already populated req.session
|
||||
// NOTE: Cannot be used in flummpress app.use() middlewares for upload/avatar bypass handlers
|
||||
// because flummpress runs ALL middlewares in parallel (Promise.all), so the session
|
||||
// middleware hasn't finished by the time these run. Those handlers validate CSRF inline.
|
||||
const validateCsrf = (req, res) => {
|
||||
if (req.session && req.session.csrf_token) {
|
||||
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
|
||||
if (!token || token !== req.session.csrf_token) {
|
||||
console.error(`[CSRF] Blocked ${req.method} ${req.url.pathname} for user ${req.session.user}. Reason: ${!token ? 'Missing token' : 'Token mismatch'}`);
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' }));
|
||||
req.url.pathname = '/csrf_blocked_bypass';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// CSRF Validation Middleware — only works for routes that go through the router (not bypass handlers)
|
||||
// because the session middleware will have completed by the time router callbacks execute.
|
||||
app.use(async (req, res) => {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps'].includes(req.url.pathname)) return;
|
||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
||||
if (req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
||||
// User hall image upload is handled by bypass middleware below
|
||||
if (req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return;
|
||||
if (!validateCsrf(req, res)) return;
|
||||
});
|
||||
|
||||
// Bypass middleware for direct upload handling
|
||||
// CSRF is validated inside handleUpload after its own session lookup
|
||||
app.use(async (req, res) => {
|
||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/upload') {
|
||||
await handleUpload(req, res, self);
|
||||
req.url.pathname = '/handled_upload_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for video/image metadata extraction and GPS stripping
|
||||
app.use(async (req, res) => {
|
||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/meta/extract-file') {
|
||||
await handleMetaExtract(req, res);
|
||||
req.url.pathname = '/handled_meta_extract_bypass';
|
||||
}
|
||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/meta/strip-gps') {
|
||||
await handleMetaStrip(req, res);
|
||||
req.url.pathname = '/handled_meta_strip_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for avatar upload (needs raw body before router consumes it)
|
||||
// CSRF is validated inside handleAvatarUpload/handleAvatarDelete after their own session lookups
|
||||
app.use(async (req, res) => {
|
||||
if (req.url.pathname === '/api/v2/settings/uploadAvatar') {
|
||||
if (req.method === 'POST') {
|
||||
await handleAvatarUpload(req, res);
|
||||
req.url.pathname = '/handled_avatar_upload_bypass';
|
||||
} else if (req.method === 'DELETE') {
|
||||
await handleAvatarDelete(req, res);
|
||||
req.url.pathname = '/handled_avatar_delete_bypass';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for custom thumbnail uploads
|
||||
app.use(async (req, res) => {
|
||||
const thumbMatch = req.url.pathname.match(/^\/api\/v2\/items\/([^/]+)\/thumbnail$/);
|
||||
if (req.method === 'POST' && thumbMatch) {
|
||||
await handleRethumbUpload(req, res, thumbMatch[1]);
|
||||
req.url.pathname = '/handled_rethumb_upload_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for meme template uploads
|
||||
app.use(async (req, res) => {
|
||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/memes') {
|
||||
await handleMemeUpload(req, res);
|
||||
req.url.pathname = '/handled_meme_upload_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for emoji uploads
|
||||
app.use(async (req, res) => {
|
||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/emojis') {
|
||||
await handleEmojiUpload(req, res);
|
||||
req.url.pathname = '/handled_emoji_upload_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for hall image uploads (multipart — needs raw body)
|
||||
app.use(async (req, res) => {
|
||||
const hallImgMatch = req.url.pathname.match(/^\/api\/v2\/admin\/halls\/([^/]+)\/image$/);
|
||||
if (hallImgMatch) {
|
||||
console.error('[BOOT] [HALL BYPASS] Image path hit:', req.method, req.url.pathname, 'cookies:', JSON.stringify(Object.keys(req.cookies || {})));
|
||||
req.params = req.params || {};
|
||||
req.params.slug = decodeURIComponent(hallImgMatch[1]);
|
||||
if (req.method === 'POST') {
|
||||
await handleHallImageUpload(req, res);
|
||||
req.url.pathname = '/handled_hall_img_bypass';
|
||||
} else if (req.method === 'DELETE') {
|
||||
await handleHallImageDelete(req, res);
|
||||
req.url.pathname = '/handled_hall_img_bypass';
|
||||
}
|
||||
}
|
||||
// POST /api/v2/admin/halls (no slug) — create new hall
|
||||
if (req.method === 'POST' && req.url.pathname === '/api/v2/admin/halls') {
|
||||
await handleHallCreate(req, res);
|
||||
req.url.pathname = '/handled_hall_bypass';
|
||||
}
|
||||
const hallDeleteMatch = req.url.pathname.match(/^\/api\/v2\/admin\/halls\/([^/]+)$/);
|
||||
if (hallDeleteMatch && (req.method === 'DELETE' || req.method === 'PATCH')) {
|
||||
console.error('[BOOT] [HALL BYPASS] CRUD path hit:', req.method, req.url.pathname);
|
||||
req.params = req.params || {};
|
||||
req.params.slug = decodeURIComponent(hallDeleteMatch[1]);
|
||||
if (req.method === 'DELETE') {
|
||||
await handleHallDelete(req, res);
|
||||
} else {
|
||||
await handleHallUpdate(req, res);
|
||||
}
|
||||
req.url.pathname = '/handled_hall_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for user hall image uploads (multipart — raw body needed)
|
||||
app.use(async (req, res) => {
|
||||
const userHallImgMatch = req.url.pathname.match(/^\/api\/v2\/me\/halls\/([^/]+)\/image$/);
|
||||
if (userHallImgMatch && req.method === 'POST') {
|
||||
console.error('[BOOT] [USER_HALL BYPASS] Image upload:', req.url.pathname);
|
||||
const { handleUserHallImageUpload } = await import('./user_hall_image_handler.mjs');
|
||||
await handleUserHallImageUpload(req, res, decodeURIComponent(userHallImgMatch[1]));
|
||||
req.url.pathname = '/handled_user_hall_img_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
tpl.views = "views";
|
||||
tpl.debug = true;
|
||||
tpl.cache = false;
|
||||
|
||||
// i18n — load active language from config (default: 'en')
|
||||
const { t, lang } = createI18n(cfg.websrv.language || 'en');
|
||||
|
||||
// Get git commit hash for debug display
|
||||
let gitHash = process.env.GIT_HASH || 'unknown';
|
||||
if (gitHash === 'unknown') {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
||||
const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
gitHash = `${branch}-${hash}`;
|
||||
} catch (e) {
|
||||
// console.warn('Could not get git hash:', e.message);
|
||||
}
|
||||
}
|
||||
console.log('Git hash:', gitHash);
|
||||
|
||||
// Fetch MOTD from database with retry logic
|
||||
let motdLoaded = false;
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
while (!motdLoaded && attempts < maxAttempts) {
|
||||
try {
|
||||
attempts++;
|
||||
const settings = await db`SELECT value FROM site_settings WHERE key = 'motd' LIMIT 1`;
|
||||
if (settings.length > 0) {
|
||||
setMotd(settings[0].value);
|
||||
console.log(`[BOOT] MOTD loaded on attempt ${attempts}`);
|
||||
} else {
|
||||
console.log(`[BOOT] No MOTD found in database (attempt ${attempts})`);
|
||||
}
|
||||
motdLoaded = true;
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] MOTD fetch failed (attempt ${attempts}):`, e.message);
|
||||
if (attempts < maxAttempts) {
|
||||
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2s before retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!motdLoaded) {
|
||||
console.error('[BOOT] MOTD failed to load after maximum attempts');
|
||||
}
|
||||
|
||||
// Fetch Manual Approval setting
|
||||
try {
|
||||
const approvalSetting = await db`SELECT value FROM site_settings WHERE key = 'manual_approval' LIMIT 1`;
|
||||
if (approvalSetting.length > 0) {
|
||||
setManualApproval(approvalSetting[0].value === 'true');
|
||||
console.log(`[BOOT] Manual Approval setting loaded: ${getManualApproval()}`);
|
||||
} else {
|
||||
console.log(`[BOOT] No Manual Approval setting found, defaulting to true`);
|
||||
setManualApproval(true);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] Manual Approval fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Fetch min_tags setting
|
||||
try {
|
||||
const mtSetting = await db`SELECT value FROM site_settings WHERE key = 'min_tags' LIMIT 1`;
|
||||
if (mtSetting.length > 0) {
|
||||
setMinTags(parseInt(mtSetting[0].value));
|
||||
console.log(`[BOOT] Min Tags setting loaded: ${getMinTags()}`);
|
||||
} else {
|
||||
console.log(`[BOOT] No Min Tags setting found, defaulting to 3`);
|
||||
setMinTags(3); // Match default in settings.mjs
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] Min Tags fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Fetch registration_open setting
|
||||
try {
|
||||
const regSetting = await db`SELECT value FROM site_settings WHERE key = 'registration_open' LIMIT 1`;
|
||||
if (regSetting.length > 0) setRegistrationOpen(regSetting[0].value === 'true');
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] Registration Open fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Fetch trusted_uploads setting
|
||||
try {
|
||||
const tuSetting = await db`SELECT value FROM site_settings WHERE key = 'trusted_uploads' LIMIT 1`;
|
||||
if (tuSetting.length > 0) {
|
||||
setTrustedUploads(parseInt(tuSetting[0].value));
|
||||
console.log(`[BOOT] Trusted Uploads setting loaded: ${getTrustedUploads()}`);
|
||||
} else {
|
||||
console.log(`[BOOT] No Trusted Uploads setting found, defaulting to ${getTrustedUploads()}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Load bypass_duplicate_check from config.json (static — not a DB setting)
|
||||
if (cfg.websrv.bypass_duplicate_check === true) {
|
||||
setBypassDuplicateCheck(true);
|
||||
console.log(`[BOOT] Duplicate check bypass ENABLED via config.json`);
|
||||
}
|
||||
|
||||
// Load protect_files from config.json (static — not a DB setting)
|
||||
if (cfg.websrv.protect_files === true) {
|
||||
setProtectFiles(true);
|
||||
console.log(`[BOOT] File protection ENABLED via config.json — direct file links require login`);
|
||||
}
|
||||
|
||||
// Load private_messages from config.json (static — not a DB setting)
|
||||
// Default is true; set to false to fully disable private messaging
|
||||
setPrivateMessages(cfg.websrv.private_messages !== false);
|
||||
console.log(`[BOOT] Private messaging: ${cfg.websrv.private_messages !== false ? 'ENABLED' : 'DISABLED'}`);
|
||||
|
||||
// Load default_layout from config.json (static)
|
||||
if (cfg.websrv.default_layout) {
|
||||
setDefaultLayout(cfg.websrv.default_layout);
|
||||
console.log(`[BOOT] Default layout set to: ${getDefaultLayout()}`);
|
||||
}
|
||||
|
||||
// Fetch about_text from database
|
||||
try {
|
||||
const aboutSetting = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`;
|
||||
if (aboutSetting.length > 0) {
|
||||
setAboutText(aboutSetting[0].value);
|
||||
console.log(`[BOOT] About text loaded`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] About text fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Fetch rules_text from database
|
||||
try {
|
||||
const rulesSetting = await db`SELECT value FROM site_settings WHERE key = 'rules_text' LIMIT 1`;
|
||||
if (rulesSetting.length > 0) {
|
||||
setRulesText(rulesSetting[0].value);
|
||||
console.log(`[BOOT] Rules text loaded`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] Rules text fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Fetch terms_text from database
|
||||
try {
|
||||
const termsSetting = await db`SELECT value FROM site_settings WHERE key = 'terms_text' LIMIT 1`;
|
||||
if (termsSetting.length > 0) {
|
||||
setTermsText(termsSetting[0].value);
|
||||
console.log(`[BOOT] Terms text loaded`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BOOT] Terms text fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
const globals = {
|
||||
lul: cfg.websrv.lul,
|
||||
themes: cfg.websrv.themes,
|
||||
default_theme: cfg.websrv.theme || 'f0ck',
|
||||
modes: cfg.allowedModes,
|
||||
api: cfg.websrv.api,
|
||||
site: cfg.main.url.full,
|
||||
domain: cfg.main.url.domain,
|
||||
hide_comments_from_public: cfg.main.hide_comments_from_public,
|
||||
git_hash: typeof gitHash !== 'undefined' ? gitHash : 'unknown',
|
||||
discord: cfg.main.discord,
|
||||
get motd() { return getMotd(); },
|
||||
get manual_approval() { return getManualApproval(); },
|
||||
get min_tags() { return getMinTags(); },
|
||||
get registration_open() { return getRegistrationOpen(); },
|
||||
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
|
||||
get trusted_uploads() { return getTrustedUploads(); },
|
||||
get about_text() { return getAboutText(); },
|
||||
get rules_text() { return getRulesText(); },
|
||||
get terms_text() { return getTermsText(); },
|
||||
get halls() { return getHalls(); },
|
||||
smtp_enabled: !!(cfg.smtp && cfg.smtp.enabled && cfg.smtp.mail_reset_password),
|
||||
show_background_cfg: cfg.websrv.background !== false,
|
||||
allowed_mimes: Object.keys(cfg.mimes).concat([...new Set(Object.values(cfg.mimes))].map(ext => `.${ext}`)).join(','),
|
||||
mimes_json: JSON.stringify(cfg.mimes),
|
||||
development: cfg.main.development || false,
|
||||
show_mime_picker: cfg.websrv.show_mime_picker !== false,
|
||||
private_society: cfg.websrv.private_society || false,
|
||||
show_content_warning: cfg.websrv.show_content_warning !== false,
|
||||
web_url_upload: !!cfg.websrv.web_url_upload,
|
||||
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
|
||||
upload_limit: cfg.main.upload_limit ?? 69,
|
||||
meme_creator: !!cfg.websrv.meme_creator,
|
||||
custom_favicon: cfg.websrv.custom_favicon || "",
|
||||
custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""),
|
||||
site_description: cfg.websrv.description || "The webs dumpster",
|
||||
enable_nsfl: !!cfg.enable_nsfl,
|
||||
nsfl_tag_id: cfg.nsfl_tag_id || 3,
|
||||
scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'],
|
||||
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
||||
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
||||
get private_messages() { return getPrivateMessages(); },
|
||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||
ts: Date.now(),
|
||||
get default_layout() { return getDefaultLayout(); },
|
||||
show_koepfe: !!cfg.websrv.show_koepfe,
|
||||
allow_language_change: cfg.websrv.allow_language_change !== false,
|
||||
use_ententeich: !!cfg.websrv.use_ententeich,
|
||||
enable_xd_score: !!cfg.websrv.enable_xd_score,
|
||||
enable_swf: !!cfg.websrv.enable_swf,
|
||||
enable_danmaku: cfg.websrv.enable_danmaku !== false,
|
||||
enable_global_chat: !!cfg.websrv.enable_global_chat,
|
||||
embed_youtube_in_comments: cfg.websrv.embed_youtube_in_comments !== false,
|
||||
koepfe_json: JSON.stringify(cfg.websrv.koepfe || []),
|
||||
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
|
||||
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
|
||||
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
|
||||
|
||||
get fonts() {
|
||||
try {
|
||||
const fontsDir = path.join(path.resolve(), 'public/s/fonts');
|
||||
if (!fs.existsSync(fontsDir)) return [];
|
||||
return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({
|
||||
name: f.split('.').shift(),
|
||||
file: f
|
||||
}));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// i18n
|
||||
t,
|
||||
lang
|
||||
};
|
||||
|
||||
tpl.globals = globals;
|
||||
|
||||
// Monkey-patch render to ensure mode and theme are passed
|
||||
const originalRender = tpl.render;
|
||||
const defaultI18n = { t, lang };
|
||||
tpl.render = function (view, data, req) {
|
||||
// Resolve per-request language BEFORE building data, so the correct
|
||||
// t() and lang are available from the start (not overridden by globals).
|
||||
let perRequestT = t;
|
||||
let perRequestLang = lang;
|
||||
|
||||
if (req) {
|
||||
const VALID_LANGS = ['en', 'de', 'nl', 'zange'];
|
||||
const rawLang = (req.session?.language && req.session.language.trim())
|
||||
|| (req.cookies?.language && req.cookies.language.trim())
|
||||
|| cfg.websrv.language || 'en';
|
||||
const userLang = VALID_LANGS.includes(rawLang) ? rawLang : (cfg.websrv.language || 'en');
|
||||
const perRequest = createI18n(userLang);
|
||||
perRequestT = perRequest.t;
|
||||
perRequestLang = perRequest.lang;
|
||||
if (process.env.NODE_ENV !== 'production')
|
||||
console.log(`[i18n] render: user=${req.session?.user} rawLang=${rawLang} userLang=${userLang}`);
|
||||
} else if (data && data.t && data.lang) {
|
||||
// Called from @include — inherit t and lang from the parent render's data
|
||||
perRequestT = data.t;
|
||||
perRequestLang = data.lang;
|
||||
}
|
||||
|
||||
// Build data: globals first, then caller-supplied data, then per-request i18n last
|
||||
// so t/lang always reflect the user's language, not the site default.
|
||||
// ALSO mutate globals.t and globals.lang: flummpress spreads this.#globals LAST
|
||||
// inside render(), so globals must carry the per-request values too.
|
||||
globals.t = perRequestT;
|
||||
globals.lang = perRequestLang;
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
lang: perRequestLang
|
||||
});
|
||||
|
||||
// Random brand image per-render
|
||||
const brand = cfg.websrv.custom_brand_image;
|
||||
if (Array.isArray(brand) && brand.length > 0) {
|
||||
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
|
||||
}
|
||||
|
||||
if (req) {
|
||||
if (req.mode !== undefined) data.mode = req.mode;
|
||||
data.theme = req.theme || req.cookies?.theme || cfg.websrv.theme || 'f0ck';
|
||||
if (!data.url) data.url = req.url;
|
||||
data.user_strict_bool = (req.session && req.session.strict_mode) ? true : false;
|
||||
data.user_logged_in_bool = !!req.session;
|
||||
data.csrf_token = req.session?.csrf_token || '';
|
||||
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
|
||||
} else {
|
||||
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
|
||||
data.user_strict_bool = false;
|
||||
data.user_logged_in_bool = false;
|
||||
}
|
||||
|
||||
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
|
||||
};
|
||||
|
||||
router.use(tpl);
|
||||
router.self = self;
|
||||
|
||||
await router.importRoutesFromPath("src/inc/routes", tpl);
|
||||
|
||||
app.listen(cfg.websrv.port);
|
||||
|
||||
})();
|
||||
119
src/lib/smtp.mjs
Normal file
119
src/lib/smtp.mjs
Normal file
@@ -0,0 +1,119 @@
|
||||
import tls from "tls";
|
||||
import net from "net";
|
||||
import { Buffer } from "buffer";
|
||||
|
||||
export const sendMail = (config, options) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { host, port, secure, user, password, from } = config;
|
||||
const { to, subject, body } = options;
|
||||
|
||||
let socket;
|
||||
let step = 0;
|
||||
|
||||
const connect = () => {
|
||||
if (secure) {
|
||||
socket = tls.connect(port, host, () => {
|
||||
console.log(`[SMTP] Securely connected to ${host}:${port}`);
|
||||
});
|
||||
} else {
|
||||
socket = net.connect(port, host, () => {
|
||||
console.log(`[SMTP] Connected to ${host}:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
socket.on("data", onData);
|
||||
socket.on("error", (err) => reject(err));
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy();
|
||||
reject(new Error("SMTP Timeout"));
|
||||
});
|
||||
};
|
||||
|
||||
const send = (data) => {
|
||||
socket.write(data + "\r\n");
|
||||
};
|
||||
|
||||
const onData = (data) => {
|
||||
const response = data.toString();
|
||||
// console.log(`[SMTP] step ${step} server: ${response.trim()}`);
|
||||
|
||||
if (response.startsWith("220") && step === 0) {
|
||||
send(`EHLO ${host}`);
|
||||
step = 1;
|
||||
} else if (response.startsWith("250") && step === 1) {
|
||||
if (!secure && response.includes("STARTTLS")) {
|
||||
send("STARTTLS");
|
||||
step = 1.5;
|
||||
} else {
|
||||
handleAuth();
|
||||
}
|
||||
} else if (response.startsWith("220") && step === 1.5) {
|
||||
// Upgrade to TLS
|
||||
const secureSocket = tls.connect({
|
||||
socket: socket,
|
||||
host: host
|
||||
}, () => {
|
||||
// console.log("[SMTP] STARTTLS successful");
|
||||
});
|
||||
socket.removeAllListeners("data");
|
||||
socket = secureSocket;
|
||||
socket.on("data", onData);
|
||||
socket.on("error", (err) => reject(err));
|
||||
send(`EHLO ${host}`);
|
||||
step = 1.9;
|
||||
} else if (response.startsWith("250") && step === 1.9) {
|
||||
handleAuth();
|
||||
} else if (response.startsWith("334") && step === 2) {
|
||||
send(Buffer.from(user).toString("base64"));
|
||||
step = 3;
|
||||
} else if (response.startsWith("334") && step === 3) {
|
||||
send(Buffer.from(password).toString("base64"));
|
||||
step = 4;
|
||||
} else if (response.startsWith("235") && step === 4) {
|
||||
send(`MAIL FROM:<${from}>`);
|
||||
step = 5;
|
||||
} else if (response.startsWith("250") && (step === 4 || step === 5)) {
|
||||
// If we didn't use AUTH, we might land here after MAIL FROM
|
||||
if (step === 4) {
|
||||
send(`MAIL FROM:<${from}>`);
|
||||
step = 5;
|
||||
} else {
|
||||
send(`RCPT TO:<${to}>`);
|
||||
step = 6;
|
||||
}
|
||||
} else if (response.startsWith("250") && step === 6) {
|
||||
send("DATA");
|
||||
step = 7;
|
||||
} else if (response.startsWith("354") && step === 7) {
|
||||
const message = [
|
||||
`From: ${from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
`Content-Type: text/plain; charset=UTF-8`,
|
||||
"",
|
||||
body,
|
||||
"."
|
||||
].join("\r\n");
|
||||
send(message);
|
||||
step = 8;
|
||||
} else if (response.startsWith("250") && step === 8) {
|
||||
send("QUIT");
|
||||
resolve(true);
|
||||
} else if (response.startsWith("5") || response.startsWith("4")) {
|
||||
reject(new Error(`SMTP Error: ${response.trim()}`));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuth = () => {
|
||||
if (user && password) {
|
||||
send("AUTH LOGIN");
|
||||
step = 2;
|
||||
} else {
|
||||
send(`MAIL FROM:<${from}>`);
|
||||
step = 5;
|
||||
}
|
||||
};
|
||||
|
||||
connect();
|
||||
});
|
||||
};
|
||||
109
src/meme_upload_handler.mjs
Normal file
109
src/meme_upload_handler.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
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 { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import path from "path";
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
export const handleMemeUpload = async (req, res) => {
|
||||
console.log('[BOOT] [MEME HANDLER] Started');
|
||||
|
||||
// Manual Session Lookup
|
||||
let user = [];
|
||||
if (req.cookies && req.cookies.session) {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (user.length === 0 || !user[0].admin) {
|
||||
console.log('[MEME HANDLER] Unauthorized');
|
||||
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
|
||||
// CSRF validation
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked meme upload for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||
|
||||
if (!boundaryMatch) {
|
||||
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
let boundary = boundaryMatch[1].trim();
|
||||
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
||||
boundary = boundary.substring(1, boundary.length - 1);
|
||||
}
|
||||
|
||||
const bodyBuffer = await collectBody(req);
|
||||
const parts = parseMultipart(bodyBuffer, boundary);
|
||||
|
||||
const template_id = (parts.template_id || '').trim().toLowerCase();
|
||||
const name = (parts.name || '').trim();
|
||||
const category = (parts.category || '').trim() || 'General';
|
||||
let url = (parts.url || '').trim();
|
||||
|
||||
if (!template_id || !name) {
|
||||
return sendJson(res, { success: false, message: 'Template ID and Name are required' }, 400);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(template_id)) {
|
||||
return sendJson(res, { success: false, message: 'Invalid ID. Use lowercase a-z, 0-9, - only.' }, 400);
|
||||
}
|
||||
|
||||
const file = parts.file;
|
||||
if (file && file.data && file.data.length > 0) {
|
||||
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : 'jpg';
|
||||
const filename = `${template_id}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||
|
||||
const filePath = path.join(cfg.paths.memes, filename);
|
||||
console.error(`[BOOT] [MEME HANDLER] Writing file to: ${filePath} (Size: ${file.data.length})`);
|
||||
await fs.writeFile(filePath, file.data);
|
||||
|
||||
const exists = (await fs.stat(filePath)).size > 0;
|
||||
console.error(`[BOOT] [MEME HANDLER] Write verify: ${exists ? 'SUCCESS' : 'FAILURE'}`);
|
||||
|
||||
if (exists) {
|
||||
url = `/memes/${filename}`;
|
||||
} else {
|
||||
throw new Error("File was written but verify failed (size 0 or not found)");
|
||||
}
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return sendJson(res, { success: false, message: 'Either image URL or File is required' }, 400);
|
||||
}
|
||||
|
||||
const newMeme = await db`
|
||||
INSERT INTO meme_templates (template_id, name, url, category)
|
||||
VALUES (${template_id}, ${name}, ${url}, ${category})
|
||||
RETURNING id, template_id, name, url, category
|
||||
`;
|
||||
|
||||
return sendJson(res, { success: true, meme: newMeme[0] });
|
||||
|
||||
} catch (err) {
|
||||
console.error('[MEME HANDLER ERROR]', err);
|
||||
return sendJson(res, { success: false, message: err.message }, 500);
|
||||
}
|
||||
};
|
||||
193
src/meta_extract_handler.mjs
Normal file
193
src/meta_extract_handler.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
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";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
/**
|
||||
* Specialized bypass handler for metadata extraction.
|
||||
* Bypasses the main router to ensure raw stream access for multipart data.
|
||||
*/
|
||||
export const handleMetaExtract = async (req, res) => {
|
||||
// Manual session lookup (bypass middleware pattern)
|
||||
if (!req.session && req.cookies?.session) {
|
||||
try {
|
||||
const user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
if (user.length > 0) {
|
||||
req.session = user[0];
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (!req.session) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// CSRF validation
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.error(`[META-EXTRACT] CSRF mismatch for ${req.session.user}`);
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
|
||||
if (!contentType.includes('multipart/form-data') || !boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||
|
||||
// Collect tiny chunk (max 5MB)
|
||||
const body = await collectBody(req, 5 * 1024 * 1024);
|
||||
const parts = parseMultipart(body, boundary);
|
||||
|
||||
const file = parts.file;
|
||||
if (!file || !file.data) {
|
||||
return sendJson(res, { success: false, msg: 'No file chunk provided' }, 400);
|
||||
}
|
||||
|
||||
const tmpPath = path.join(cfg.paths.tmp, `meta_byp_${Math.random().toString(36).substring(7)}`);
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
const results = [];
|
||||
const seen = new Set();
|
||||
const addResult = (val) => {
|
||||
if (!val) return;
|
||||
const clean = String(val)
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/[\x00-\x1F\x7F]/g, '')
|
||||
.trim();
|
||||
if (clean && clean.length > 1 && clean.length <= 255 && !seen.has(clean.toLowerCase())) {
|
||||
seen.add(clean.toLowerCase());
|
||||
results.push(clean);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect image vs video/audio from file chunk magic bytes
|
||||
const magic = file.data.slice(0, 12);
|
||||
const isJpeg = magic[0] === 0xFF && magic[1] === 0xD8;
|
||||
const isPng = magic[0] === 0x89 && magic[1] === 0x50;
|
||||
const isWebp = magic[8] === 0x57 && magic[9] === 0x45 && magic[10] === 0x42 && magic[11] === 0x50;
|
||||
const isGif = magic[0] === 0x47 && magic[1] === 0x49;
|
||||
const isTiff = (magic[0] === 0x49 && magic[1] === 0x49) || (magic[0] === 0x4D && magic[1] === 0x4D);
|
||||
const isImage = isJpeg || isPng || isWebp || isGif || isTiff;
|
||||
|
||||
if (isImage) {
|
||||
// Use exiftool for rich EXIF extraction
|
||||
try {
|
||||
const { stdout: exifOut } = await queue.spawn('exiftool', ['-json', '-charset', 'utf8', tmpPath], { quiet: true });
|
||||
if (exifOut && exifOut.trim()) {
|
||||
const parsed = JSON.parse(exifOut);
|
||||
const tags = parsed[0] || {};
|
||||
|
||||
// Text fields
|
||||
const textFields = [
|
||||
'Title', 'Description', 'Comment', 'Artist', 'Author',
|
||||
'Creator', 'Copyright', 'CopyrightNotice', 'Rights',
|
||||
'Make', 'Model', 'LensModel', 'Software',
|
||||
'ObjectName', 'Headline', 'Caption', 'CaptionAbstract',
|
||||
'Subject', 'Keywords', 'By-line', 'ByLine', 'Credit', 'Source',
|
||||
];
|
||||
for (const key of textFields) {
|
||||
const val = tags[key];
|
||||
if (val) {
|
||||
if (Array.isArray(val)) val.forEach(v => addResult(v));
|
||||
else addResult(val);
|
||||
}
|
||||
}
|
||||
|
||||
// Location text fields
|
||||
const locationFields = [
|
||||
'City', 'Sub-location', 'Province-State', 'Country-PrimaryLocationName',
|
||||
'Country', 'State', 'Location', 'Sublocation', 'CountryCode',
|
||||
'XMP-iptcExt:LocationName', 'XMP-photoshop:City', 'XMP-photoshop:Country',
|
||||
];
|
||||
let hasTextLocation = false;
|
||||
for (const key of locationFields) {
|
||||
if (tags[key]) { addResult(tags[key]); hasTextLocation = true; }
|
||||
}
|
||||
|
||||
// GPS: raw coords + reverse geocode
|
||||
let hasGpsData = false;
|
||||
if (tags['GPSLatitude'] != null && tags['GPSLongitude'] != null) {
|
||||
const lat = parseFloat(tags['GPSLatitude']);
|
||||
const lon = parseFloat(tags['GPSLongitude']);
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
hasGpsData = true;
|
||||
addResult(`${lat.toFixed(6)},${lon.toFixed(6)}`);
|
||||
if (!hasTextLocation && cfg.main.socks) {
|
||||
try {
|
||||
const proxyUrl = cfg.main.socks.replace(/^socks5:\/\//i, 'socks5h://');
|
||||
const nominatimUrl = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`;
|
||||
const { stdout: geoOut } = await queue.spawn('curl', [
|
||||
'--proxy', proxyUrl,
|
||||
'-s', '--max-time', '10',
|
||||
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
nominatimUrl
|
||||
], { quiet: true });
|
||||
if (geoOut && geoOut.trim()) {
|
||||
const geo = JSON.parse(geoOut);
|
||||
const addr = geo.address || {};
|
||||
const parts = [
|
||||
addr.city || addr.town || addr.village || addr.hamlet,
|
||||
addr.state,
|
||||
addr.country,
|
||||
].filter(Boolean);
|
||||
parts.forEach(p => addResult(p));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasGpsData) tags._hasGpsData = true;
|
||||
}
|
||||
} catch (exifErr) {
|
||||
console.warn('[META-EXTRACT] exiftool failed, skipping EXIF:', exifErr.message);
|
||||
}
|
||||
} else {
|
||||
// Video/audio: use ffprobe metadata
|
||||
const metadata = await queue.getVideoMetadata(tmpPath);
|
||||
const keysToCheck = [
|
||||
'title', 'TITLE', 'comment', 'COMMENT', 'artist', 'ARTIST',
|
||||
'album_artist', 'ALBUM_ARTIST', 'author', 'AUTHOR',
|
||||
'genre', 'GENRE', 'description', 'DESCRIPTION',
|
||||
];
|
||||
keysToCheck.forEach(key => {
|
||||
const val = metadata?.[key];
|
||||
if (val && typeof val === 'string') addResult(val);
|
||||
});
|
||||
}
|
||||
|
||||
if (cfg.websrv.debug) {
|
||||
console.log(`[META-EXTRACT] Result for ${req.session.user}: ${results.length} unique fields found`);
|
||||
}
|
||||
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
fields: results,
|
||||
hasGpsData: isImage && results.some(r => /^-?\d+\.\d+,-?\d+\.\d+$/.test(r))
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[META-BYPASS-EXTRACT ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: 'Error extracting metadata' }, 500);
|
||||
}
|
||||
};
|
||||
93
src/meta_strip_handler.mjs
Normal file
93
src/meta_strip_handler.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
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";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
|
||||
/**
|
||||
* Strip GPS/location EXIF data from an uploaded image chunk and return the clean bytes.
|
||||
* POST /api/v2/meta/strip-gps
|
||||
*/
|
||||
export const handleMetaStrip = async (req, res) => {
|
||||
// Manual session lookup
|
||||
if (!req.session && req.cookies?.session) {
|
||||
try {
|
||||
const user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
if (user.length > 0) req.session = user[0];
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (!req.session) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
|
||||
}
|
||||
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' }));
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
if (!contentType.includes('multipart/form-data') || !boundaryMatch) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ success: false, msg: 'Invalid content type' }));
|
||||
}
|
||||
|
||||
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||
const body = await collectBody(req, 20 * 1024 * 1024); // 20MB max for full image
|
||||
const parts = parseMultipart(body, boundary);
|
||||
|
||||
const file = parts.file;
|
||||
if (!file || !file.data) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return res.end(JSON.stringify({ success: false, msg: 'No file provided' }));
|
||||
}
|
||||
|
||||
const tmpPath = path.join(cfg.paths.tmp, `strip_gps_${Math.random().toString(36).substring(7)}`);
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Strip all GPS and location EXIF tags in-place
|
||||
await queue.spawn('exiftool', [
|
||||
'-gps:all=',
|
||||
'-xmp:gps:all=',
|
||||
'-iptc:city=',
|
||||
'-iptc:province-state=',
|
||||
'-iptc:country-primary-location-name=',
|
||||
'-iptc:sub-location=',
|
||||
'-overwrite_original',
|
||||
tmpPath,
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
|
||||
// Read the cleaned file and send it back
|
||||
const cleanData = await fs.readFile(tmpPath);
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
const filename = file.filename || 'image';
|
||||
const mime = file.contentType || 'application/octet-stream';
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': mime,
|
||||
'Content-Length': cleanData.length,
|
||||
'Content-Disposition': `attachment; filename="${filename}"`,
|
||||
'X-GPS-Stripped': '1',
|
||||
});
|
||||
res.end(cleanData);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[META-STRIP-GPS ERROR]', err);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, msg: 'Failed to strip GPS data' }));
|
||||
}
|
||||
};
|
||||
164
src/rethumb_handler.mjs
Normal file
164
src/rethumb_handler.mjs
Normal file
@@ -0,0 +1,164 @@
|
||||
import cfg from "./inc/config.mjs";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import db from "./inc/sql.mjs";
|
||||
import lib from "./inc/lib.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import queue from "./inc/queue.mjs";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
export const handleRethumbUpload = async (req, res, itemId) => {
|
||||
console.log('[RETHUMB HANDLER] Upload started for item:', itemId);
|
||||
|
||||
// Manual Session Lookup
|
||||
let user = [];
|
||||
if (req.cookies && req.cookies.session) {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (user.length === 0) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
|
||||
// CSRF validation
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked rethumb upload for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify Item exists and permissions
|
||||
let item;
|
||||
try {
|
||||
const items = await db`select id, username, mime, active from items where id = ${+itemId} limit 1`;
|
||||
if (items.length === 0) {
|
||||
return sendJson(res, { success: false, msg: 'Item not found' }, 404);
|
||||
}
|
||||
item = items[0];
|
||||
} catch (e) {
|
||||
console.error('[RETHUMB HANDLER] Database error:', e);
|
||||
return sendJson(res, { success: false, msg: 'Database error' }, 500);
|
||||
}
|
||||
|
||||
// Permission check
|
||||
if (!req.session.admin && !req.session.is_moderator && req.session.user !== item.username) {
|
||||
return sendJson(res, { success: false, msg: 'Insufficient permissions' }, 403);
|
||||
}
|
||||
|
||||
// Must be a Flash file (only flash files allow custom thumbnail reuploads currently)
|
||||
if (item.mime !== 'application/x-shockwave-flash' && item.mime !== 'application/vnd.adobe.flash.movie') {
|
||||
return sendJson(res, { success: false, msg: 'Only Flash uploads support custom thumbnails at this time' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||
|
||||
if (!boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
const body = await collectBody(req);
|
||||
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||
const file = parts.file;
|
||||
|
||||
if (!file || !file.data) {
|
||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// Validate file size (5MB max)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.data.length > maxSize) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
let mime = file.contentType.toLowerCase();
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Save to tmp for verification
|
||||
const uuid = (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
|
||||
const tmpPath = path.join(cfg.paths.tmp, `rethumb_${uuid}_${itemId}`);
|
||||
|
||||
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Verify MIME
|
||||
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||
const allowedActualMimes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
if (!allowedActualMimes.includes(actualMime.trim())) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `Invalid file type detected: ${actualMime.trim()}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Generate the thumbnail
|
||||
const tDir = item.active ? cfg.paths.t : path.join(cfg.paths.pending, 't');
|
||||
const finalPath = path.join(tDir, `${item.id}.webp`);
|
||||
|
||||
try {
|
||||
await execFile('magick', [tmpPath, '-coalesce', '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', finalPath]);
|
||||
|
||||
// Check if item contains NSFW or NSFL tag
|
||||
const tags = await db`
|
||||
select tag_id from tags_assign
|
||||
where item_id = ${+item.id}
|
||||
and tag_id in (2, ${cfg.nsfl_tag_id || 3})
|
||||
`;
|
||||
if (tags.length > 0) {
|
||||
// Generate blurred thumbnail
|
||||
await queue.genBlurredThumbnail(item.id, !item.active);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[RETHUMB HANDLER] Magick error:', err);
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
|
||||
}
|
||||
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
|
||||
console.log('[RETHUMB HANDLER] Custom thumbnail applied to item', item.id);
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
msg: 'Thumbnail updated successfully'
|
||||
}, 200);
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'BODY_TOO_LARGE') {
|
||||
return sendJson(res, { success: false, msg: 'File too large (5 MB max)' }, 413);
|
||||
}
|
||||
console.error('[RETHUMB HANDLER ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: lib.logError(err, 'Thumbnail Upload failed') }, 500);
|
||||
}
|
||||
};
|
||||
624
src/upload_handler.mjs
Normal file
624
src/upload_handler.mjs
Normal file
@@ -0,0 +1,624 @@
|
||||
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";
|
||||
import https from "https";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck } from "./inc/settings.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
|
||||
// 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, self) => {
|
||||
// Manual session lookup is required here because this handler is called from a
|
||||
// bypass middleware that runs in parallel with the main session middleware.
|
||||
if (req.cookies?.session) {
|
||||
try {
|
||||
const user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "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.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
if (user.length > 0) {
|
||||
req.session = user[0];
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.session) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel.
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
|
||||
// Robust boundary extraction (handles both quoted and unquoted boundaries)
|
||||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
|
||||
if (!contentType || !contentType.includes('multipart/form-data') || !boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||
|
||||
// Determine max file size early for collectBody
|
||||
let effectiveMaxBytes = cfg.main.maxfilesize || (150 * 1024 * 1024);
|
||||
if (req.session?.admin) {
|
||||
effectiveMaxBytes = Math.floor(effectiveMaxBytes * (cfg.main.adminmultiplier || 10));
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = await collectBody(req, effectiveMaxBytes);
|
||||
} catch (bodyErr) {
|
||||
throw bodyErr;
|
||||
}
|
||||
|
||||
const parts = parseMultipart(body, boundary);
|
||||
|
||||
// Validate required fields
|
||||
const file = parts.file;
|
||||
const rating = parts.rating;
|
||||
const tagsRaw = parts.tags;
|
||||
const comment = parts.comment ? parts.comment.trim() : '';
|
||||
|
||||
const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1');
|
||||
|
||||
if (!file || !file.data) {
|
||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
|
||||
return sendJson(res, { success: false, msg: 'Rating (sfw/nsfw/nsfl) is required' }, 400);
|
||||
}
|
||||
|
||||
if (rating === 'nsfl' && !cfg.enable_nsfl) {
|
||||
return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400);
|
||||
}
|
||||
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||
const minTags = getMinTags();
|
||||
if (tags.length < minTags) {
|
||||
return sendJson(res, { success: false, msg: `At least ${minTags} tags are required` }, 400);
|
||||
}
|
||||
|
||||
// Validate MIME type
|
||||
const allowedMimes = Object.keys(cfg.mimes);
|
||||
let mime = file.contentType;
|
||||
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
||||
}
|
||||
|
||||
// Size was already validated by collectBody (effectiveMaxBytes)
|
||||
const size = file.data.length;
|
||||
|
||||
let manualApproval = getManualApproval();
|
||||
|
||||
// Enforce manual approval for untrusted users (configurable threshold)
|
||||
// Admins and moderators are exempt from this check
|
||||
const trustedThreshold = getTrustedUploads();
|
||||
if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) {
|
||||
try {
|
||||
const totalUploads = await db`
|
||||
SELECT count(*) as count
|
||||
FROM items
|
||||
WHERE username = ${req.session.user}
|
||||
AND is_deleted = false
|
||||
`;
|
||||
if (parseInt(totalUploads[0].count) < trustedThreshold) {
|
||||
console.log(`[UPLOAD] Forcing manual approval for new user: ${req.session.user} (Upload count: ${totalUploads[0].count}/${trustedThreshold})`);
|
||||
manualApproval = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Failed to check total upload count:', err);
|
||||
// Default to manual approval on error for safety if we are unsure
|
||||
manualApproval = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Rate Limit Check (if manual approval is disabled)
|
||||
// Admins and moderators are exempt from rate limiting
|
||||
if (!manualApproval && !req.session.admin && !req.session.is_moderator) {
|
||||
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
|
||||
const uploadCount = await db`
|
||||
SELECT count(*) as count
|
||||
FROM items
|
||||
WHERE username = ${req.session.user}
|
||||
AND stamp > ${twelveHoursAgo}
|
||||
AND is_deleted = false
|
||||
`;
|
||||
if (parseInt(uploadCount[0].count) >= 69) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: 'Rate limit exceeded. You can only upload 69 files every 12 hours.'
|
||||
}, 429);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate UUID & Base Paths
|
||||
const uuid = await queue.genuuid();
|
||||
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
|
||||
// Ensure directories exist
|
||||
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||
await fs.mkdir(path.join(cfg.paths.pending, 'b'), { recursive: true });
|
||||
await fs.mkdir(path.join(cfg.paths.pending, 't'), { recursive: true });
|
||||
await fs.mkdir(path.join(cfg.paths.pending, 'ca'), { recursive: true });
|
||||
|
||||
// Save temporarily to detect actual MIME
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Verify MIME
|
||||
let actualMime = (await queue.spawn('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);
|
||||
}
|
||||
|
||||
// Reclassify audio-only MP4 containers (e.g. .m4a files detected as video/mp4)
|
||||
if (actualMime === 'video/mp4' || actualMime === 'video/quicktime') {
|
||||
const origExt = file.filename.split('.').pop().toLowerCase();
|
||||
if (['m4a', 'aac'].includes(origExt)) {
|
||||
actualMime = 'audio/mp4';
|
||||
console.log(`[UPLOAD] Reclassified ${origExt} from video/mp4 to audio/mp4`);
|
||||
} else {
|
||||
// Check with ffprobe if it has video streams
|
||||
try {
|
||||
const probeResult = await queue.spawn('ffprobe', [
|
||||
'-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=codec_type',
|
||||
'-of', 'csv=p=0', tmpPath
|
||||
]);
|
||||
if (!probeResult.stdout.trim()) {
|
||||
actualMime = 'audio/mp4';
|
||||
console.log(`[UPLOAD] Reclassified audio-only MP4 to audio/mp4`);
|
||||
}
|
||||
} catch (err) {
|
||||
// ffprobe not available or failed, keep original MIME
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ext = cfg.mimes[actualMime] || 'bin';
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const destPath = path.join(cfg.paths.pending, 'b', filename);
|
||||
|
||||
// Constants
|
||||
const checksum = (await queue.spawn('sha256sum', [tmpPath])).stdout.trim().split(" ")[0];
|
||||
|
||||
// Check repost
|
||||
if (!getBypassDuplicateCheck()) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// PHash check
|
||||
let phash = null;
|
||||
try {
|
||||
phash = await queue.generatePHash(tmpPath);
|
||||
if (phash && !getBypassDuplicateCheck()) {
|
||||
const phashMatch = await queue.checkrepostphash(phash);
|
||||
if (phashMatch) {
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `This file is a visual duplicate`,
|
||||
repost: phashMatch
|
||||
}, 409);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[UPLOAD] PHash error:', e);
|
||||
}
|
||||
|
||||
// When bypass is active, symlink to the existing file if one already exists on disk.
|
||||
// We write the symlink straight into cfg.paths.b so we can skip the pending flow entirely.
|
||||
let linkedToExisting = false;
|
||||
if (getBypassDuplicateCheck()) {
|
||||
console.error(`[UPLOAD] bypass: looking up existing file for checksum ${checksum}`);
|
||||
const existing = await db`
|
||||
SELECT dest, checksum FROM items
|
||||
WHERE checksum = ${checksum} OR checksum LIKE ${checksum + '_bypass_%'}
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
console.error(`[UPLOAD] bypass: DB lookup found ${existing.length} row(s)`, existing.length ? existing[0].checksum : '');
|
||||
if (existing.length > 0) {
|
||||
const existingFile = existing[0].dest;
|
||||
const existingAbsPath = path.join(cfg.paths.b, existingFile);
|
||||
try {
|
||||
// Resolve to the real file to avoid symlink chains
|
||||
const realTargetAbsPath = await fs.realpath(existingAbsPath);
|
||||
|
||||
// Determine where the symlink will live
|
||||
// If manual approval is enabled, it lives in pending/b. Otherwise directly in public/b.
|
||||
const symlinkPath = manualApproval ? destPath : path.join(cfg.paths.b, filename);
|
||||
const symlinkDir = path.dirname(symlinkPath);
|
||||
|
||||
// Calculate relative path for the symlink target
|
||||
const relativeTarget = path.relative(symlinkDir, realTargetAbsPath);
|
||||
|
||||
await fs.symlink(relativeTarget, symlinkPath);
|
||||
linkedToExisting = true;
|
||||
console.error(`[UPLOAD] bypass: symlinked ${symlinkPath} → ${relativeTarget}`);
|
||||
} catch (e) {
|
||||
console.error(`[UPLOAD ERROR] bypass symlink failed:`, e);
|
||||
}
|
||||
} else {
|
||||
console.error(`[UPLOAD] bypass: no existing file found for ${checksum}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!linkedToExisting) {
|
||||
// Normal path: copy tmp to pending/b, to be moved to public later
|
||||
await fs.copyFile(tmpPath, destPath);
|
||||
}
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
|
||||
// When bypass is active the real checksum may already exist in the DB (unique constraint).
|
||||
// Suffix it so the INSERT can proceed — the file is genuinely a new item entry.
|
||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
|
||||
// Insert
|
||||
await db`
|
||||
insert into items ${db({
|
||||
src: '',
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
size: size,
|
||||
checksum: insertChecksum,
|
||||
phash: phash,
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !manualApproval,
|
||||
is_oc: is_oc
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')
|
||||
}
|
||||
`;
|
||||
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
// Automatically subscribe uploader to comment thread
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${req.session.id}, ${itemid})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD HANDLER] Failed to auto-subscribe uploader:', err);
|
||||
}
|
||||
|
||||
// Thumbnail & Coverart
|
||||
const isPending = linkedToExisting ? manualApproval : true;
|
||||
let thumbProcessed = false;
|
||||
|
||||
// Custom Thumbnail for Flash
|
||||
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
|
||||
if (parts.thumbnail && parts.thumbnail.data && parts.thumbnail.data.length > 0) {
|
||||
try {
|
||||
const thumbTmp = path.join(cfg.paths.tmp, `${itemid}_custom_thumb.tmp`);
|
||||
await fs.writeFile(thumbTmp, parts.thumbnail.data);
|
||||
|
||||
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const thumbDest = path.join(tDir, `${itemid}.webp`);
|
||||
|
||||
await queue.spawn('magick', [thumbTmp, '-resize', '128x128^', '-gravity', 'center', '-crop', '128x128+0+0', '+repage', thumbDest]);
|
||||
await fs.unlink(thumbTmp).catch(() => {});
|
||||
thumbProcessed = true;
|
||||
console.log(`[UPLOAD] Custom thumbnail processed for Flash item ${itemid}`);
|
||||
} catch (thumbErr) {
|
||||
console.error(`[UPLOAD] Custom thumbnail processing failed for item ${itemid}:`, thumbErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!thumbProcessed) {
|
||||
await queue.genThumbnail(filename, actualMime, itemid, '', isPending);
|
||||
}
|
||||
|
||||
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
// Fallback to placeholder for thumbnail ONLY if it hasn't been processed yet
|
||||
if (!thumbProcessed) {
|
||||
const tPath = !isPending
|
||||
? path.join(cfg.paths.t, itemid + '.webp')
|
||||
: path.join(cfg.paths.pending, 't', itemid + '.webp');
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', tPath]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate blurred thumbnail for NSFW/NSFL
|
||||
if (rating === 'nsfw' || rating === 'nsfl') {
|
||||
await queue.genBlurredThumbnail(itemid, isPending);
|
||||
}
|
||||
|
||||
// Insert optional first comment
|
||||
if (comment && comment.length > 0 && comment.length <= 2000) {
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: req.session.id,
|
||||
content: comment
|
||||
})}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD HANDLER] Failed to insert comment:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
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
|
||||
`;
|
||||
}
|
||||
|
||||
// Assign OC tags if the uploader ticked the OC checkbox
|
||||
if (is_oc) {
|
||||
const ocTags = ['oc', 'original content'];
|
||||
for (const tagname of ocTags) {
|
||||
const normalized = tagname.replace(/\s+/g, '-').toLowerCase();
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
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`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-tag SWF uploads with "Flash" and "SWF"
|
||||
if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') {
|
||||
const swfTags = ['Flash', 'SWF'];
|
||||
for (const tagname of swfTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
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`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Action if auto-approved
|
||||
if (!manualApproval) {
|
||||
if (!linkedToExisting) {
|
||||
// Move logic: Handles both real files and symlinks (reposts) correctly
|
||||
const moveSafe = async (src, dst) => {
|
||||
try {
|
||||
const lstat = await fs.lstat(src);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(src);
|
||||
const absTarget = path.resolve(path.dirname(src), target);
|
||||
const relTarget = path.relative(path.dirname(dst), absTarget);
|
||||
await fs.symlink(relTarget, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
} else {
|
||||
await fs.copyFile(src, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[UPLOAD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const itemDest = path.join(cfg.paths.b, filename);
|
||||
const thumbDest = path.join(cfg.paths.t, `${itemid}.webp`);
|
||||
const blurDest = path.join(cfg.paths.t, `${itemid}_blur.webp`);
|
||||
const coverDest = path.join(cfg.paths.ca, `${itemid}.webp`);
|
||||
|
||||
await moveSafe(destPath, itemDest);
|
||||
await moveSafe(path.join(cfg.paths.pending, 't', `${itemid}.webp`), thumbDest);
|
||||
|
||||
if (actualMime.startsWith('audio')) {
|
||||
await moveSafe(path.join(cfg.paths.pending, 'ca', `${itemid}.webp`), coverDest);
|
||||
}
|
||||
if (rating === 'nsfw' || rating === 'nsfl') {
|
||||
await moveSafe(path.join(cfg.paths.pending, 't', `${itemid}_blur.webp`), blurDest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- From here on, we process in the background to return to the user immediately ---
|
||||
const backgroundProcess = async () => {
|
||||
try {
|
||||
// Thumbnail & Coverart is now primarily handled synchronously
|
||||
// for immediate visual feedback in some cases, but we keep this as safety
|
||||
// EXCEPT for custom thumbnails which are already processed.
|
||||
const isPending = manualApproval;
|
||||
try {
|
||||
// If it's Flash, we might have already processed a custom thumbnail.
|
||||
// We'll only run genThumbnail if the thumbnail doesn't exist yet.
|
||||
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const thumbPath = path.join(tDir, `${itemid}.webp`);
|
||||
const thumbExists = await fs.access(thumbPath).then(() => true).catch(() => false);
|
||||
|
||||
if (!thumbExists) {
|
||||
await queue.genThumbnail(filename, actualMime, itemid, '', isPending);
|
||||
}
|
||||
|
||||
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BACKGROUND ERROR] genThumbnail failed for item ${itemid}:`, err);
|
||||
}
|
||||
|
||||
// Ensure blurred thumbnail exists if needed
|
||||
if (rating === 'nsfw' || rating === 'nsfl') {
|
||||
const tDir = isPending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const blurPath = path.join(tDir, `${itemid}_blur.webp`);
|
||||
const blurExists = await fs.access(blurPath).then(() => true).catch(() => false);
|
||||
if (!blurExists) {
|
||||
await queue.genBlurredThumbnail(itemid, isPending).catch(err => console.error(`[BACKGROUND ERROR] genBlurredThumbnail failed:`, err));
|
||||
}
|
||||
}
|
||||
|
||||
// Note: video title metadata is surfaced to the user as a suggestion in the upload form.
|
||||
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
|
||||
|
||||
|
||||
// Discord Webhook
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
|
||||
const payload = JSON.stringify({ content: message });
|
||||
const url = new URL(discordClient.webhook_url);
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload)
|
||||
}
|
||||
};
|
||||
const reqDiscord = https.request(options, (resDiscord) => { });
|
||||
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
|
||||
reqDiscord.write(payload);
|
||||
reqDiscord.end();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
if (!manualApproval) {
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
username: req.session.user,
|
||||
display_name: req.session.display_name || null,
|
||||
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
|
||||
is_oc: !!is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||
const clients = await Promise.all(self.bot.clients);
|
||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||
if (matrixWrapper?.client) {
|
||||
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
|
||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Matrix notification error:', err);
|
||||
}
|
||||
|
||||
// Staff Notifications
|
||||
if (manualApproval) {
|
||||
try {
|
||||
const staff = await db`select id, login from "user" where admin = true or is_moderator = true`;
|
||||
const notifications = staff.map(user => ({
|
||||
user_id: user.id,
|
||||
type: 'admin_pending',
|
||||
reference_id: 0,
|
||||
item_id: itemid
|
||||
}));
|
||||
|
||||
if (notifications.length > 0) {
|
||||
await db`INSERT INTO notifications ${db(notifications)} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD HANDLER] Failed to notify staff:', err);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (globalErr) {
|
||||
console.error(`[CRITICAL BACKGROUND ERROR] Item ${itemid}:`, globalErr);
|
||||
}
|
||||
};
|
||||
|
||||
// Start background processing without awaiting
|
||||
backgroundProcess();
|
||||
|
||||
const successMsg = manualApproval
|
||||
? 'Upload successful! Your upload is pending admin approval.'
|
||||
: 'Upload successful! Your upload is now live.';
|
||||
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
msg: successMsg,
|
||||
itemid: itemid,
|
||||
manual_approval: manualApproval,
|
||||
redirect: !manualApproval ? `/${itemid}` : null
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'BODY_TOO_LARGE') {
|
||||
const isBoosted = req.session?.admin || req.session?.is_moderator;
|
||||
console.error(`[UPLOAD HANDLER ERROR] [BODY_TOO_LARGE] User: ${req.session?.user || 'unknown'}. Limit: ${lib.formatSize(cfg.main.maxfilesize * (isBoosted ? cfg.main.adminmultiplier : 1))}`);
|
||||
return sendJson(res, { success: false, msg: 'File too large' }, 413);
|
||||
}
|
||||
console.error('[UPLOAD HANDLER ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: lib.logError(err, 'Upload failed') }, 500);
|
||||
}
|
||||
};
|
||||
117
src/user_hall_image_handler.mjs
Normal file
117
src/user_hall_image_handler.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
// user_hall_image_handler.mjs
|
||||
// Handles multipart upload of custom images for user-owned halls.
|
||||
// Mirrors the pattern used in hall_image_handler.mjs.
|
||||
|
||||
import cfg from "./inc/config.mjs";
|
||||
import path from "path";
|
||||
import { promises as fs } from "fs";
|
||||
import db from "./inc/sql.mjs";
|
||||
import lib from "./inc/lib.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
const HALL_CUSTOM_DIR = path.join(cfg.paths.s, '../hall_custom');
|
||||
const HALL_CACHE_DIR = path.join(cfg.paths.s, '../hall_cache');
|
||||
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
const lookupSession = async (req) => {
|
||||
if (!req.cookies?.session) return null;
|
||||
const users = await db`
|
||||
SELECT "user".id, "user".user, "user".admin, "user".is_moderator,
|
||||
"user_sessions".id as sess_id, "user_sessions".csrf_token
|
||||
FROM "user_sessions"
|
||||
LEFT JOIN "user" ON "user".id = "user_sessions".user_id
|
||||
WHERE "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
LIMIT 1
|
||||
`;
|
||||
return users.length > 0 ? users[0] : null;
|
||||
};
|
||||
|
||||
const clearUserHallCache = async (userId, slug) => {
|
||||
const { createHash } = await import('crypto');
|
||||
for (const mode of [0, 1, 2]) {
|
||||
const hash = createHash('md5').update(`uh_${userId}_${slug}_${mode}`).digest('hex');
|
||||
await fs.unlink(path.join(HALL_CACHE_DIR, hash + '.webp')).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// Called by the bypass middleware in index.mjs for POST /api/v2/me/halls/:slug/image
|
||||
export const handleUserHallImageUpload = async (req, res, slug) => {
|
||||
const session = await lookupSession(req);
|
||||
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||
|
||||
// CSRF check
|
||||
const token = req.headers['x-csrf-token'];
|
||||
if (!token || token !== session.csrf_token) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
|
||||
// Look up the hall (must be owned by this user)
|
||||
const hall = (await db`
|
||||
SELECT id FROM user_halls
|
||||
WHERE user_id = ${session.id} AND slug = ${slug}
|
||||
LIMIT 1
|
||||
`)[0];
|
||||
if (!hall) return sendJson(res, { success: false, msg: 'Hall not found' }, 404);
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||
if (!boundaryMatch) return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
|
||||
let boundary = boundaryMatch[1].trim();
|
||||
if (boundary.startsWith('"') && boundary.endsWith('"')) boundary = boundary.slice(1, -1);
|
||||
|
||||
const body = await collectBody(req);
|
||||
const parts = parseMultipart(body, boundary);
|
||||
const file = parts.file;
|
||||
|
||||
if (!file?.data) return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
if (file.data.length > 10 * 1024 * 1024) return sendJson(res, { success: false, msg: 'File too large (10 MB max)' }, 400);
|
||||
|
||||
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
|
||||
const fileMime = (file.contentType || '').toLowerCase().trim();
|
||||
if (!allowedMimes.includes(fileMime))
|
||||
return sendJson(res, { success: false, msg: 'Invalid file type: ' + fileMime }, 400);
|
||||
|
||||
await fs.mkdir(HALL_CUSTOM_DIR, { recursive: true });
|
||||
|
||||
const tmpPath = path.join(cfg.paths.tmp || '/tmp', `uhall_${session.id}_${slug}_tmp`);
|
||||
const finalPath = path.join(HALL_CUSTOM_DIR, `u_${session.id}_${slug}.webp`);
|
||||
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Verify with file magic
|
||||
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||
if (!['image/gif', 'image/jpeg', 'image/png', 'image/webp'].includes(actualMime.trim())) {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: 'Invalid file type: ' + actualMime.trim() }, 400);
|
||||
}
|
||||
|
||||
// Resize to 600x300 webp
|
||||
try {
|
||||
await execFile('magick', [tmpPath, '-coalesce', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', '-quality', '85', finalPath]);
|
||||
} catch (err) {
|
||||
console.error('[USER_HALL_IMG] Magick error:', err.stderr || err.message);
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: 'Failed to process image' }, 500);
|
||||
}
|
||||
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
await clearUserHallCache(session.id, slug);
|
||||
await db`UPDATE user_halls SET custom_image = true WHERE id = ${hall.id}`;
|
||||
|
||||
console.log('[USER_HALL_IMG] Uploaded custom image for:', slug, 'by user:', session.id);
|
||||
return sendJson(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[USER_HALL_IMG] Upload error:', err);
|
||||
return sendJson(res, { success: false, msg: err.message || 'Upload failed' }, 500);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user