init f0ckm

This commit is contained in:
2026-04-25 19:51:52 +02:00
commit b646107eb7
241 changed files with 70364 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
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 0999' }, 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;
};

View 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;
};

View 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;
};