alotta good shit

This commit is contained in:
2026-09-19 06:20:37 +02:00
parent 74f7884525
commit 1477d56658
45 changed files with 4363 additions and 2069 deletions
+2
View File
@@ -31,6 +31,7 @@
],
"enable_pdf": false,
"enable_nsfl": false,
"enable_comments": true,
"enable_private_uploads": true,
"enable_expiring_uploads": true,
"default_upload_visibility": 0,
@@ -124,6 +125,7 @@
"halls_enabled": true,
"userhalls_enabled": true,
"enable_userhall_image_upload": true,
"enable_oc": true,
"abyss_enabled": true,
"meme_creator": true,
"enable_cleanup": false,
+31
View File
@@ -0,0 +1,31 @@
-- passkey_credentials: stores WebAuthn credential records for all users
-- (both registered accounts and anonymous shadow users)
CREATE TABLE IF NOT EXISTS public.passkey_credentials (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE, -- base64url-encoded credentialId
public_key_spki TEXT NOT NULL, -- base64-encoded DER SPKI (ES-256 / P-256)
sign_count BIGINT NOT NULL DEFAULT 0, -- replay-attack counter
aaguid TEXT, -- authenticator AAGUID (informational)
name TEXT, -- user-assigned label ("Bitwarden", "iPhone")
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_used TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON public.passkey_credentials(credential_id);
CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON public.passkey_credentials(user_id);
-- Extend anon_identities to support passkey-based identities.
-- New passkey rows use credential_id; legacy SSH rows retain pubkey/fingerprint.
-- pubkey and fingerprint are made nullable to allow passkey-only rows.
ALTER TABLE public.anon_identities
ADD COLUMN IF NOT EXISTS credential_id TEXT UNIQUE;
ALTER TABLE public.anon_identities
ALTER COLUMN pubkey DROP NOT NULL;
ALTER TABLE public.anon_identities
ALTER COLUMN fingerprint DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_anon_identities_credential_id
ON public.anon_identities(credential_id);
+1
View File
@@ -0,0 +1 @@
ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS categories text[] DEFAULT '{}';
+109 -9
View File
@@ -14062,6 +14062,79 @@ textarea.mod-reason:focus {
font-size: 0.75em;
}
/* Audit filter bar */
.audit-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 18px;
padding: 12px 14px;
background: var(--badge-bg);
border: 1px solid var(--nav-border-color);
border-radius: 8px;
}
.audit-filter-input {
background: rgba(0, 0, 0, 0.3);
border: 1px solid var(--nav-border-color);
border-radius: 5px;
color: inherit;
font-size: 0.85em;
padding: 5px 10px;
outline: none;
transition: border-color 0.2s;
}
.audit-filter-input:focus {
border-color: var(--accent);
}
select.audit-filter-input {
min-width: 180px;
}
input.audit-filter-input {
min-width: 160px;
}
.audit-filter-btn {
cursor: pointer;
font-size: 0.8em;
padding: 5px 12px;
border: none;
line-height: 1.6;
}
.audit-filter-clear {
opacity: 0.7;
}
.audit-filter-clear:hover {
opacity: 1;
}
/* Header right group (time + id) */
.audit-card-header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
/* Entry ID badge */
.audit-entry-id {
font-size: 0.72em;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
color: var(--accent);
background: rgba(0, 0, 0, 0.25);
border: 1px solid var(--nav-border-color);
border-radius: 4px;
padding: 1px 6px;
letter-spacing: 0.03em;
white-space: nowrap;
}
.audit-diff {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
@@ -17393,7 +17466,7 @@ textarea#profile_description {
.user-infobox-block {
display: flex;
gap: 10px;
gap: 5px;
background: var(--comment-bg);
border: 1px solid var(--author-border, rgba(0, 255, 0, 0.2));
line-height: 1;
@@ -17401,9 +17474,8 @@ textarea#profile_description {
align-items: flex-start;
width: 100%;
max-width: 100%;
min-height: 90px;
min-height: 40px;
box-sizing: border-box;
margin-bottom: 10px;
}
/* ── Banner inside alternative infobox ──────────────────────────────── */
@@ -17435,8 +17507,8 @@ textarea#profile_description {
.user-infobox-avatar img {
width: 64px;
height: 64px;
width: auto;
height: 22px;
object-fit: cover;
}
@@ -17447,12 +17519,23 @@ textarea#profile_description {
}
.user-infobox-header {
display: flex;
justify-content: space-between;
display: grid;
align-items: center;
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid var(--nav-border-color);
grid-template-columns: auto 1fr;
}
.user-infobox-mime {
background: black;
padding: 4px;
margin: 2px;
font-size: 6px;
border-radius: 3px;
font-weight: bold;
text-transform: uppercase;
cursor: default;
user-select: none;
align-self: self-end;
}
.user-infobox-actions i {
@@ -17485,6 +17568,7 @@ textarea#profile_description {
font-size: 0.8em;
color: #888;
letter-spacing: 0.5px;
text-align: right;
}
.timestamp-link {
@@ -21592,6 +21676,7 @@ div#flash,
background: var(--nav-bg, #2b2b2b);
color: var(--white, #fff);
border: 1px solid var(--nav-border-color, #444);
margin-right: 4px;
}
.bulk-btn-close:hover {
@@ -21600,6 +21685,21 @@ div#flash,
border-color: #ff4444 !important;
}
.bulk-btn-danger {
background: transparent !important;
color: #ff5555 !important;
border: 1px solid #ff5555 !important;
font-weight: bold !important;
box-shadow: none !important;
}
.bulk-btn-danger:hover {
background: #ff4444 !important;
color: #fff !important;
border-color: #ff4444 !important;
filter: none !important;
}
/* Bulk rating dropdown wrapper */
.bulk-dropdown-wrap {
position: relative;
+113 -10
View File
@@ -396,18 +396,48 @@
}
};
const deleteButtonEvent = async e => {
if (e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
const ctx = getContext();
if (!ctx) return;
const { postid, poster, authorId } = ctx;
const deleteSubItemEvent = async (subf0ck, postid) => {
if (!subf0ck || !postid) return;
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
const subDesc = subf0ck.display_index
? `Slide #${subf0ck.display_index}`
: `Slide (${subf0ck.slug || subf0ck.id})`;
ModAction.confirm(
'Delete Slide from Album',
`Are you sure you want to delete <strong style="color:#d9534f">${subDesc}</strong> from this album?<br><small style="opacity:0.8;">The rest of the album will remain intact.</small>`,
async (reason) => {
const res = await post("/api/v2/admin/delete-album-item", {
postid: postid,
sub_id: subf0ck.id,
sub_slug: subf0ck.slug,
order_index: subf0ck.order_index,
reason: reason
});
if (!res.success) {
throw new Error(res.msg || 'Failed to delete album item');
}
if (res.post_deleted) {
if (window.flashMessage) window.flashMessage('Album deleted (no remaining items)', 2500, 'info');
const mediaObj = document.querySelector('.media-object');
if (mediaObj) {
mediaObj.innerHTML = '<div style="padding: 100px; text-align: center; color: #d9534f;"><h1>Album Deleted</h1><p>The album has been removed.</p></div>';
}
} else {
if (window.albumGallery && typeof window.albumGallery.removeSubf0ck === 'function') {
window.albumGallery.removeSubf0ck(subf0ck.id || subf0ck.slug || subf0ck.order_index);
} else {
window.location.reload();
}
if (window.flashMessage) window.flashMessage('Slide deleted from album', 2500, 'success');
}
},
{ allowEmpty: window.f0ckSession?.is_admin, confirmText: 'Delete Slide' }
);
};
const deleteEntirePost = (postid, poster, authorId) => {
const i18n = window.f0ckI18n || {};
const confirmTitle = i18n.item_delete_title || 'Delete Item';
const posterStr = poster
@@ -438,6 +468,68 @@
}, { allowEmpty: window.f0ckSession?.is_admin });
};
const deleteButtonEvent = async e => {
if (e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
const ctx = getContext();
if (!ctx) return;
const { postid, poster, authorId } = ctx;
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
const isAlbum = !!(window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function');
const curSub = isAlbum ? window.albumGallery.getCurrentSubf0ck() : null;
if (isAlbum && curSub) {
const subDesc = curSub.display_index ? `Slide ${curSub.display_index}` : 'Current Slide';
const choiceHtml = `
<div style="margin-bottom: 15px; font-size: 1.05rem;">
This post is an album containing multiple slides. What would you like to delete?
</div>
<div style="display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<button type="button" id="btn-choice-delete-sub" class="btn btn-warning" style="padding: 8px 16px; font-weight: 600; cursor: pointer;">
<i class="fa-solid fa-trash-can"></i> Delete ${subDesc} Only
</button>
<button type="button" id="btn-choice-delete-album" class="btn btn-danger" style="padding: 8px 16px; font-weight: 600; cursor: pointer;">
<i class="fa-solid fa-layer-group"></i> Delete Entire Album
</button>
</div>
`;
ModAction.confirm('Delete Options', choiceHtml, () => {}, {
hideReason: true,
hideConfirm: true,
unsafeContent: true,
cancelText: 'Cancel'
});
setTimeout(() => {
const modal = document.getElementById('mod-action-modal');
if (!modal) return;
const subBtn = modal.querySelector('#btn-choice-delete-sub');
const albumBtn = modal.querySelector('#btn-choice-delete-album');
if (subBtn) {
subBtn.onclick = () => {
modal.style.display = 'none';
deleteSubItemEvent(curSub, postid);
};
}
if (albumBtn) {
albumBtn.onclick = () => {
modal.style.display = 'none';
deleteEntirePost(postid, poster, authorId);
};
}
}, 50);
return;
}
deleteEntirePost(postid, poster, authorId);
};
let tmptt = null;
const editTagEvent = async e => {
e.preventDefault();
@@ -504,6 +596,17 @@
addtagClick(e);
} else if (target.closest("#a_delete")) {
deleteButtonEvent(e);
} else if (target.closest(".album-sub-delete-btn, #a_delete_sub")) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const ctx = getContext();
const curSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
? window.albumGallery.getCurrentSubf0ck()
: null;
if (ctx && curSub) {
deleteSubItemEvent(curSub, ctx.postid);
}
} else if (target.matches('#tags .badge > a[href*="/tag/"]')) {
editTagEvent(e);
} else if (target.closest('.admin-deltag') || target.closest('.removetag')) {
+568 -969
View File
File diff suppressed because it is too large Load Diff
+460 -38
View File
@@ -96,6 +96,8 @@ window.cancelAnimFrame = (function () {
// 3. Fallback to URL pathname if on an item page
const path = window.location.pathname;
if (path.includes('/admin/') || path.includes('/mod/') || path.includes('/settings') || path.includes('/user/')) return null;
// Exclude pagination paths like /p/2, /tag/foo/p/3 — the trailing digit is a page number, not an item ID
if (/\/p\/\d+\/?$/.test(path)) return null;
const match = path.match(/\/(\d+)\/?$/);
if (match) return match[1];
@@ -207,7 +209,7 @@ window.cancelAnimFrame = (function () {
console.log(`[RANDOM-POOL] nsfp_version changed (${window._randomPoolNsfpVersion}${newNsfpVersion}), invalidating pool`);
}
_shuffleArray(data.items);
window._randomPool = { items: data.items, total: data.total, sampled: data.sampled };
window._randomPool = { items: data.items, total: data.total, sampled: data.sampled, idMap: data.id_map || {} };
window._randomPoolContext = contextKey;
window._randomPoolCursor = 0;
window._randomPoolNsfpVersion = newNsfpVersion;
@@ -1894,7 +1896,24 @@ window.cancelAnimFrame = (function () {
}
};
const updateOnaraActiveItem = (itemid, url, forceScroll = false, slug = null) => {
const getExtFromMime = (mime) => {
if (!mime || typeof mime !== 'string') return '';
const sub = mime.split('/')[1] || mime;
return sub
.replace('youtube', 'yt')
.replace('x-shockwave-flash', 'flash')
.replace('vnd.adobe.flash.movie', 'flash')
.replace('x-zip-compressed', 'zip')
.replace('x-rar-compressed', 'rar')
.replace('vnd.rar', 'rar')
.replace('x-7z-compressed', '7z')
.replace('x-tar', 'tar')
.replace('x-bzip2', 'bz2')
.replace('x-xz', 'xz')
.toUpperCase();
};
const updateOnaraActiveItem = (itemid, url, forceScroll = false, slug = null, extraMeta = null) => {
if (!isOnaraActive()) return null;
// Clear onara-active from any and all elements to guarantee only 1 item is selected
document.querySelectorAll('.onara-active').forEach(el => el.classList.remove('onara-active'));
@@ -1912,6 +1931,12 @@ window.cancelAnimFrame = (function () {
if (!targetThumb && itemid) {
targetThumb = document.querySelector(`.posts > a.thumb[data-item-id="${itemid}"], .posts > a.thumb[href$="/${itemid}"], .posts > a.thumb[href*="/${itemid}#"], .posts > a.thumb[data-bg*="/${itemid}."]`);
}
if (!targetThumb) {
const altNumericId = window._randomPool?.idMap?.[slug] || window._randomPool?.idMap?.[itemid];
if (altNumericId) {
targetThumb = document.querySelector(`.posts > a.thumb[data-item-id="${altNumericId}"], .posts > a.thumb[href$="/${altNumericId}"], .posts > a.thumb[data-bg*="/${altNumericId}."]`);
}
}
// Blur any other active element so browser native focus cannot highlight a second thumbnail
if (document.activeElement && document.activeElement !== targetThumb && typeof document.activeElement.blur === 'function') {
@@ -1920,25 +1945,180 @@ window.cancelAnimFrame = (function () {
if (targetThumb) {
targetThumb.classList.add('onara-active');
if (extraMeta) {
if (extraMeta.mime && !targetThumb.dataset.mime) {
targetThumb.dataset.mime = extraMeta.mime;
targetThumb.dataset.ext = getExtFromMime(extraMeta.mime);
}
if (extraMeta.user && !targetThumb.dataset.user) {
targetThumb.dataset.user = extraMeta.user;
}
if (extraMeta.dest && !targetThumb.dataset.file) {
targetThumb.dataset.file = String(extraMeta.dest).replace(/^\/b\//, '');
}
}
scrollOnaraThumbIntoView(targetThumb, forceScroll);
}
return targetThumb;
};
window.updateOnaraActiveItem = updateOnaraActiveItem;
const resolveItemThumbInfo = (itemid, slug, extraMeta = null) => {
let numericId = extraMeta?.numericId || null;
let thumb = extraMeta?.thumb || null;
let mode = extraMeta?.mode || null;
let mime = extraMeta?.mime || null;
let user = extraMeta?.user || null;
let dest = extraMeta?.dest || null;
if (!numericId) {
if (typeof itemid === 'number' || (typeof itemid === 'string' && /^\d+$/.test(itemid))) {
numericId = itemid;
} else if (typeof slug === 'number' || (typeof slug === 'string' && /^\d+$/.test(slug))) {
numericId = slug;
} else if (window._randomPool?.idMap) {
if (slug && window._randomPool.idMap[slug]) {
numericId = window._randomPool.idMap[slug];
} else if (itemid && window._randomPool.idMap[itemid]) {
numericId = window._randomPool.idMap[itemid];
}
}
if (!numericId && typeof window.getCurrentItemId === 'function') {
const cur = window.getCurrentItemId();
if (cur && /^\d+$/.test(cur)) numericId = cur;
}
}
// Recover mime, user, dest from mounted Onara DOM if not passed in extraMeta
if (!mime) {
const mimeEl = document.querySelector('#onara-item-mount #info-file-mime, #onara-item-mount [data-mime], #info-file-mime');
if (mimeEl) {
mime = mimeEl.getAttribute('data-mime') || mimeEl.textContent?.trim() || null;
}
if (!mime) {
const mediaEl = document.querySelector('#onara-item-mount video, #onara-item-mount audio, #onara-item-mount img');
if (mediaEl) {
if (mediaEl.tagName === 'VIDEO') mime = 'video/mp4';
else if (mediaEl.tagName === 'AUDIO') mime = 'audio/mp3';
else if (mediaEl.tagName === 'IMG') {
const src = mediaEl.src || '';
if (src.endsWith('.png')) mime = 'image/png';
else if (src.endsWith('.gif')) mime = 'image/gif';
else if (src.endsWith('.webp')) mime = 'image/webp';
else mime = 'image/jpeg';
}
}
}
}
if (!user) {
const userEl = document.querySelector('#onara-item-mount #a_username, #a_username');
if (userEl) {
user = userEl.getAttribute('data-username') || userEl.textContent?.trim() || null;
}
}
if (!dest) {
const directLinkEl = document.querySelector('#onara-item-mount #info-file-direct-link, #info-file-direct-link');
if (directLinkEl) {
dest = directLinkEl.getAttribute('href') || null;
}
}
if (!thumb && numericId) {
thumb = `/t/${numericId}.webp`;
} else if (!thumb && typeof itemid === 'string' && /^\d+$/.test(itemid)) {
thumb = `/t/${itemid}.webp`;
}
if (!mode) {
if (typeof window.activeMode === 'string' && ['sfw', 'nsfw', 'nsfl'].includes(window.activeMode)) {
mode = window.activeMode;
} else {
mode = 'sfw';
}
}
return { numericId, thumb, mode, mime, user, dest };
};
const createSynthThumb = (itemid, url, slug, extraMeta = null) => {
const { numericId, thumb, mode, mime, user, dest } = resolveItemThumbInfo(itemid, slug, extraMeta);
const synthThumb = document.createElement('a');
synthThumb.href = url || `/${slug || itemid}`;
synthThumb.className = 'thumb lazy-thumb onara-active onara-synth-thumb loaded';
if (numericId) {
synthThumb.dataset.itemId = numericId;
}
if (mime) {
synthThumb.dataset.mime = mime;
synthThumb.dataset.ext = getExtFromMime(mime);
}
if (user) {
synthThumb.dataset.user = user;
}
if (dest) {
synthThumb.dataset.file = String(dest).replace(/^\/b\//, '');
}
const finalThumbUrl = thumb || (numericId ? `/t/${numericId}.webp` : `/t/${itemid}.webp`);
synthThumb.dataset.bg = finalThumbUrl;
synthThumb.setAttribute('data-mode', mode || 'sfw');
synthThumb.dataset.size = '1';
synthThumb.style.setProperty('--thumb-bg', `url('${finalThumbUrl}')`);
synthThumb.innerHTML = '<div class="thumb-indicators"></div><div class="thumb-select-check"><i class="fa-solid fa-check"></i></div><p></p>';
return synthThumb;
};
let _onaraSyncSeq = 0;
const syncOnaraBackgroundGrid = async (itemid, url, knownPage = null, slug = null) => {
const syncOnaraBackgroundGrid = async (itemid, url, knownPage = null, slug = null, extraMeta = null) => {
if (!isOnaraActive()) return;
const isRandom = document.cookie.includes('random_mode=1') || (url && url.includes('random=1')) || window.location.search.includes('random=1');
// Fast path: if target item is already in current background DOM (and not a temporary placeholder), just highlight and ensure visibility
const existingThumb = updateOnaraActiveItem(itemid, url, false, slug);
const existingThumb = updateOnaraActiveItem(itemid, url, false, slug, extraMeta);
if (existingThumb && !existingThumb.classList.contains('onara-synth-thumb')) {
return;
}
if (existingThumb && existingThumb.classList.contains('onara-synth-thumb')) {
if (isRandom) {
if (extraMeta) {
if (extraMeta.mime && !existingThumb.dataset.mime) {
existingThumb.dataset.mime = extraMeta.mime;
existingThumb.dataset.ext = getExtFromMime(extraMeta.mime);
}
if (extraMeta.user && !existingThumb.dataset.user) {
existingThumb.dataset.user = extraMeta.user;
}
if (extraMeta.dest && !existingThumb.dataset.file) {
existingThumb.dataset.file = String(extraMeta.dest).replace(/^\/b\//, '');
}
}
scrollOnaraThumbIntoView(existingThumb, false);
return;
}
existingThumb.remove();
}
const currentPosts = document.querySelector('.posts');
// In random (ZOMG) mode, if posts already exist in background, don't fetch page 1 (which wipes the grid with random items).
// Instead, seamlessly insert the synthetic thumb at a random position in the existing grid.
if (isRandom && currentPosts && currentPosts.children.length > 0) {
currentPosts.querySelectorAll('.onara-synth-thumb').forEach(el => el.remove());
const synthThumb = createSynthThumb(itemid, url, slug, extraMeta);
const children = Array.from(currentPosts.children);
const randIdx = Math.floor(Math.random() * (children.length + 1));
if (randIdx < children.length) {
currentPosts.insertBefore(synthThumb, children[randIdx]);
} else {
currentPosts.appendChild(synthThumb);
}
if (typeof window.initLazyLoading === 'function') window.initLazyLoading();
scrollOnaraThumbIntoView(synthThumb, true);
return;
}
const currentSeq = ++_onaraSyncSeq;
const urlObj = new URL(url || window.location.href, window.location.origin);
const feedBasePath = urlObj.pathname.replace(/\/+$/, '').replace(/\/(?:\d+|[a-zA-Z0-9_-]{11})$/, '') || '/';
@@ -2067,16 +2247,21 @@ window.cancelAnimFrame = (function () {
}
}
const thumb = updateOnaraActiveItem(itemid, url, true, slug);
const currentPosts = document.querySelector('.posts');
if (!thumb && currentPosts) {
const synthThumb = document.createElement('a');
synthThumb.href = url;
synthThumb.className = 'thumb lazy-thumb onara-active onara-synth-thumb';
synthThumb.dataset.bg = `/t/${itemid}.webp`;
synthThumb.dataset.size = '1';
synthThumb.innerHTML = '<div class="thumb-indicators"></div><p></p>';
currentPosts.prepend(synthThumb);
const thumb = updateOnaraActiveItem(itemid, url, true, slug, extraMeta);
const currentPostsFallback = document.querySelector('.posts');
if (!thumb && currentPostsFallback) {
const synthThumb = createSynthThumb(itemid, url, slug, extraMeta);
if (isRandom && currentPostsFallback.children.length > 0) {
const children = Array.from(currentPostsFallback.children);
const randIdx = Math.floor(Math.random() * (children.length + 1));
if (randIdx < children.length) {
currentPostsFallback.insertBefore(synthThumb, children[randIdx]);
} else {
currentPostsFallback.appendChild(synthThumb);
}
} else {
currentPostsFallback.prepend(synthThumb);
}
if (typeof window.initLazyLoading === 'function') window.initLazyLoading();
scrollOnaraThumbIntoView(synthThumb, true);
}
@@ -3130,7 +3315,11 @@ window.cancelAnimFrame = (function () {
};
const initAlbumGallery = () => {
const container = document.querySelector('.album-gallery-container');
const onaraMount = document.getElementById('onara-item-mount');
const isOnara = (typeof isOnaraActive === 'function' && isOnaraActive()) || document.body.classList.contains('onara-modal-open');
const container = (isOnara && onaraMount)
? (onaraMount.querySelector('.album-gallery-container') || document.querySelector('.album-gallery-container'))
: document.querySelector('.album-gallery-container');
if (!container) {
window._currentActiveAlbumGallery = null;
return;
@@ -3413,8 +3602,24 @@ window.cancelAnimFrame = (function () {
if (isAutoplayAllowed()) {
const playPromise = videoEl.play();
if (playPromise !== undefined) {
playPromise.catch(() => {
playerWrap.classList.add('v0ck_initial');
playPromise.then(() => {
playerWrap.classList.remove('v0ck_initial');
}).catch((err) => {
if (err.name === 'AbortError') {
const onCanPlay = () => {
videoEl.removeEventListener('canplay', onCanPlay);
if (videoEl.paused) {
videoEl.play().then(() => {
playerWrap.classList.remove('v0ck_initial');
}).catch(() => {
playerWrap.classList.add('v0ck_initial');
});
}
};
videoEl.addEventListener('canplay', onCanPlay, { once: true });
} else {
playerWrap.classList.add('v0ck_initial');
}
});
}
} else {
@@ -3509,8 +3714,24 @@ window.cancelAnimFrame = (function () {
if (isAutoplayAllowed()) {
const playPromise = audioEl.play();
if (playPromise !== undefined) {
playPromise.catch(() => {
playerWrap.classList.add('v0ck_initial');
playPromise.then(() => {
playerWrap.classList.remove('v0ck_initial');
}).catch((err) => {
if (err.name === 'AbortError') {
const onCanPlay = () => {
audioEl.removeEventListener('canplay', onCanPlay);
if (audioEl.paused) {
audioEl.play().then(() => {
playerWrap.classList.remove('v0ck_initial');
}).catch(() => {
playerWrap.classList.add('v0ck_initial');
});
}
};
audioEl.addEventListener('canplay', onCanPlay, { once: true });
} else {
playerWrap.classList.add('v0ck_initial');
}
});
}
} else {
@@ -4006,6 +4227,39 @@ window.cancelAnimFrame = (function () {
updateInfoModal: updateInfoModal,
updateAlbumTags: updateAlbumTags,
getCurrentSubf0ck: () => albumData[currentIndex],
removeSubf0ck: (subIdentifier) => {
let idx = -1;
if (typeof subIdentifier === 'number' && subIdentifier >= 0 && subIdentifier < albumData.length) {
idx = subIdentifier;
} else if (subIdentifier !== undefined && subIdentifier !== null) {
idx = albumData.findIndex(s => s && (s.id == subIdentifier || s.slug == subIdentifier || s.subf0ck_id == subIdentifier));
} else {
idx = currentIndex;
}
if (idx === -1) idx = currentIndex;
if (idx < 0 || idx >= albumData.length) return false;
albumData.splice(idx, 1);
if (albumData.length <= 1) {
window.location.reload();
return true;
}
const thumbs = container.querySelectorAll('.album-thumb-item');
if (thumbs[idx]) {
thumbs[idx].remove();
}
const updatedThumbs = container.querySelectorAll('.album-thumb-item');
updatedThumbs.forEach((th, newIdx) => {
th.setAttribute('data-index', newIdx);
});
if (totalCountEl) totalCountEl.textContent = albumData.length;
if (currentIndex >= albumData.length) currentIndex = albumData.length - 1;
showImage(currentIndex, 'none', true);
return true;
},
isHovered: false
};
window.albumGallery = window._currentActiveAlbumGallery;
@@ -4037,7 +4291,15 @@ window.cancelAnimFrame = (function () {
const setupMedia = () => {
window._currentActiveAlbumGallery = null;
window.albumGallery = null;
const elem = document.querySelector("#my-video") || document.querySelector("audio#my-video");
const onaraMount = document.getElementById('onara-item-mount');
const isOnara = (typeof isOnaraActive === 'function' && isOnaraActive()) || document.body.classList.contains('onara-modal-open');
let elem = null;
if (isOnara && onaraMount) {
elem = onaraMount.querySelector("#my-video, audio#my-video");
}
if (!elem) {
elem = document.querySelector("#main #my-video, #main audio#my-video") || document.querySelector("#my-video, audio#my-video");
}
if (elem) {
video = new v0ck(elem);
} else {
@@ -12049,8 +12311,16 @@ window.cancelAnimFrame = (function () {
onaraMount.innerHTML = '';
}
_container = onaraMount;
updateOnaraActiveItem(itemid, url, false, _cachedItem.slug);
syncOnaraBackgroundGrid(itemid, url, _cachedItem.page, _cachedItem.slug);
const cachedExtraMeta = {
numericId: _cachedItem.numericId,
thumb: _cachedItem.thumb,
mode: _cachedItem.mode,
mime: _cachedItem.mime,
user: _cachedItem.user,
dest: _cachedItem.dest
};
updateOnaraActiveItem(itemid, url, false, _cachedItem.slug, cachedExtraMeta);
syncOnaraBackgroundGrid(itemid, url, _cachedItem.page, _cachedItem.slug, cachedExtraMeta);
} else {
_container = document.querySelector('#main .container') || (document.getElementById('main')?.classList.contains('item-view') ? document.getElementById('main') : null);
const _isStructuralPage = !!document.querySelector('.pagewrapper');
@@ -12214,6 +12484,8 @@ window.cancelAnimFrame = (function () {
const tStart = performance.now();
let html, paginationHtml, responseSlug = null, responsePage = null;
let responseNumericId = null, responseThumb = null, responseMode = null;
let responseMime = null, responseUser = null, responseDest = null;
// ── Pre-fetched data path (merged random + item load) ──────────────
if (options.prefetchedData) {
@@ -12228,6 +12500,12 @@ window.cancelAnimFrame = (function () {
paginationHtml = data.pagination;
responseSlug = data.slug || data.item?.slug || null;
responsePage = data.page || null;
responseNumericId = data.numeric_id || data.id || data.item?.id || null;
responseThumb = data.thumb || data.item?.thumb || (responseNumericId ? `/t/${responseNumericId}.webp` : null);
responseMode = data.mode ?? data.tag_id ?? data.item?.tag_id ?? null;
responseMime = data.mime || data.item?.matching_sub_mime || data.item?.mime || null;
responseUser = data.user || data.username || data.item?.author_display_name || data.item?.display_name || data.item?.username || null;
responseDest = data.dest || data.file || data.item?.matching_sub_dest || data.item?.dest || null;
}
window.f0ckDebug(`[CLIENT_DEBUG] Using pre-fetched data (skipped network fetch)`);
} else {
@@ -12259,6 +12537,12 @@ window.cancelAnimFrame = (function () {
paginationHtml = data.pagination;
responseSlug = data.slug || data.item?.slug || null;
responsePage = data.page || null;
responseNumericId = data.numeric_id || data.id || data.item?.id || null;
responseThumb = data.thumb || data.item?.thumb || (responseNumericId ? `/t/${responseNumericId}.webp` : null);
responseMode = data.mode ?? data.tag_id ?? data.item?.tag_id ?? null;
responseMime = data.mime || data.item?.matching_sub_mime || data.item?.mime || null;
responseUser = data.user || data.username || data.item?.author_display_name || data.item?.display_name || data.item?.username || null;
responseDest = data.dest || data.file || data.item?.matching_sub_dest || data.item?.dest || null;
} else {
html = rawText;
}
@@ -12270,7 +12554,18 @@ window.cancelAnimFrame = (function () {
// ── Store in item cache (stale-while-revalidate) ───────────────────────
if (html && !options.noCacheStore) {
itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, page: responsePage, ts: Date.now() });
itemCacheMap.set(_itemCacheKey, {
html,
slug: responseSlug,
page: responsePage,
numericId: responseNumericId,
thumb: responseThumb,
mode: responseMode,
mime: responseMime,
user: responseUser,
dest: responseDest,
ts: Date.now()
});
if (itemCacheMap.size > ITEM_CACHE_MAX) {
// Evict oldest entry
itemCacheMap.delete(itemCacheMap.keys().next().value);
@@ -12290,8 +12585,16 @@ window.cancelAnimFrame = (function () {
onaraMount.innerHTML = '';
}
container = onaraMount;
updateOnaraActiveItem(itemid, url, false, responseSlug);
syncOnaraBackgroundGrid(itemid, url, responsePage, responseSlug);
const currentExtraMeta = {
numericId: responseNumericId,
thumb: responseThumb,
mode: responseMode,
mime: responseMime,
user: responseUser,
dest: responseDest
};
updateOnaraActiveItem(itemid, url, false, responseSlug, currentExtraMeta);
syncOnaraBackgroundGrid(itemid, url, responsePage, responseSlug, currentExtraMeta);
} else {
container = document.querySelector('#main .container') || (document.getElementById('main') && document.getElementById('main').classList.contains('item-view') ? document.getElementById('main') : null);
const isStructuralPage = !!document.querySelector('.pagewrapper');
@@ -12731,7 +13034,11 @@ window.cancelAnimFrame = (function () {
// Background grid sync: pass targetUrl and null so actual page is looked up and synced
if (isOnaraActive()) {
syncOnaraBackgroundGrid(pick, targetUrl, null, pick);
const numericId = window._randomPool?.idMap?.[pick] || (/^\d+$/.test(pick) ? pick : null);
syncOnaraBackgroundGrid(pick, targetUrl, null, pick, {
numericId,
thumb: numericId ? `/t/${numericId}.webp` : null
});
}
return;
}
@@ -12802,7 +13109,14 @@ window.cancelAnimFrame = (function () {
}
loadItemAjax(targetUrl, true, { transition: 'fade-zoom', prefetchedData: data });
if (isOnaraActive() && data.id) {
syncOnaraBackgroundGrid(data.id, targetUrl, data.page || null, targetKey);
syncOnaraBackgroundGrid(data.id, targetUrl, data.page || null, targetKey, {
numericId: data.numeric_id || data.id,
thumb: data.thumb || `/t/${data.numeric_id || data.id}.webp`,
mode: data.mode ?? data.tag_id,
mime: data.mime,
user: data.user || data.username,
dest: data.dest
});
}
} else {
// No items found — restore UI, don't redirect
@@ -16580,7 +16894,8 @@ class NotificationSystem {
this.retryCount = 0;
this.maxRetries = 20; // Increased retries
this.pendingNotifIds = new Set(); // item IDs notified before thumbnail was in the grid
this.activeTab = 'user'; // 'user' or 'system'
const activeTabEl = this.dropdown ? this.dropdown.querySelector('.notif-tab.active') : null;
this.activeTab = activeTabEl ? activeTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user');
this._cachedUser = [];
this._cachedSystem = [];
@@ -16906,15 +17221,18 @@ class NotificationSystem {
if (!delId) return;
window.f0ckDebug(`[SSE] Item deleted: ${delId}`);
// Remove from main grid — a.thumb is the anchor, li is its parent card
const thumb = document.querySelector(`a.thumb[href$="/${delId}"], a.lazy-thumb[href$="/${delId}"]`);
if (thumb) {
// Remove from main grid — prefer data-item-id (works with slugs too), fall back to href match
const gridCards = document.querySelectorAll(
`a.thumb[data-item-id="${delId}"], a.lazy-thumb[data-item-id="${delId}"], ` +
`a.thumb[href$="/${delId}"], a.lazy-thumb[href$="/${delId}"]`
);
gridCards.forEach(thumb => {
const card = thumb.closest('li') || thumb;
card.style.transition = 'opacity 0.3s ease, transform 0.3s ease';
card.style.opacity = '0';
card.style.transform = 'scale(0.95)';
setTimeout(() => card.remove(), 300);
}
});
// If currently viewing this item, navigate to next item using soft AJAX nav
const currentItemId = (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null) || window.currentItemId;
@@ -17014,6 +17332,33 @@ class NotificationSystem {
document.dispatchEvent(new CustomEvent('f0ck:global_chat_topic', { detail: data.data }));
} else if (data.type === 'global_chat_presence') {
document.dispatchEvent(new CustomEvent('f0ck:global_chat_presence', { detail: data.data }));
} else if (data.type === 'brand_image') {
window.f0ckDebug(`[SSE] Brand image update received:`, data.data?.url);
if (data.data?.url) {
const src = data.data.url;
// Update the randomizeLogo pool so clicking the brand always uses the current image
window.f0ckBrandImages = [src];
const logos = document.querySelectorAll('#navbar-logo');
if (logos.length > 0) {
logos.forEach(el => { el.src = src; el.style.display = ''; });
} else {
// Logo element doesn't exist yet (no image was set before) — create it
document.querySelectorAll('a.navbar-brand').forEach(brandLink => {
const textNode = Array.from(brandLink.childNodes).find(n => n.nodeType === Node.TEXT_NODE);
const img = document.createElement('img');
img.id = 'navbar-logo';
img.src = src;
img.alt = document.title;
img.style.cssText = 'max-height:40px;vertical-align:middle;max-width:180px;width:auto;';
if (textNode) brandLink.insertBefore(img, textNode);
else brandLink.prepend(img);
});
}
} else {
window.f0ckBrandImages = [];
document.querySelectorAll('#navbar-logo').forEach(el => el.remove());
}
}
} catch (err) {
console.error('SSE data parse error', err);
@@ -19113,6 +19458,9 @@ class ModAction {
confirmBtn.innerText = options.confirmText || (hideReason ? (i18n.confirm_yes || 'Yes') : (i18n.confirm_btn || 'Confirm'));
cancelBtn.innerText = options.cancelText || (hideReason ? (i18n.confirm_no || 'No') : (i18n.cancel_btn || 'Cancel'));
confirmBtn.style.display = options.hideConfirm ? 'none' : '';
cancelBtn.style.display = options.hideCancel ? 'none' : '';
const close = () => {
modal.style.display = 'none';
cleanup();
@@ -19149,6 +19497,8 @@ class ModAction {
const cleanup = () => {
confirmBtn.onclick = null;
cancelBtn.onclick = null;
confirmBtn.style.display = '';
cancelBtn.style.display = '';
if (enterHandler) reasonEl.removeEventListener('keydown', enterHandler);
confirmBtn.disabled = false;
};
@@ -19813,6 +20163,11 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
// Helper to reset all category checkboxes
const resetReportCategories = () => {
document.querySelectorAll('.report-cat-check').forEach(cb => { cb.checked = false; });
};
// Open item report
document.addEventListener('click', (e) => {
const itemBtn = e.target.closest('.report-item-btn');
@@ -19822,6 +20177,7 @@ document.addEventListener('DOMContentLoaded', () => {
reportCommentInput.value = '';
reportUserInput.value = '';
reportReason.value = '';
resetReportCategories();
clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
@@ -19836,6 +20192,7 @@ document.addEventListener('DOMContentLoaded', () => {
reportCommentInput.value = commentBtn.dataset.id;
reportUserInput.value = '';
reportReason.value = '';
resetReportCategories();
clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
@@ -19850,6 +20207,7 @@ document.addEventListener('DOMContentLoaded', () => {
reportCommentInput.value = '';
reportUserInput.value = userBtn.dataset.userId;
reportReason.value = '';
resetReportCategories();
clearReportError();
reportModal.style.display = 'flex';
document.body.classList.add('modal-open');
@@ -19861,6 +20219,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.target.matches('#report-cancel') || e.target.id === 'report-modal') {
reportModal.style.display = 'none';
document.body.classList.remove('modal-open');
resetReportCategories();
clearReportError();
if (_reportRcWidgetId !== null && window.grecaptcha) {
try { grecaptcha.reset(_reportRcWidgetId); } catch(e) {}
@@ -19871,8 +20230,10 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.target.matches('#report-submit')) {
clearReportError();
const reason = reportReason.value.trim();
if (!reason) {
showReportError((window.f0ckI18n && window.f0ckI18n.reason_required) || 'Please provide a reason.', reportReason);
const checkedCategories = Array.from(document.querySelectorAll('.report-cat-check:checked')).map(cb => cb.value);
if (!reason && checkedCategories.length === 0) {
showReportError((window.f0ckI18n && window.f0ckI18n.reason_required) || 'Please select at least one reason or provide a description.', reportReason);
return;
}
@@ -19894,7 +20255,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (reportItemInput.value) payload.append('item_id', reportItemInput.value);
if (reportCommentInput.value) payload.append('comment_id', reportCommentInput.value);
if (reportUserInput.value) payload.append('reported_user_id', reportUserInput.value);
payload.append('reason', reason);
if (reason) payload.append('reason', reason);
if (checkedCategories.length) payload.append('categories', checkedCategories.join(','));
if (rcToken) payload.append('g-recaptcha-response', rcToken);
const submitBtn = e.target;
@@ -19916,6 +20278,7 @@ document.addEventListener('DOMContentLoaded', () => {
if (window.showFlash) window.showFlash((window.f0ckI18n && window.f0ckI18n.report_success) || 'Report submitted successfully.', 'success');
// Reset fields for future reports
reportReason.value = '';
resetReportCategories();
} else {
const errMsg = data.msg || (window.f0ckI18n && window.f0ckI18n.report_error) || 'An error occurred.';
showReportError(errMsg);
@@ -22312,6 +22675,9 @@ window.BulkSelection = (() => {
const bar = document.createElement('div');
bar.id = 'bulk-action-bar';
bar.innerHTML = `
<button type="button" class="bulk-btn bulk-btn-close" id="bulk-btn-close" title="Cancel selection (Esc)">
<i class="fa-solid fa-xmark"></i>
</button>
<div class="bulk-count-box">
<i class="fa-solid fa-check-double" style="color: var(--accent);"></i>
<span class="bulk-count-num">0</span>
@@ -22344,13 +22710,14 @@ window.BulkSelection = (() => {
</button>
</div>
</div>
<button type="button" class="bulk-btn bulk-btn-danger" id="bulk-btn-delete" title="Delete selected items">
<i class="fa-solid fa-trash-can"></i>
<span class="btn-text-full">Delete</span>
</button>
<button type="button" class="bulk-btn" id="bulk-btn-select-all" title="Select all loaded items">
<i class="fa-regular fa-square-check"></i>
<span class="btn-text-full">Select All</span>
</button>
<button type="button" class="bulk-btn bulk-btn-close" id="bulk-btn-close" title="Cancel selection (Esc)">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
`;
document.body.appendChild(bar);
@@ -22406,6 +22773,7 @@ window.BulkSelection = (() => {
document.getElementById('bulk-btn-select-all')?.addEventListener('click', () => selectAllLoaded());
document.getElementById('bulk-btn-close')?.addEventListener('click', () => exitSelectionMode());
document.getElementById('bulk-btn-delete')?.addEventListener('click', () => executeBulkDelete());
// Wire up Modal events
document.getElementById('bulk-modal-close-btn')?.addEventListener('click', () => closeTagModal());
@@ -22779,6 +23147,59 @@ window.BulkSelection = (() => {
}
}
function executeBulkDelete() {
const itemIds = Array.from(selectedIds);
if (itemIds.length === 0) return;
if (typeof ModAction === 'undefined') {
return window.flashMessage?.('Error: ModAction module not loaded', 3000, 'error');
}
const i18n = window.f0ckI18n || {};
const title = i18n.item_delete_title || 'Delete Item';
const msg = `Are you sure you want to delete <strong>${itemIds.length} selected item(s)</strong>? This cannot be undone.`;
ModAction.confirm(title, msg, async (reason) => {
let deleted = 0;
let failed = 0;
for (const id of itemIds) {
try {
const res = await fetch('/api/v2/admin/deletepost', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({ postid: id, reason: reason || 'Bulk delete' })
});
const data = await res.json();
if (data.success) {
deleted++;
const thumb = document.querySelector(`a.thumb[data-item-id="${id}"], a.lazy-thumb[data-item-id="${id}"], a.thumb[href$="/${id}"], a.lazy-thumb[href$="/${id}"]`);
if (thumb) {
const card = thumb.closest('li') || thumb;
card.style.transition = 'opacity 0.25s ease, transform 0.25s ease';
card.style.opacity = '0';
card.style.transform = 'scale(0.92)';
setTimeout(() => card.remove(), 260);
}
} else {
failed++;
console.warn(`[BULK_DELETE] Failed to delete item ${id}:`, data.msg);
}
} catch (err) {
failed++;
console.error(`[BULK_DELETE] Error deleting item ${id}:`, err);
}
}
if (failed > 0) throw new Error(`Deleted ${deleted} item(s), ${failed} failed`);
flash(`Deleted ${deleted} item(s)`);
exitSelectionMode();
}, { allowEmpty: window.f0ckSession?.is_admin, unsafeContent: true });
}
// --- Event Listeners Initialization ---
function init() {
ensureDOMElements();
@@ -22979,6 +23400,7 @@ window.BulkSelection = (() => {
openTagModal,
closeTagModal,
executeBulkRating,
executeBulkDelete,
selectAllLoaded,
getSelectedIds: () => Array.from(selectedIds)
};
+294
View File
@@ -0,0 +1,294 @@
var CATEGORY_META = {
wrong_rating: { label: 'Wrong Rating', bg: '#ffc107', color: '#000' },
spam: { label: 'Spam', bg: '#6c757d', color: '#fff' },
duplicate: { label: 'Duplicate', bg: '#17a2b8', color: '#fff' },
copyright: { label: 'Copyright', bg: '#fd7e14', color: '#fff' },
illegal: { label: 'Illegal', bg: '#dc3545', color: '#fff' },
other: { label: 'Other', bg: '#495057', color: '#fff' }
};
function catBadge(c) {
var m = CATEGORY_META[c] || { label: c, bg: '#444', color: '#fff' };
return '<span class="rp-cat" style="background:' + m.bg + ';color:' + m.color + ';">' + m.label + '</span>';
}
function rpEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function relTime(d) {
var diff = (Date.now() - new Date(d)) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
window.currentPage = window.currentPage || 1;
window.loadReports = async function(page) {
page = page || 1;
window.currentPage = page;
var status = document.getElementById('report-status-filter').value;
var feed = document.getElementById('reports-feed');
var pag = document.getElementById('reports-pagination');
feed.innerHTML = '<div class="rp-state-msg"><i class="fa-solid fa-spinner fa-spin"></i> Loading...</div>';
pag.innerHTML = '';
try {
var res = await fetch('/api/v2/mod/reports?status=' + status + '&page=' + page);
var data = await res.json();
if (!data.success) {
feed.innerHTML = '<div class="rp-state-msg" style="color:#dc3545;">Error: ' + rpEsc(data.msg) + '</div>';
return;
}
window.currentReports = data.reports;
window.emojiMap = new Map();
if (data.emojis) data.emojis.forEach(function(e) { window.emojiMap.set(e.name.toLowerCase(), e.url); });
if (!data.reports.length) {
feed.innerHTML = '<div class="rp-state-msg">No reports found.</div>';
return;
}
feed.innerHTML = '';
var isAdmin = window.f0ckSession && window.f0ckSession.admin;
data.reports.forEach(function(r) {
var card = document.createElement('div');
var cats = Array.isArray(r.categories) ? r.categories : [];
card.className = 'rp-card' + (cats.indexOf('illegal') !== -1 ? ' illegal-flag' : '');
var statusBadge = '<span class="rp-status-badge rp-status-' + status + '">' + status + '</span>';
var reporter = r.reporter_name
? '<a href="/user/' + rpEsc(r.reporter_name) + '" class="rp-reporter-link">' + rpEsc(r.reporter_name) + '</a>' + (r.reporter_ip ? ' <span class="rp-reporter-ip">(' + rpEsc(r.reporter_ip) + ')</span>' : '')
: '<span style="color:#666;font-style:italic;">Guest' + (r.reporter_ip ? ' (' + rpEsc(r.reporter_ip) + ')' : '') + '</span>';
var targetHtml = '';
var itemLink = '';
if (r.comment_id) {
targetHtml = '<span style="color:#888;font-size:0.85em;">comment #' + r.comment_id + '</span>';
if (r.resolved_item_id) itemLink = '<a href="/' + r.resolved_item_id + '" target="_blank" class="rp-open-link"><i class="fa-solid fa-arrow-up-right-from-square"></i> item #' + r.resolved_item_id + '</a>';
} else if (r.resolved_item_id) {
targetHtml = '<span style="color:#888;font-size:0.85em;">item</span>';
itemLink = '<a href="/' + r.resolved_item_id + '" target="_blank" class="rp-open-link"><i class="fa-solid fa-arrow-up-right-from-square"></i> #' + r.resolved_item_id + '</a>';
} else if (r.reported_user_name) {
targetHtml = 'user <a href="/user/' + rpEsc(r.reported_user_name) + '" class="rp-target-link">' + rpEsc(r.reported_user_name) + '</a>';
}
var previewHtml = '';
var isItem = !!r.resolved_item_id && r.resolved_item_dest;
var isComment = !!r.comment_id;
if (isItem && !isComment) {
var mime = r.resolved_item_mime || '';
var src = '/b/' + r.resolved_item_dest;
var href = '/' + r.resolved_item_id;
if (mime === 'video/youtube') {
var ytId = r.resolved_item_dest.replace('yt:', '');
previewHtml = '<div class="rp-preview"><img src="https://img.youtube.com/vi/' + ytId + '/mqdefault.jpg" loading="lazy"><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-brands fa-youtube"></i></a></div>';
} else if (mime.indexOf('image/') === 0) {
previewHtml = '<div class="rp-preview"><img src="' + src + '" loading="lazy"><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-expand"></i></a></div>';
} else if (mime.indexOf('video/') === 0) {
previewHtml = '<div class="rp-preview"><video src="' + src + '" muted playsinline preload="metadata"></video><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-play"></i></a></div>';
} else if (mime.indexOf('audio/') === 0) {
previewHtml = '<div class="rp-preview"><div class="rp-no-preview"><i class="fa-solid fa-music"></i></div><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-expand"></i></a></div>';
} else {
previewHtml = '<div class="rp-preview"><div class="rp-no-preview"><i class="fa-solid fa-file"></i></div><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-expand"></i></a></div>';
}
} else if (isComment) {
var body = (r.comment_body || '[deleted]').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
if (window.emojiMap) {
body = body.replace(/:([a-z0-9_]+):/g, function(match, code) {
var url = window.emojiMap.get(code.toLowerCase());
return url ? '<img src="' + url + '" style="height:18px;vertical-align:middle;" title=":' + code + ':">' : match;
});
}
previewHtml = '<div class="rp-preview" style="background:rgba(0,0,0,0.4);width:180px;min-width:180px;align-items:flex-start;padding:12px;overflow-y:auto;font-size:0.78em;color:#ccc;font-family:monospace;line-height:1.5;white-space:pre-wrap;height:140px;">' + body + '</div>';
} else {
previewHtml = '<div class="rp-preview"><div class="rp-no-preview"><i class="fa-solid fa-user"></i></div></div>';
}
var catsHtml = cats.length ? '<div class="rp-cats">' + cats.map(catBadge).join('') + '</div>' : '';
var reasonHtml = r.reason ? '<div class="rp-reason">' + rpEsc(r.reason) + '</div>' : '';
var actions = '';
if (status === 'pending') {
actions += '<button class="rp-btn rp-btn-resolve" onclick="window.resolveReport(' + r.id + ',\'resolved\')"><i class="fa-solid fa-check"></i> Resolve</button>';
actions += '<button class="rp-btn rp-btn-reject" onclick="window.resolveReport(' + r.id + ',\'rejected\')"><i class="fa-solid fa-xmark"></i> Reject</button>';
actions += '<span class="rp-sep"></span>';
}
if (isItem && !isComment) {
var isUnav = r.resolved_item_visibility === 3;
actions += '<button class="rp-btn rp-btn-delete" onclick="window.adminDeleteItem(' + r.resolved_item_id + ')"><i class="fa-solid fa-trash"></i> Delete</button>';
actions += '<button class="rp-btn ' + (isUnav ? 'rp-btn-avail' : 'rp-btn-unavail') + '" onclick="window.modToggleUnavailable(' + r.resolved_item_id + ',' + (r.resolved_item_visibility||0) + ')">' + (isUnav ? '<i class="fa-solid fa-eye"></i> Restore' : '<i class="fa-solid fa-ban"></i> 451') + '</button>';
}
if (isComment) {
actions += '<button class="rp-btn rp-btn-delete" onclick="window.adminDeleteComment(' + r.comment_id + ')"><i class="fa-solid fa-trash"></i> Del Comment</button>';
}
if (r.reported_user_id && (isAdmin || !r.reported_user_is_admin)) {
var who = r.reported_user_name ? rpEsc(r.reported_user_name) : 'user';
actions += '<span class="rp-sep"></span>';
actions += '<button class="rp-btn rp-btn-warn" onclick="window.modWarnUser(' + r.reported_user_id + ')"><i class="fa-solid fa-triangle-exclamation"></i> Warn ' + who + '</button>';
actions += '<button class="rp-btn rp-btn-ban" onclick="window.adminBanUser(' + r.reported_user_id + ')"><i class="fa-solid fa-gavel"></i> Ban ' + who + '</button>';
} else if (!r.reported_user_id) {
actions += '<span class="rp-anon-note">Anonymous reporter</span>';
}
if (r.reporter_id && r.reporter_name) {
actions += '<button class="rp-btn rp-btn-secondary" onclick="window.modWarnUser(' + r.reporter_id + ')">Warn Reporter</button>';
}
var ts = new Date(r.created_at).toLocaleString();
var rt = relTime(r.created_at);
var resolverHtml = (status !== 'pending' && r.resolver_name)
? '<span style="font-size:0.78em;color:#888;margin-left:6px;"><i class="fa-solid fa-' + (status === 'resolved' ? 'check' : 'xmark') + '" style="margin-right:3px;color:' + (status === 'resolved' ? '#28a745' : '#6c757d') + ';"></i>by <a href="/user/' + rpEsc(r.resolver_name) + '" style="color:#888;font-weight:600;text-decoration:none;">' + rpEsc(r.resolver_name) + '</a></span>'
: '';
card.innerHTML =
'<div class="rp-card-bar">' +
'<div class="rp-card-bar-left">' +
'<span class="rp-card-id">#' + r.id + '</span>' +
statusBadge +
resolverHtml +
'<span style="font-size:0.83em;color:#aaa;margin-left:6px;">from ' + reporter + '</span>' +
(targetHtml ? '<span style="font-size:0.83em;color:#777;">&rarr; ' + targetHtml + '</span>' : '') +
itemLink +
'</div>' +
'<span class="rp-card-time" title="' + rpEsc(ts) + '">' + rt + '</span>' +
'</div>' +
'<div class="rp-card-body">' +
previewHtml +
'<div class="rp-info">' + catsHtml + reasonHtml + '</div>' +
'</div>' +
'<div class="rp-card-actions">' + actions + '</div>';
feed.appendChild(card);
});
pag.innerHTML = '';
if (data.pages > 1) {
if (data.page > 1)
pag.innerHTML += '<button onclick="window.loadReports(' + (data.page-1) + ')"><i class="fa-solid fa-chevron-left"></i> Prev</button>';
pag.innerHTML += '<span class="rp-page-info">Page ' + data.page + ' of ' + data.pages + '</span>';
if (data.page < data.pages)
pag.innerHTML += '<button onclick="window.loadReports(' + (data.page+1) + ')">Next <i class="fa-solid fa-chevron-right"></i></button>';
}
} catch(e) {
feed.innerHTML = '<div class="rp-state-msg" style="color:#dc3545;"><i class="fa-solid fa-circle-exclamation"></i> Network error</div>';
}
};
window.resolveReport = function(id, action) {
var label = action === 'resolved' ? 'Resolve' : 'Reject';
var desc = action === 'resolved'
? 'Mark report #' + id + ' as resolved.'
: 'Reject report #' + id + '. The content will remain as-is.';
window.ModAction.confirm(label + ' Report #' + id, desc, async function() {
var params = new URLSearchParams();
params.append('action', action);
var csrfToken = window.f0ckSession && window.f0ckSession.csrf_token;
var res = await fetch('/api/v2/mod/reports/' + id + '/resolve', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
},
body: params
});
var data = await res.json();
if (data.success) {
window.loadReports(window.currentPage);
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function')
window.NotificationSystemInstance.pollDebounced();
} else {
throw new Error(data.msg || 'Failed to update report');
}
}, { hideReason: true });
};
window.adminDeleteComment = function(id) {
window.ModAction.confirm('Delete Comment #' + id, 'Are you sure you want to delete this comment? This action is permanent.', async (reason) => {
var params = new URLSearchParams();
params.append('reason', reason);
var res = await fetch('/api/comments/' + id + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('comment deleted', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
window.adminDeleteItem = function(id) {
window.ModAction.confirm('Delete Item #' + id, 'Are you sure you want to delete this item? This action is permanent.', async (reason) => {
var params = new URLSearchParams();
params.append('postid', id); params.append('reason', reason);
var res = await fetch('/api/v2/admin/deletepost', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('item deleted', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
window.modToggleUnavailable = function(id, currentVis) {
var willBeUnavailable = currentVis !== 3;
var targetVis = willBeUnavailable ? 3 : 0;
var actionText = willBeUnavailable ? 'Make Unavailable (HTTP 451)' : 'Make Available (Public)';
window.ModAction.confirm('Item Visibility', actionText + ' for item #' + id + '?', async () => {
var params = new URLSearchParams();
params.append('postid', id); params.append('id', id); params.append('visibility', targetVis);
var csrfToken = window.f0ckSession && window.f0ckSession.csrf_token;
var res = await fetch('/api/v2/item/visibility', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken }, body: params });
var data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash(willBeUnavailable ? 'Item marked unavailable (451)' : 'Item restored to public', 'success');
var item = window.currentReports.find(function(x) { return x.resolved_item_id === id; });
if (item) item.resolved_item_visibility = targetVis;
window.loadReports(window.currentPage);
} else throw new Error(data.msg || 'Failed to update visibility');
});
};
window.modWarnUser = function(userId) {
window.ModAction.confirm('Warn User ID ' + userId, '', async (reason) => {
var params = new URLSearchParams();
params.append('user_id', userId); params.append('reason', reason);
var res = await fetch('/api/v2/mod/warnings/issue', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('user has been warned', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
window.adminBanUser = function(userId) {
var isAdmin = window.f0ckSession && window.f0ckSession.admin;
var promptHtml =
'<p>This will restrict the user from accessing their account and performing most actions.</p>' +
'<div style="margin-top:10px;"><label>Ban Duration:</label>' +
'<select id="ban-duration-select" class="form-control" style="margin-top:5px;">' +
(isAdmin ? '<option value="permanent">Permanent</option>' : '') +
'<option value="1">1 Hour</option><option value="6">6 Hours</option><option value="24">24 Hours (1 Day)</option>' +
(!isAdmin ? '<option value="48">48 Hours (2 Days)</option>' : '') +
(isAdmin ? '<option value="168">168 Hours (1 Week)</option>' : '') +
(isAdmin ? '<option value="720">720 Hours (1 Month)</option>' : '') +
'</select></div>';
window.ModAction.confirm('Ban User ID ' + userId, promptHtml, async (reason) => {
var duration = document.getElementById('ban-duration-select').value;
var params = new URLSearchParams();
params.append('user_id', userId); params.append('reason', reason); params.append('duration', duration);
var res = await fetch('/api/v2/admin/ban', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('User banned cleanly.', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
(function() {
var filter = document.getElementById('report-status-filter');
if (filter) filter.onchange = function() { window.loadReports(1); };
window.loadReports(1);
})();
+2 -2
View File
@@ -3654,8 +3654,8 @@
// Tab type arrays
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
let sActiveTab = 'user';
const sActiveTabEl = sNotifDropdown ? sNotifDropdown.querySelector('.notif-tab.active') : null;
let sActiveTab = sActiveTabEl ? sActiveTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user');
let sCachedNotifs = [];
if (sNotifBtn && sNotifDropdown) {
+1 -1
View File
@@ -1170,7 +1170,7 @@
const renderVideoCard = (video, options = {}) => {
const videoKey = (window.f0ckSession?.enable_item_slugs !== false && video.slug) ? video.slug : video.id;
const rClass = video.rating_class || 'untagged';
const rClass = video.rating_class || (video.tag_id == 1 ? 'sfw' : (video.tag_id == 2 ? 'nsfw' : ((video.tag_id == 3 || video.tag_id == window.f0ckSession?.nsfl_tag_id) ? 'nsfl' : 'untagged')));
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
const blurSfw = localStorage.getItem('blurSfw') === 'true';
+26 -6
View File
@@ -815,13 +815,33 @@ class v0ck {
// Attempt autoplay and show overlay if blocked
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
if (shouldAutoplay) {
const playPromise = togglePlay();
if (playPromise !== undefined) {
playPromise.catch(() => {
if (!video.paused) {
player.classList.remove('v0ck_initial');
} else {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise.then(() => {
player.classList.remove('v0ck_initial');
}).catch((err) => {
if (err && err.name === 'AbortError') {
const onCanPlay = () => {
video.removeEventListener('canplay', onCanPlay);
if (video.paused) {
video.play().then(() => {
player.classList.remove('v0ck_initial');
}).catch(() => {
player.classList.add('v0ck_initial');
});
}
};
video.addEventListener('canplay', onCanPlay, { once: true });
} else {
player.classList.add('v0ck_initial');
}
});
} else if (video.paused) {
player.classList.add('v0ck_initial');
});
} else if (video.paused) {
player.classList.add('v0ck_initial');
}
}
} else {
player.classList.add('v0ck_initial');
+201
View File
@@ -0,0 +1,201 @@
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 audit from "./inc/audit.mjs";
import { getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
const execFile = promisify(_execFile);
const sendJson = (res, data, code = 200) => {
const body = JSON.stringify(data);
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
/** Shared admin session + CSRF lookup */
async function getAdminSession(req, res) {
if (!req.cookies || !req.cookies.session) {
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
return null;
}
const user = await db`
SELECT "user".id, "user".login, "user".user, "user".admin,
"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) {
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
return null;
}
const session = user[0];
// CSRF validation via header
if (session.csrf_token) {
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== session.csrf_token) {
console.warn(`[CSRF] Blocked brand image request for user ${session.user}. Invalid token.`);
sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
return null;
}
}
return session;
}
/** Delete the physical file for a stored brand image URL (if any) */
async function deleteOldBrandFile() {
const current = getBrandImageUrl();
if (!current) return;
// Strip query string to get the bare filename
const urlPath = current.split('?')[0];
// Only delete files that live under our navbar img dir
if (!urlPath.startsWith('/s/img/navbar/brand.')) return;
const filename = path.basename(urlPath);
const filePath = path.join(cfg.paths.s, 'img', 'navbar', filename);
await fs.unlink(filePath).catch(() => {});
}
/** Persist brand image URL to site_settings DB and in-memory setting */
async function persistBrandImage(url) {
setBrandImageUrl(url);
await db`
INSERT INTO site_settings (key, value)
VALUES ('brand_image_url', ${url})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
}
// ── Upload ─────────────────────────────────────────────────────────────────
export const handleBrandImageUpload = async (req, res) => {
console.log('[BRAND UPLOAD] Started');
const session = await getAdminSession(req, res);
if (!session) return;
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
return sendJson(res, { success: false, msg: 'Invalid content type — multipart boundary missing' }, 400);
}
const body = await collectBody(req, 5 * 1024 * 1024); // 5 MB hard cap
const parts = parseMultipart(body, boundaryMatch[1]);
const file = parts.file;
if (!file || !file.data || file.data.length === 0) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
// 2 MB soft cap
const maxSize = 2 * 1024 * 1024;
if (file.data.length > maxSize) {
return sendJson(res, {
success: false,
msg: `File too large. Maximum is 2 MB, got ${(file.data.length / 1024 / 1024).toFixed(2)} MB`
}, 400);
}
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/svg+xml'];
const mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
if (!allowedMimes.includes(mime)) {
return sendJson(res, {
success: false,
msg: `Invalid file type. Allowed: gif, jpg, png, webp, svg. Got: ${mime}`
}, 400);
}
const imgDir = path.join(cfg.paths.s, 'img', 'navbar');
await fs.mkdir(imgDir, { recursive: true });
await fs.mkdir(cfg.paths.tmp, { recursive: true });
const isSvg = mime === 'image/svg+xml';
const ts = Date.now();
// Timestamp baked into the filename — every upload is a unique file
const outFilename = isSvg ? `brand.${ts}.svg` : `brand.${ts}.webp`;
const tmpPath = path.join(cfg.paths.tmp, `brand_tmp_${ts}`);
const finalPath = path.join(imgDir, outFilename);
await fs.writeFile(tmpPath, file.data);
if (!isSvg) {
// Verify actual MIME with file(1)
try {
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
const safeActual = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
if (!safeActual.includes(actualMime.trim())) {
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime.trim()}` }, 400);
}
} catch (_) {
// file(1) not available — skip magic check
}
// Convert to WebP via ImageMagick
try {
await execFile('magick', [tmpPath, '-coalesce', '-quality', '85', finalPath]);
} catch (err) {
console.error('[BRAND UPLOAD] ImageMagick error:', err);
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'Failed to process image (ImageMagick required)' }, 500);
}
} else {
// SVG: copy as-is
await fs.copyFile(tmpPath, finalPath);
}
await fs.unlink(tmpPath).catch(() => {});
// Delete the previous brand file from disk
await deleteOldBrandFile();
const publicUrl = `/s/img/navbar/${outFilename}`;
await persistBrandImage(publicUrl);
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: publicUrl })})`.catch(() => {});
await audit.log(session.id, 'update_brand_image', 'system', 0, { url: publicUrl });
console.log('[BRAND UPLOAD] Done:', publicUrl);
return sendJson(res, { success: true, url: publicUrl, msg: 'Brand image updated' });
} catch (err) {
if (err.code === 'BODY_TOO_LARGE') {
return sendJson(res, { success: false, msg: 'File too large (5 MB max)' }, 413);
}
console.error('[BRAND UPLOAD ERROR]', err);
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
}
};
// ── Delete ─────────────────────────────────────────────────────────────────
export const handleBrandImageDelete = async (req, res) => {
console.log('[BRAND DELETE] Started');
const session = await getAdminSession(req, res);
if (!session) return;
try {
// Delete the physical file before clearing the setting
await deleteOldBrandFile();
await persistBrandImage('');
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: null })})`.catch(() => {});
await audit.log(session.id, 'delete_brand_image', 'system', 0, {});
console.log('[BRAND DELETE] Done');
return sendJson(res, { success: true, msg: 'Brand image removed' });
} catch (err) {
console.error('[BRAND DELETE ERROR]', err);
return sendJson(res, { success: false, msg: 'Delete failed: ' + err.message }, 500);
}
};
+26 -104
View File
@@ -3,89 +3,6 @@ import db from './sql.mjs';
import lib from './lib.mjs';
import cfg from './config.mjs';
const SPKI_ED25519_HEADER = Buffer.from('302a300506032b6570032100', 'hex');
/**
* Parse an OpenSSH formatted Ed25519 public key.
* Format: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... [comment]"
* @param {string} sshKey
* @returns {{ keyObject: crypto.KeyObject, rawPub: Buffer, wirePub: Buffer, fingerprint: string, shortFingerprint: string }}
*/
export function parseOpenSshPubkey(sshKey) {
if (!sshKey || typeof sshKey !== 'string') {
throw new Error('Missing or invalid SSH public key');
}
const parts = sshKey.trim().split(/\s+/);
if (parts.length < 2 || parts[0] !== 'ssh-ed25519') {
throw new Error('Only ssh-ed25519 keys are supported');
}
const wirePub = Buffer.from(parts[1], 'base64');
if (wirePub.length < 19) {
throw new Error('Invalid OpenSSH public key wire payload');
}
const typeLen = wirePub.readUInt32BE(0);
if (typeLen !== 11) {
throw new Error('Invalid key type length in OpenSSH wire format');
}
const type = wirePub.subarray(4, 4 + typeLen).toString('utf8');
if (type !== 'ssh-ed25519') {
throw new Error(`Expected ssh-ed25519, got ${type}`);
}
const keyLenOffset = 4 + typeLen;
const keyLen = wirePub.readUInt32BE(keyLenOffset);
if (keyLen !== 32) {
throw new Error(`Invalid Ed25519 key length: expected 32, got ${keyLen}`);
}
const rawPub = wirePub.subarray(keyLenOffset + 4, keyLenOffset + 4 + keyLen);
if (rawPub.length !== 32) {
throw new Error('Malformed Ed25519 raw public key');
}
// Construct standard SPKI DER for crypto.createPublicKey
const der = Buffer.concat([SPKI_ED25519_HEADER, rawPub]);
const keyObject = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
// Standard OpenSSH SHA256 fingerprint: SHA256:<base64-without-padding>
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(wirePub).digest('base64').replace(/=+$/, '');
const shortFingerprint = fingerprint.slice(7, 15);
return { keyObject, rawPub, wirePub, fingerprint, shortFingerprint };
}
/**
* Verify an Ed25519 signature against an OpenSSH public key.
* @param {string} sshPubkey
* @param {string|Buffer} message
* @param {string} signature (hex or base64)
* @returns {boolean}
*/
export function verifySignature(sshPubkey, message, signature) {
try {
const { keyObject } = parseOpenSshPubkey(sshPubkey);
const msgBuf = Buffer.isBuffer(message) ? message : Buffer.from(message, 'utf8');
let sigBuf;
if (typeof signature === 'string') {
const isHex = /^[0-9a-fA-F]{128}$/.test(signature);
sigBuf = isHex ? Buffer.from(signature, 'hex') : Buffer.from(signature, 'base64');
} else if (Buffer.isBuffer(signature)) {
sigBuf = signature;
} else {
return false;
}
return crypto.verify(null, msgBuf, keyObject, sigBuf);
} catch (err) {
return false;
}
}
import security from './security.mjs';
import { getHashUserIps } from './settings.mjs';
@@ -127,19 +44,24 @@ export async function logAnonActivity(req, { action, targetId = null, details =
}
/**
* Find or create a shadow user in the database for an anonymous SSH identity.
* @param {string} pubkey
* @param {string} fingerprint
* Find or create a shadow user in the database for a passkey-authenticated anonymous identity.
*
* @param {string} credentialId - base64url WebAuthn credential ID
* @param {object} [req]
* @param {string} [hwFingerprint]
* @returns {Promise<{ userId: number, isNew: boolean }>}
* @returns {Promise<{ userId: number, isNew: boolean, fingerprint: string }>}
*/
export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFingerprint = null) {
const normPubkey = pubkey.trim();
export async function getOrCreateAnonUserByCredential(credentialId, req = null, hwFingerprint = null) {
const auditIp = req ? resolveAuditIP(req) : null;
// Derive a stable "fingerprint" from the credential ID (for ban checks / display)
const fpBytes = crypto.createHash('sha256').update(Buffer.from(credentialId)).digest();
const fingerprint = 'SHA256:' + fpBytes.toString('base64').replace(/=+$/, '');
// Check if we already have a row for this credential
const existing = await db`
SELECT user_id FROM anon_identities
WHERE pubkey = ${normPubkey}
WHERE credential_id = ${credentialId}
LIMIT 1
`;
@@ -149,13 +71,13 @@ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFin
SET last_seen = NOW()
${auditIp ? db`, last_ip = ${auditIp}` : db``}
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
WHERE pubkey = ${normPubkey}
`;
return { userId: existing[0].user_id, isNew: false };
WHERE credential_id = ${credentialId}
`.catch(() => {});
return { userId: existing[0].user_id, isNew: false, fingerprint };
}
// Generate unique shadow username
const shortHash = crypto.createHash('sha256').update(fingerprint).digest('hex').slice(0, 8);
// Generate unique shadow username based on fingerprint short hash
const shortHash = fpBytes.toString('hex').slice(0, 8);
let baseLogin = `anon_${shortHash}`;
let finalLogin = baseLogin;
let counter = 1;
@@ -180,19 +102,19 @@ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFin
`;
await db`
INSERT INTO anon_identities (user_id, pubkey, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${normPubkey}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
ON CONFLICT (pubkey) DO UPDATE
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
ON CONFLICT (credential_id) DO UPDATE
SET last_seen = NOW()
${auditIp ? db`, last_ip = ${auditIp}` : db``}
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
`;
return { userId, isNew: true };
return { userId, isNew: true, fingerprint };
}
/**
* Create a valid session in user_sessions for this anonymous user.
* Create a valid session in user_sessions for an anonymous user.
* @param {number} userId
* @param {object} req
* @param {string} [hwFingerprint]
@@ -201,7 +123,7 @@ export async function getOrCreateAnonUser(pubkey, fingerprint, req = null, hwFin
export async function createAnonSession(userId, req, hwFingerprint = null) {
const auditIp = resolveAuditIP(req);
// Update anon_identities last_ip, created_ip, and hw_fingerprint
// Update anon_identities last_ip and hw_fingerprint
await db`
UPDATE anon_identities
SET last_ip = ${auditIp},
@@ -210,13 +132,13 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
WHERE user_id = ${userId}
`.catch(() => {});
// 1. If req.session is already active for this exact userId, reuse it!
// If req.session is already active for this exact userId, reuse it
if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) {
await logAnonActivity(req, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: req.session.csrf_token };
}
// 2. If client has a session cookie that maps to this userId in DB, reuse it!
// If client has a session cookie that maps to this userId in DB, reuse it
if (req?.cookies?.session) {
const existingHash = lib.sha256(req.cookies.session);
const existing = await db`
@@ -245,7 +167,7 @@ export async function createAnonSession(userId, req, hwFingerprint = null) {
browser: ua,
created_at: stamp,
last_used: stamp,
last_action: '/anon/session',
last_action: '/anon/passkey/auth',
kmsi: 1,
ip: ip
};
+1 -18
View File
@@ -505,24 +505,7 @@ export default new class {
};
async loggedin(req, res, next) {
if (!req.session) {
const sshPubkey = req.headers['x-ssh-pubkey'];
const sshTimestamp = parseInt(req.headers['x-ssh-timestamp'], 10);
const sshSig = req.headers['x-ssh-signature'];
if (sshPubkey && sshTimestamp && sshSig && Math.abs(Date.now() - sshTimestamp) <= 300000 && getEnableAnonymousAccess()) {
try {
const { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser } = await import('./anon_auth.mjs');
const message = `anon-auth:${sshTimestamp}:${sshPubkey}`;
if (verifySignature(sshPubkey, message, sshSig)) {
const parsed = parseOpenSshPubkey(sshPubkey);
const { userId } = await getOrCreateAnonUser(sshPubkey, parsed.fingerprint);
req.session = { id: userId, user: 'anonymous', display_name: 'Anonymous', is_anon: true, fingerprint: parsed.fingerprint };
}
} catch (e) {
console.warn('[LIB_LOGGEDIN] Anon header auth failed:', e);
}
}
}
// SSH header auth removed (hard cut) — anonymous users must use passkey sessions
if (!req.session) {
return res.reply({
code: 401,
+13 -2
View File
@@ -829,7 +829,9 @@ const f0cklib = {
const rows = (await db`
select
items.id,
items.title,
items.slug,
items.stamp,
items.visibility,
items.mime,
items.dest,
@@ -842,9 +844,10 @@ const f0cklib = {
items.album_count,
${user_id ? db`max(coalesce(uvv.view_count, 0)) as my_views,` : db``}
${user_id ? db`EXISTS (SELECT 1 FROM notifications WHERE user_id = ${user_id} AND item_id = items.id AND is_read = false) as has_notification,` : db`false as has_notification,`}
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' else 'NSFL' end) as tag,
(case when min(ta.tag_id) = 1 then 'SFW' when min(ta.tag_id) = 2 then 'NSFW' when min(ta.tag_id) = ${cfg.nsfl_tag_id || 3} then 'NSFL' else null end) as tag,
min(ta.tag_id) as tag_id,
max(uo.display_name) as display_name,
max(uo.username_color) as username_color,
${cfg.websrv.enable_dynamic_thumbs ? db`
(
(SELECT count(*) FROM favorites WHERE item_id = items.id) +
@@ -866,6 +869,9 @@ const f0cklib = {
row.xd_tier = meta.tier;
row.xd_label = meta.label;
row.is_audio = !!(row.mime && row.mime.startsWith('audio/'));
const tId = row.tag_id != null ? Number(row.tag_id) : null;
row.rating_class = tId === 1 ? 'sfw' : (tId === 2 ? 'nsfw' : (tId === nsflId ? 'nsfl' : 'untagged'));
row.rating_label = tId === 1 ? 'SFW' : (tId === 2 ? 'NSFW' : (tId === nsflId ? 'NSFL' : '?'));
}
if (rows.some(r => r.is_album)) {
@@ -2385,7 +2391,11 @@ const f0cklib = {
}
const items = rows.map(r => r.slug || String(r.id));
return { items, total: items.length, sampled: items.length >= 10000 };
const idMap = {};
for (const r of rows) {
if (r.slug) idMap[r.slug] = r.id;
}
return { items, id_map: idMap, total: items.length, sampled: items.length >= 10000 };
},
getComments: async (itemId, sort = 'new', process = true) => {
const numericId = await resolveNumericItemId(itemId);
@@ -2556,6 +2566,7 @@ const f0cklib = {
}
},
getSubscriptionStatus: async (userId, itemId) => {
if (cfg.enable_comments === false) return false;
const numericId = await resolveNumericItemId(itemId);
if (!userId || !numericId) return false;
const tStart = Date.now();
+2 -1
View File
@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs";
import crypto from "crypto";
import path from "path";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs, getBrandImageUrl } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -299,6 +299,7 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(),
shitpost_mode: getShitpostMode(),
enable_cleanup_config: cfg.websrv.enable_cleanup !== false,
current_brand_image: getBrandImageUrl(),
tmp: null
}, req)
});
+47 -1
View File
@@ -241,6 +241,21 @@ export default (router, tpl) => {
} catch (_) {}
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const itemNumericId = data.item?.id ? String(data.item.id) : itemid;
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
const itemUser = isAnon
? 'anonymous'
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
const itemUsername = isAnon
? 'anonymous'
: (data.item?.username || 'anonymous');
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -248,8 +263,16 @@ export default (router, tpl) => {
pagination: paginationHtml,
title: data.title,
id: itemid,
numeric_id: itemNumericId,
slug: data.item?.slug || null,
page: itemPage,
thumb: itemThumb,
mode: itemMode,
tag_id: itemTagId,
mime: itemMime,
user: itemUser,
username: itemUsername,
dest: itemDest,
is_random: true
})
});
@@ -489,6 +512,21 @@ export default (router, tpl) => {
} catch (_) {}
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const itemNumericId = data.item?.id ? String(data.item.id) : (/^\d+$/.test(itemid) ? itemid : null);
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
const itemUser = isAnon
? 'anonymous'
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
const itemUsername = isAnon
? 'anonymous'
: (data.item?.username || 'anonymous');
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -496,8 +534,16 @@ export default (router, tpl) => {
pagination: paginationHtml,
title: data.title,
id: itemid,
numeric_id: itemNumericId,
slug: data.item?.slug || null,
page: itemPage
page: itemPage,
thumb: itemThumb,
mode: itemMode,
tag_id: itemTagId,
mime: itemMime,
user: itemUser,
username: itemUsername,
dest: itemDest
})
});
});
+369 -155
View File
@@ -1,17 +1,26 @@
import crypto from 'node:crypto';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import security from '../../security.mjs';
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, createAnonSession } from '../../anon_auth.mjs';
import {
getOrCreateAnonUserByCredential,
createAnonSession,
resolveAuditIP
} from '../../anon_auth.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, fromBase64url, getRpIdFromHost
} from '../../webauthn.mjs';
import { getEnableAnonymousAccess } from '../../settings.mjs';
export default router => {
router.group(/^\/api\/v2\/anon/, group => {
/**
* POST /api/v2/anon/session
* Authenticate via OpenSSH Ed25519 signature and establish an anonymous session.
*/
// ─── Helpers ─────────────────────────────────────────────────────────────
const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => {
if (!sourceReason) return prefix;
let clean = sourceReason;
@@ -30,123 +39,60 @@ export default router => {
res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`);
};
group.post(/\/session$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({
success: false,
banned: true,
msg: 'YOU ARE BANNED!',
reason: ipBan.reason || 'IP address is banned',
expires: ipBan.expires ? new Date(ipBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
const body = req.post || req.body || {};
const pubkey = (body.pubkey || '').trim();
const timestamp = parseInt(body.timestamp, 10);
const signature = (body.signature || '').trim();
if (!pubkey || !timestamp || !signature) {
return res.json({ success: false, msg: 'Missing pubkey, timestamp, or signature' }, 400);
}
// Freshness check (5-minute window for clock skew)
const now = Date.now();
if (Math.abs(now - timestamp) > 300000) {
return res.json({ success: false, msg: 'Timestamp expired or out of bounds' }, 401);
}
const message = `anon-auth:${timestamp}:${pubkey}`;
const isValid = verifySignature(pubkey, message, signature);
if (!isValid) {
return res.json({ success: false, msg: 'Invalid Ed25519 signature' }, 401);
}
const parsed = parseOpenSshPubkey(pubkey);
const hwFingerprint = (body.hw_fingerprint || '').trim() || null;
// Check tombstone token sent from client (fingerprint or hardware ID)
if (body.tombstone && body.tombstone.banned) {
const tombstoneFp = body.tombstone.fingerprint;
const tombstoneHw = body.tombstone.hw_fingerprint;
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, activeTombstoneBan.reason || 'Device is banned', activeTombstoneBan.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint || tombstoneHw,
msg: 'YOU ARE BANNED!',
reason: activeTombstoneBan.reason || 'Device is banned',
expires: activeTombstoneBan.expires ? new Date(activeTombstoneBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
const checkAndCascadeBans = async (res, fingerprint, hwFingerprint, credentialId, tombstone) => {
// Tombstone cascade
if (tombstone && tombstone.banned) {
const tombstoneFp = tombstone.fingerprint;
const tombstoneHw = tombstone.hw_fingerprint;
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
return activeTombstoneBan;
}
}
// Check hardware fingerprint ban
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, hwBan.reason || 'Hardware ID is banned', hwBan.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: hwBan.reason || 'Hardware ID is banned',
expires: hwBan.expires ? new Date(hwBan.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
// Hardware fingerprint ban
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
return hwBan;
}
}
// Check fingerprint ban
const fpBan = await security.isFingerprintBanned(parsed.fingerprint);
// Fingerprint ban
if (fingerprint) {
const fpBan = await security.isFingerprintBanned(fingerprint);
if (fpBan) {
if (hwFingerprint) {
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
if (!alreadyHwBanned) {
await security.banAnonymousUser({
fingerprint: parsed.fingerprint,
fingerprint,
hwFingerprint,
bannedBy: fpBan.banned_by,
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
@@ -156,69 +102,332 @@ export default router => {
});
}
}
setBanCookie(res, fpBan.reason || 'Key fingerprint is banned', fpBan.expires);
return fpBan;
}
}
return null;
};
// ─── Deprecated SSH endpoint — hard cut ───────────────────────────────────
group.post(/\/session$/, async (req, res) => {
return res.json({
success: false,
msg: 'SSH-key anonymous authentication has been replaced by passkeys. Please refresh the page.'
}, 410);
});
// ─── Passkey Registration ─────────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/register/begin
* Returns WebAuthn registration options (challenge + rp + user config).
* The client does NOT need to be logged in.
*/
group.post(/\/passkey\/register\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-register' });
// Generate a temporary opaque user handle (32 random bytes, base64url)
// This will be replaced by the real user_id after finish, but WebAuthn requires
// a user.id at registration time. We store it in the challenge.
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
// Store the user handle in the challenge so finish can retrieve it
// (The challenge entry is keyed by challenge string)
// We re-issue the challenge with the user handle attached
const challengeWithHandle = generateChallenge({ type: 'anon-register', userHandle });
// Temporary display name for the registration prompt
const tmpName = `anon_new@${cfg.main?.url?.domain || 'f0ck.dev'}`;
const options = buildRegistrationOptions({
challenge: challengeWithHandle,
userId: userHandle,
userName: tmpName,
displayName: 'Anonymous',
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] register/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/register/finish
* Verify attestation, create shadow user + credential, establish session.
*/
group.post(/\/passkey\/register\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-register') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Verify the attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[ANON_PASSKEY] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
// Get fingerprint before ban checks (derived from credentialId)
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
// Ban checks
const ban = await checkAndCascadeBans(res, fingerprint, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: fpBan.reason || 'Key fingerprint is banned',
expires: fpBan.expires ? new Date(fpBan.expires).toLocaleString() : 'Permanent',
success: false, banned: true,
fingerprint, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
const { userId, isNew } = await getOrCreateAnonUser(pubkey, parsed.fingerprint, req, hwFingerprint);
// Get or create shadow user
const { userId, isNew, fingerprint: fp } = await getOrCreateAnonUserByCredential(
credentialId, req, hwFingerprint || null
);
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
if (!alreadyFpBanned) {
await security.banAnonymousUser({
userId,
fingerprint: parsed.fingerprint,
hwFingerprint,
reason: u.ban_reason || 'Banned',
expires: u.ban_expires,
banIps: true,
banHardware: true
});
}
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({
success: false,
banned: true,
fingerprint: parsed.fingerprint,
hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!',
reason: u.ban_reason || 'Banned',
expires: u.ban_expires ? new Date(u.ban_expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint);
// Store / update passkey credential in passkey_credentials
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW()
`;
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
is_new: isNew,
user_id: userId,
fingerprint: parsed.fingerprint,
short_fingerprint: parsed.shortFingerprint,
hw_fingerprint: hwFingerprint,
csrf_token: csrf_token
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Session establishment error:', err);
console.error('[ANON_PASSKEY] register/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Passkey Authentication ───────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/auth/begin
* Returns authentication options. allowCredentials is empty (discoverable credential flow).
*/
group.post(/\/passkey\/auth\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-auth' });
const options = buildAuthenticationOptions({
challenge,
allowCredentials: [], // discoverable — let the browser/Bitwarden pick
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] auth/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/auth/finish
* Verify assertion, establish anonymous session.
*/
group.post(/\/passkey\/auth\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: hwFingerprint, tombstone } = body;
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-auth') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Look up stored credential
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count, ai.fingerprint
FROM passkey_credentials pc
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = ${credentialId}
WHERE pc.credential_id = ${credentialId}
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'Passkey not registered. Please register first.' }, 401);
}
const { user_id: userId, public_key_spki: spki, sign_count: storedSignCount, fingerprint } = credRows[0];
// Verify the assertion
let authResult;
try {
authResult = await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki,
storedSignCount,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[ANON_PASSKEY] Auth verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Derive fingerprint if not stored yet (legacy or first-time)
const fpForBan = fingerprint || (() => {
return 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
})();
// Ban checks
const ban = await checkAndCascadeBans(res, fpForBan, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false, banned: true,
fingerprint: fpForBan, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
// Update sign count and last_used
await db`
UPDATE passkey_credentials
SET sign_count = ${authResult.newSignCount}, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Update anon_identities (hw_fingerprint, last_seen)
await db`
UPDATE anon_identities
SET last_seen = NOW()
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
WHERE user_id = ${userId} AND credential_id = ${credentialId}
`.catch(() => {});
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
user_id: userId,
fingerprint: fpForBan,
short_fingerprint: fpForBan.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_PASSKEY] auth/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Identity ─────────────────────────────────────────────────────────────
/**
* GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state.
@@ -234,9 +443,11 @@ export default router => {
}
const rows = await db`
SELECT pubkey, fingerprint, hw_fingerprint, created_at, last_seen
FROM anon_identities
WHERE user_id = ${req.session.id}
SELECT ai.credential_id, ai.fingerprint, ai.hw_fingerprint, ai.created_at, ai.last_seen,
pc.name AS passkey_name, pc.aaguid
FROM anon_identities ai
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
WHERE ai.user_id = ${req.session.id}
LIMIT 1
`;
@@ -247,9 +458,10 @@ export default router => {
is_anon: true,
user_id: req.session.id,
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
short_fingerprint: fp ? fp.slice(7, 15) : null,
hw_fingerprint: rows[0].hw_fingerprint,
pubkey: rows[0].pubkey,
credential_id: rows[0].credential_id,
passkey_name: rows[0].passkey_name,
csrf_token: req.session.csrf_token
});
}
@@ -262,11 +474,13 @@ export default router => {
csrf_token: req.session.csrf_token
});
} catch (err) {
console.error('[ANON_AUTH] Identity lookup error:', err);
console.error('[ANON_PASSKEY] Identity lookup error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────
/**
* POST /api/v2/anon/logout
* Clear anonymous session cookie and remove active session from database.
@@ -282,7 +496,7 @@ export default router => {
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
return res.json({ success: true });
} catch (err) {
console.error('[ANON_AUTH] Logout error:', err);
console.error('[ANON_PASSKEY] Logout error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
+142 -1
View File
@@ -9,7 +9,7 @@ import path from "path";
import f0cklib from '../../routeinc/f0cklib.mjs';
import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs';
import { purgeExpiredUploads } from '../../lib_delete.mjs';
import { purgeExpiredUploads, safeDeleteMediaFile } from '../../lib_delete.mjs';
import { calculateExpiresAt } from './upload.mjs';
import { addPrivateItem, removePrivateItem, addUnavailableItem, removeUnavailableItem } from '../../private_items.mjs';
import { logAnonActivity } from '../../anon_auth.mjs';
@@ -1714,6 +1714,147 @@ export default router => {
});
});
group.post(/\/admin\/delete-album-item$/, lib.loggedin, async (req, res) => {
const postid = +(req.post?.postid ?? req.body?.postid);
if (!postid || postid <= 0) {
return res.json({ success: false, msg: 'Invalid postid' }, 400);
}
const items = await db`
SELECT id, dest, mime, username, is_album, album_count, is_deleted, active
FROM items
WHERE id = ${postid} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!items.length) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const item = items[0];
if (!item.is_album) {
return res.json({ success: false, msg: 'Item is not an album' }, 400);
}
const isMod = req.session.admin || req.session.is_moderator;
const isOwner = req.session.user && item.username && req.session.user.toLowerCase() === item.username.toLowerCase();
if (!isMod && !isOwner) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
const subId = req.post?.sub_id ?? req.post?.subf0ck_id ?? req.body?.sub_id;
const subSlug = req.post?.sub_slug ?? req.body?.sub_slug;
const subIndex = req.post?.order_index ?? req.body?.order_index;
const allSubs = await db`
SELECT id, item_id, dest, mime, size, checksum, order_index, slug
FROM album_items
WHERE item_id = ${postid}
ORDER BY order_index ASC
`;
if (!allSubs.length) {
return res.json({ success: false, msg: 'No album items found' }, 404);
}
let targetSub = null;
if (subId !== undefined && subId !== null && subId !== '') {
targetSub = allSubs.find(s => s.id === +subId || s.slug === String(subId));
}
if (!targetSub && subSlug) {
targetSub = allSubs.find(s => s.slug === String(subSlug));
}
if (!targetSub && subIndex !== undefined && subIndex !== null && subIndex !== '') {
targetSub = allSubs.find(s => s.order_index === +subIndex);
}
if (!targetSub) {
return res.json({ success: false, msg: 'Album sub-item not found' }, 404);
}
const reason = req.post?.reason || req.body?.reason || 'No reason provided';
// If this is the last remaining item in the album, delete the whole post
if (allSubs.length <= 1) {
await safeDeleteMediaFile(item.dest, postid);
const thumbName = `${postid}.webp`;
await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {});
await fs.unlink(path.join(cfg.paths.t, `${postid}_blur.webp`)).catch(() => {});
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, thumbName)).catch(() => {});
}
await db`DELETE FROM album_items_tags_assign WHERE album_item_id = ${targetSub.id}`.catch(() => {});
await db`DELETE FROM album_items WHERE id = ${targetSub.id}`.catch(() => {});
await db`UPDATE items SET active = false, is_deleted = true WHERE id = ${postid}`;
await audit.log(req.session.id, 'delete_item', 'item', postid, { filename: item.dest, reason });
db.notify('delete_item', JSON.stringify({ id: postid })).catch(() => {});
return res.json({ success: true, post_deleted: true, msg: 'Last album item removed, post deleted' });
}
// Multi-item album: safely remove this sub-item media
if (targetSub.dest && !targetSub.dest.startsWith('yt:')) {
await safeDeleteMediaFile(targetSub.dest, postid);
const subBase = targetSub.dest.replace(/\.[^.]+$/, '');
await fs.unlink(path.join(cfg.paths.t, `${subBase}.webp`)).catch(() => {});
if (targetSub.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${subBase}.webp`)).catch(() => {});
}
} else if (targetSub.dest?.startsWith('yt:')) {
await fs.unlink(path.join(cfg.paths.t, `${targetSub.id}.webp`)).catch(() => {});
}
await db`DELETE FROM album_items_tags_assign WHERE album_item_id = ${targetSub.id}`.catch(() => {});
await db`DELETE FROM album_items WHERE id = ${targetSub.id}`;
const remainingSubs = allSubs.filter(s => s.id !== targetSub.id);
const isCoverDeleted = targetSub.order_index === 0;
// Re-index remaining album items so order_index is contiguous
await db`
UPDATE album_items
SET order_index = order_index - 1
WHERE item_id = ${postid} AND order_index > ${targetSub.order_index}
`;
if (isCoverDeleted) {
const newCover = remainingSubs[0];
await db`
UPDATE items
SET dest = ${newCover.dest},
mime = ${newCover.mime},
size = ${newCover.size || 0},
checksum = ${newCover.checksum || ''},
album_count = ${remainingSubs.length}
WHERE id = ${postid}
`;
try {
await queue.genThumbnail(newCover.dest, newCover.mime, postid, '');
await queue.genBlurredThumbnail(postid);
} catch (thumbErr) {
console.error('[DELETE-ALBUM-ITEM] Failed to regen thumbnail for promoted cover:', thumbErr);
}
} else {
await db`
UPDATE items
SET album_count = ${remainingSubs.length}
WHERE id = ${postid}
`;
}
await audit.log(req.session.id, 'delete_album_item', 'album_item', targetSub.id, {
postid,
filename: targetSub.dest,
order_index: targetSub.order_index,
reason
});
return res.json({
success: true,
post_deleted: false,
deleted_sub_id: targetSub.id,
deleted_order_index: targetSub.order_index,
remaining_count: remainingSubs.length,
promoted_cover: isCoverDeleted
});
});
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('favorite')) {
return res.json({ success: false, msg: 'Anonymous favorites are disabled' }, 403);
+274
View File
@@ -5,6 +5,12 @@ import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { canAnonDo, isAnonSession } from '../../settings.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, getRpIdFromHost
} from '../../webauthn.mjs';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -1194,6 +1200,274 @@ export default router => {
}
});
// ─── Passkey Management (registered users) ──────────────────────────────
/**
* GET /api/v2/settings/passkeys
* List the current user's registered passkeys.
*/
group.get(/\/passkeys$/, lib.registeredUser, async (req, res) => {
try {
const rows = await db`
SELECT id, credential_id, name, aaguid, created_at, last_used
FROM passkey_credentials
WHERE user_id = ${req.session.id}
ORDER BY created_at DESC
`;
return res.json({ success: true, passkeys: rows });
} catch (err) {
console.error('[PASSKEYS] List error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/begin
* Generate WebAuthn registration options for a logged-in user.
*/
group.post(/\/passkeys\/register\/begin$/, lib.registeredUser, async (req, res) => {
try {
// Get existing credentials to exclude (prevent re-registering same authenticator)
const existing = await db`
SELECT credential_id FROM passkey_credentials
WHERE user_id = ${req.session.id}
`;
const excludeCredentials = existing.map(r => ({ id: r.credential_id, type: 'public-key' }));
const challenge = generateChallenge({ type: 'user-register', userId: req.session.id });
// User handle: SHA256 of user_id encoded as base64url (stable, opaque)
const userHandle = base64url(
crypto.createHash('sha256').update(String(req.session.id)).digest().slice(0, 16)
);
const body = req.post || req.body || {};
const passkeyName = (body.name || '').trim().slice(0, 64) || null;
const options = buildRegistrationOptions({
challenge,
userId: userHandle,
userName: req.session.login || req.session.user,
displayName: req.session.display_name || req.session.user,
excludeCredentials,
rpId: getRpIdFromHost(req.headers.host)
});
// Stash the intended passkey name in challenge metadata
if (passkeyName) {
// Re-consume and re-store with name (challenges are stored by their value)
// Simpler: store name in a temporary Map keyed by challenge
options._passkeyName = passkeyName;
}
return res.json({ success: true, options, passkey_name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/finish
* Verify attestation and store new passkey for the logged-in user.
*/
group.post(/\/passkeys\/register\/finish$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, name } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-register' || challengeMeta.userId !== req.session.id) {
return res.json({ success: false, msg: 'Challenge mismatch' }, 400);
}
// Verify attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[PASSKEYS] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
const passkeyName = ((name || '').trim().slice(0, 64)) || 'Passkey';
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${req.session.id}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${passkeyName})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW(), name = ${passkeyName}
`;
return res.json({ success: true, credential_id: credentialId, name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/delete
* Remove a passkey credential owned by the current user.
* Uses POST + JSON body to avoid URL-encoding issues with credential IDs.
*/
group.post(/\/passkeys\/delete$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const credentialId = body.credential_id;
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* DELETE /api/v2/settings/passkeys/:id
* Legacy path — kept for compatibility.
*/
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
try {
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/begin
* Start a passkey login challenge for a registered user (no session required).
*/
group.post(/\/passkeys\/login\/begin$/, async (req, res) => {
try {
const challenge = generateChallenge({ type: 'user-login' });
const options = buildAuthenticationOptions({ challenge, allowCredentials: [], rpId: getRpIdFromHost(req.headers.host) });
return res.json({ success: true, options });
} catch (err) {
console.error('[PASSKEYS] login/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/finish
* Verify assertion and create a full registered-user session.
*/
group.post(/\/passkeys\/login\/finish$/, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId } = body;
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-login') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Look up credential — must belong to a registered (non-anon) user
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count,
u.login, u.user, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
FROM passkey_credentials pc
JOIN "user" u ON u.id = pc.user_id
WHERE pc.credential_id = ${credentialId}
AND u.login IS NOT NULL
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'No registered account found for this passkey.' }, 401);
}
const row = credRows[0];
if (row.banned) {
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
}
// Verify the assertion
try {
await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki: row.public_key_spki,
storedSignCount: row.sign_count,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[PASSKEYS] login/finish verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Update sign count
await db`
UPDATE passkey_credentials SET sign_count = sign_count + 1, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Create a full user session (same as normal login)
const stamp = Math.floor(Date.now() / 1000);
const ip = (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.socket?.remoteAddress || '').split(',')[0].trim();
const sessionToken = crypto.randomBytes(32).toString('hex');
const csrfToken = crypto.randomBytes(32).toString('hex');
const sessRecord = {
user_id: row.user_id,
session: lib.sha256(sessionToken),
csrf_token: csrfToken,
browser: req.headers['user-agent'] || '',
created_at: stamp,
last_used: stamp,
last_action: '/passkey-login',
kmsi: 1,
ip
};
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}`;
res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false });
} catch (err) {
console.error('[PASSKEYS] login/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
return group;
});
+10 -6
View File
@@ -481,9 +481,11 @@ export default router => {
}
// 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); }
if (cfg.enable_comments !== false) {
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 {
@@ -820,9 +822,11 @@ export default router => {
addPrivateItem(itemid, filename, session.user);
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { }
if (cfg.enable_comments !== false) {
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);
+3 -22
View File
@@ -7,13 +7,12 @@ import audit from "../audit.mjs";
import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
import { parseOpenSshPubkey, verifySignature, getOrCreateAnonUser, resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { getEnableAnonymousAccess, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
// Get comments for an item
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
const itemId = req.params.itemid;
@@ -399,28 +398,10 @@ export default (router, tpl) => {
// Post a comment
router.post('/api/comments', async (req, res) => {
if (!req.session) {
const sshPubkey = req.headers['x-ssh-pubkey'];
const sshTimestamp = parseInt(req.headers['x-ssh-timestamp'], 10);
const sshSig = req.headers['x-ssh-signature'];
if (sshPubkey && sshTimestamp && sshSig && getEnableAnonymousAccess()) {
const now = Date.now();
if (Math.abs(now - sshTimestamp) <= 300000) {
const message = `anon-auth:${sshTimestamp}:${sshPubkey}`;
if (verifySignature(sshPubkey, message, sshSig)) {
try {
const parsed = parseOpenSshPubkey(sshPubkey);
const { userId } = await getOrCreateAnonUser(sshPubkey, parsed.fingerprint);
req.session = { id: userId, user: 'anonymous', display_name: 'Anonymous', is_anon: true, fingerprint: parsed.fingerprint };
} catch (e) {
console.error('[ANON_COMMENTS] Auth header error:', e);
}
}
}
}
}
// Anonymous users must have an active passkey session — no SSH header fallback (hard cut)
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
if (isAnonSession(req.session) && !canAnonDo('comment')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Anonymous commenting is disabled" }) });
}
+15 -9
View File
@@ -382,9 +382,11 @@ export default (router) => {
if (repost) {
await fs.unlink(finalTmp).catch(() => {});
// Auto-subscribe user to the existing item they attempted to rehost
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
}
return res.reply({
code: 200,
@@ -402,9 +404,11 @@ export default (router) => {
if (phashMatch) {
await fs.unlink(finalTmp).catch(() => {});
// Auto-subscribe user to the existing item they attempted to rehost (visual match)
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
}
return res.reply({
code: 200,
@@ -444,9 +448,11 @@ export default (router) => {
`;
// Automatically subscribe user to the new item
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
}
// Process thumbnail
try {
+25 -2
View File
@@ -580,9 +580,32 @@ export default (router, tpl) => {
it.is_onara_active = false;
}
}
// If not in first page of feed, unshift it so thumbnail is present in background
// If not in first page of feed, add it so thumbnail is present in background
if (!foundInGrid && data.item) {
gridData.items.unshift({ ...data.item, is_onara_active: true });
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const tagId = data.item.tag_id || (data.item.is_nsfl ? nsflId : (data.item.is_nsfw ? 2 : (data.item.is_sfw ? 1 : null)));
const thumbUrl = data.item.thumb || data.item.thumbnail || (data.item.id ? `/t/${data.item.id}.webp` : null);
const cleanDest = data.item.dest ? String(data.item.dest).replace(/^\/b\//, '') : '';
const gridItem = {
...data.item,
id: data.item.id,
slug: data.item.slug,
tag_id: tagId,
thumb: thumbUrl,
dest: cleanDest,
display_name: data.item.author_display_name || data.item.display_name || data.item.username,
username: data.item.username,
mime: data.item.matching_sub_mime || data.item.mime,
thumb_size: data.item.thumb_size || 1,
is_onara_active: true
};
if (isRandom && gridData.items.length > 0) {
const insertIdx = Math.floor(Math.random() * (gridData.items.length + 1));
gridData.items.splice(insertIdx, 0, gridItem);
} else {
gridData.items.unshift(gridItem);
}
}
}
+18 -2
View File
@@ -422,16 +422,28 @@ export default (router, tpl) => {
const page = +(req.url.qs?.page || 1);
const limit = 50;
const offset = (page - 1) * limit;
const filterAction = req.url.qs?.action?.trim() || '';
const filterUser = req.url.qs?.user?.trim() || '';
const logs = await db`
SELECT al.*, u.user as username
FROM audit_log al
LEFT JOIN "user" u ON al.user_id = u.id
WHERE true
${filterAction ? db`AND al.action = ${filterAction}` : db``}
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
ORDER BY al.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
const totalResult = await db`SELECT count(*) as c FROM audit_log`;
const totalResult = await db`
SELECT count(*) as c
FROM audit_log al
LEFT JOIN "user" u ON al.user_id = u.id
WHERE true
${filterAction ? db`AND al.action = ${filterAction}` : db``}
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
`;
const total = totalResult[0].c;
const pages = Math.ceil(total / limit);
@@ -493,7 +505,9 @@ export default (router, tpl) => {
logs: processed,
page,
pages,
hasMore: page < pages
hasMore: page < pages,
filterAction,
filterUser
});
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
@@ -506,6 +520,8 @@ export default (router, tpl) => {
logs: processedLogs,
page,
pages,
filterAction,
filterUser,
tmp: null
}, req)
});
+16 -2
View File
@@ -318,6 +318,20 @@ db.listen('motd', (payload) => {
}
}).catch(err => console.error('DB Listen MOTD error:', err));
// Global listener for brand image updates
db.listen('brand_image', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting brand_image update to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'brand_image', data: { url: data.url || null } });
}
} catch (e) {
console.error('Brand image broadcast error:', e);
}
}).catch(err => console.error('DB Listen brand_image error:', err));
// Global listener for new items (live grid updates)
db.listen('new_item', (payload) => {
try {
@@ -864,7 +878,7 @@ export default (router, tpl) => {
// Notification History Page
router.get('/notifications', async (req, res) => {
if (!req.session) return res.redirect('/login');
const tab = req.url.qs?.tab || 'user';
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs?.tab || 'user');
const data = await getNotificationHistory(req.session.id, 1, 50, tab);
data.session = req.session;
data.hidePagination = true;
@@ -885,7 +899,7 @@ export default (router, tpl) => {
success: false
}, 401);
const page = parseInt(req.url.qs.page) || 1;
const tab = req.url.qs.tab || null;
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs.tab || null);
const data = await getNotificationHistory(req.session.id, page, 50, tab);
const html = tpl.render('snippets/notifications-list', { ...data, active_mode: req.session?.mode ?? 0 }, req);
+19 -4
View File
@@ -10,9 +10,21 @@ export default (router, tpl) => {
router.post(/^\/api\/v2\/report\/?$/, async (req, res) => {
try {
const { item_id, comment_id, reported_user_id, reason } = req.post;
// The framework's readBody uses Object.fromEntries which loses duplicate keys
// and decodeURIComponent on an array joins it as comma-separated string.
// So categories[]= ends up as a single comma-joined string — split it back.
const VALID_CATEGORIES = ['wrong_rating', 'spam', 'duplicate', 'copyright', 'illegal', 'other'];
let rawCats = req.post['categories[]'] || req.post['categories'] || '';
let categories = [];
if (Array.isArray(rawCats)) {
categories = rawCats;
} else if (typeof rawCats === 'string' && rawCats.length > 0) {
categories = rawCats.split(',').map(s => s.trim());
}
categories = categories.filter(c => VALID_CATEGORIES.includes(c));
if (!reason || reason.trim().length === 0) {
return res.json({ success: false, msg: "Reason is required." }, 400);
if ((!reason || reason.trim().length === 0) && categories.length === 0) {
return res.json({ success: false, msg: "Please select at least one reason or provide a description." }, 400);
}
// At least one target must be specified
@@ -50,14 +62,15 @@ export default (router, tpl) => {
}
const reportRes = await db`
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason)
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories)
VALUES (
${req.session ? req.session.id : null},
${ip},
${item_id ? +item_id : null},
${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null},
${reason.trim()}
${reason ? reason.trim() : ''},
${db.array(categories)}
)
RETURNING id
`;
@@ -108,6 +121,7 @@ export default (router, tpl) => {
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,
resolver.user AS resolver_name,
i.dest AS item_dest,
tgt_auth.id AS item_user_id,
tgt_auth.user AS item_user_name,
@@ -123,6 +137,7 @@ export default (router, tpl) => {
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 "user" resolver ON r.resolved_by = resolver.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
+9
View File
@@ -246,3 +246,12 @@ export const setNsfpIds = (ids) => {
cfg.nsfp = [...nsfp_ids];
};
// Brand image URL — stored in site_settings DB, not config.json
// Falls back to cfg.websrv.custom_brand_image (array or string) on first boot
let brand_image_url = (() => {
const raw = cfg.websrv?.custom_brand_image;
return Array.isArray(raw) ? (raw[0] || '') : (raw || '');
})();
export const getBrandImageUrl = () => brand_image_url;
export const setBrandImageUrl = (val) => { brand_image_url = val || ''; };
+2 -2
View File
@@ -760,7 +760,7 @@ export default async bot => {
// Auto-subscribe uploader
try {
if (websiteUser?.id) {
if (cfg.enable_comments !== false && websiteUser?.id) {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${websiteUser.id}, ${itemid})
@@ -867,7 +867,7 @@ export default async bot => {
// Auto-subscribe uploader
try {
if (websiteUser?.id) {
if (cfg.enable_comments !== false && websiteUser?.id) {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${websiteUser.id}, ${itemid})
+474
View File
@@ -0,0 +1,474 @@
/**
* webauthn.mjs Pure Node.js WebAuthn / Passkey helpers (ES-256 / P-256 only)
*
* No external dependencies uses Node.js built-in `crypto` (WebCrypto).
*
* Supported algorithm: ES-256 (COSE alg -7, P-256).
* Supported attestation formats: "none" (passkey managers always send "none").
*
* Challenge store: in-memory Map with TTL. Challenges are single-use.
*/
import crypto from 'node:crypto';
import cfg from './config.mjs';
// ---------------------------------------------------------------------------
// Challenge store (in-memory, single-use, 5-minute TTL)
// ---------------------------------------------------------------------------
const challengeStore = new Map();
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
setInterval(() => {
const now = Date.now();
for (const [k, v] of challengeStore) {
if (now > v.expiresAt) challengeStore.delete(k);
}
}, 60_000);
/**
* Generate a random challenge and store it with optional metadata.
* @param {object} [meta] - e.g. { userId, type }
* @returns {string} base64url-encoded challenge
*/
export function generateChallenge(meta = {}) {
const buf = crypto.getRandomValues(new Uint8Array(32));
const challenge = base64url(buf);
challengeStore.set(challenge, { ...meta, expiresAt: Date.now() + CHALLENGE_TTL_MS });
return challenge;
}
/**
* Consume (use-once) a challenge. Returns stored metadata or throws.
* @param {string} challenge base64url
* @returns {object} stored metadata
*/
export function consumeChallenge(challenge) {
const entry = challengeStore.get(challenge);
if (!entry) throw new Error('Challenge not found or expired');
if (Date.now() > entry.expiresAt) {
challengeStore.delete(challenge);
throw new Error('Challenge expired');
}
challengeStore.delete(challenge);
return entry;
}
// ---------------------------------------------------------------------------
// Relying Party config
// ---------------------------------------------------------------------------
function getRpId(override) {
// Explicit override wins (e.g. derived from request Host header for localhost dev)
if (override) return override;
return cfg.websrv?.passkey_rp_id || cfg.main?.url?.domain || 'localhost';
}
/**
* Derive an rpId from a Host header value.
* e.g. "localhost:1337" "localhost", "f0ck.dev" "f0ck.dev"
* Use this in route handlers and pass the result to build-x and verify-x functions.
*/
export function getRpIdFromHost(hostHeader) {
if (!hostHeader) return null;
return hostHeader.split(':')[0] || null;
}
function getExpectedOrigins(rpId) {
const origins = [`https://${rpId}`];
// Also allow http for localhost dev
if (rpId === 'localhost' || rpId.startsWith('127.') || rpId === '[::1]') {
origins.push(`http://${rpId}`);
// Also allow http://localhost:PORT
if (cfg.websrv?.port) origins.push(`http://${rpId}:${cfg.websrv.port}`);
}
// Allow any extra origins from config
if (Array.isArray(cfg.websrv?.passkey_extra_origins)) {
origins.push(...cfg.websrv.passkey_extra_origins);
}
return origins;
}
// ---------------------------------------------------------------------------
// Base64url helpers
// ---------------------------------------------------------------------------
export function base64url(buf) {
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
return b.toString('base64url');
}
export function fromBase64url(str) {
return Buffer.from(str, 'base64url');
}
function fromBase64(str) {
return Buffer.from(str, 'base64');
}
function toBase64(buf) {
return Buffer.from(buf).toString('base64');
}
// ---------------------------------------------------------------------------
// CBOR minimal decoder (only what we need for authenticatorData / COSE key)
// ---------------------------------------------------------------------------
/**
* Minimal CBOR decoder supporting:
* - unsigned int (major 0)
* - negative int (major 1)
* - byte string (major 2)
* - text string (major 3)
* - array (major 4)
* - map (major 5)
* - simple/float (major 7) ignored/skipped
*/
function cborDecode(buf, offset = 0) {
const [value, newOffset] = _cborDecodeItem(buf, offset);
return value;
}
function _cborDecodeItem(buf, offset) {
const byte = buf[offset++];
const major = (byte >> 5) & 0x7;
const info = byte & 0x1f;
let additionalInt;
if (info < 24) {
additionalInt = info;
} else if (info === 24) {
additionalInt = buf[offset++];
} else if (info === 25) {
additionalInt = (buf[offset] << 8) | buf[offset + 1];
offset += 2;
} else if (info === 26) {
additionalInt = (buf[offset] << 24 | buf[offset+1] << 16 | buf[offset+2] << 8 | buf[offset+3]) >>> 0;
offset += 4;
} else if (info === 27) {
// 64-bit uint — read as BigInt then convert (sign_count fits in 53-bit JS int normally)
const hi = (buf[offset] << 24 | buf[offset+1] << 16 | buf[offset+2] << 8 | buf[offset+3]) >>> 0;
const lo = (buf[offset+4] << 24 | buf[offset+5] << 16 | buf[offset+6] << 8 | buf[offset+7]) >>> 0;
additionalInt = hi * 0x100000000 + lo;
offset += 8;
} else {
throw new Error(`CBOR: unsupported additional info ${info}`);
}
switch (major) {
case 0: return [additionalInt, offset];
case 1: return [-(additionalInt + 1), offset];
case 2: {
const slice = buf.slice(offset, offset + additionalInt);
return [slice, offset + additionalInt];
}
case 3: {
const str = buf.slice(offset, offset + additionalInt).toString('utf8');
return [str, offset + additionalInt];
}
case 4: {
const arr = [];
for (let i = 0; i < additionalInt; i++) {
let item;
[item, offset] = _cborDecodeItem(buf, offset);
arr.push(item);
}
return [arr, offset];
}
case 5: {
const map = {};
for (let i = 0; i < additionalInt; i++) {
let key, val;
[key, offset] = _cborDecodeItem(buf, offset);
[val, offset] = _cborDecodeItem(buf, offset);
map[key] = val;
}
return [map, offset];
}
case 7: {
// simple/float — skip
if (info < 20) return [undefined, offset];
if (info === 20) return [false, offset];
if (info === 21) return [true, offset];
if (info === 22) return [null, offset];
if (info === 25) return [undefined, offset + 2]; // float16, skip
if (info === 26) return [undefined, offset + 4]; // float32, skip
if (info === 27) return [undefined, offset + 8]; // float64, skip
return [undefined, offset];
}
default:
throw new Error(`CBOR: unsupported major type ${major}`);
}
}
// ---------------------------------------------------------------------------
// Parse authenticatorData (binary, defined in WebAuthn spec)
// ---------------------------------------------------------------------------
function parseAuthenticatorData(buf) {
// 32 bytes rpIdHash + 1 byte flags + 4 bytes signCount + optional attested cred data
if (buf.length < 37) throw new Error('authenticatorData too short');
const rpIdHash = buf.slice(0, 32);
const flags = buf[32];
const signCount = buf.readUInt32BE(33);
const UP = (flags & 0x01) !== 0; // user presence
const UV = (flags & 0x04) !== 0; // user verification
const AT = (flags & 0x40) !== 0; // attested credential data included
const ED = (flags & 0x80) !== 0; // extension data included
let credentialData = null;
let offset = 37;
if (AT) {
const aaguid = buf.slice(offset, offset + 16);
offset += 16;
const credIdLen = buf.readUInt16BE(offset);
offset += 2;
const credentialId = buf.slice(offset, offset + credIdLen);
offset += credIdLen;
// Remaining bytes (up to ED extension) are COSE public key
const coseKeyBuf = ED
? buf.slice(offset) // we'll just parse until COSE map ends
: buf.slice(offset);
const coseKey = cborDecode(coseKeyBuf);
credentialData = { aaguid, credentialId, coseKey };
}
return { rpIdHash, flags: { UP, UV, AT, ED }, signCount, credentialData };
}
// ---------------------------------------------------------------------------
// Convert COSE ES-256 public key map to raw P-256 point (uncompressed)
// ---------------------------------------------------------------------------
function coseToSpki(coseKey) {
// COSE map keys: 1=kty, 3=alg, -1=crv, -2=x, -3=y
const kty = coseKey[1];
const alg = coseKey[3];
if (kty !== 2) throw new Error(`COSE: expected kty=2 (EC2), got ${kty}`);
if (alg !== -7) throw new Error(`COSE: expected alg=-7 (ES-256), got ${alg}`);
const x = Buffer.from(coseKey[-2]); // 32 bytes
const y = Buffer.from(coseKey[-3]); // 32 bytes
if (x.length !== 32 || y.length !== 32) throw new Error('COSE: invalid P-256 point length');
// Build DER SPKI for P-256:
// SEQUENCE {
// SEQUENCE { OID 1.2.840.10045.2.1 (ecPublicKey), OID 1.2.840.10045.3.1.7 (P-256) }
// BIT STRING { 0x04 || x || y }
// }
const oid = Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex');
const point = Buffer.concat([Buffer.from([0x04]), x, y]);
const spki = Buffer.concat([oid, point]);
return spki;
}
// ---------------------------------------------------------------------------
// Registration verification
// ---------------------------------------------------------------------------
/**
* Verify a WebAuthn registration (attestation) response.
*
* @param {object} params
* @param {string} params.challenge - base64url challenge that was sent to client
* @param {string} params.clientDataJSON - base64url from CredentialCreationResponse
* @param {string} params.attestationObject - base64url from CredentialCreationResponse
* @param {string} params.credentialId - base64url credential ID from response
*
* @returns {{ credentialId: string, spki: string, signCount: number, aaguid: string }}
* credentialId: base64url, spki: base64-encoded DER, signCount, aaguid: hex
*/
export async function verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: rpIdOverride }) {
// 1. Decode and verify clientDataJSON
const clientData = JSON.parse(fromBase64url(clientDataJSON).toString('utf8'));
if (clientData.type !== 'webauthn.create') {
throw new Error('clientData.type must be webauthn.create');
}
if (clientData.challenge !== challenge) {
throw new Error('Challenge mismatch');
}
const rpId = getRpId(rpIdOverride);
const expectedOrigins = getExpectedOrigins(rpId);
if (!expectedOrigins.includes(clientData.origin)) {
throw new Error(`Unexpected origin: ${clientData.origin}. Expected one of: ${expectedOrigins.join(', ')}`);
}
// 2. Parse attestationObject (CBOR)
const attObjBuf = fromBase64url(attestationObject);
const attObj = cborDecode(attObjBuf);
const fmt = attObj['fmt'];
const authDataBuf = Buffer.from(attObj['authData']);
// Only accept "none" attestation (all passkey managers use this)
if (fmt !== 'none' && fmt !== 'packed' && fmt !== 'fido-u2f') {
// We parse but don't verify attestation statements for non-none formats;
// passkeys always use "none" so this is fine in practice.
}
// 3. Parse authenticatorData
const authData = parseAuthenticatorData(authDataBuf);
// 4. Verify rpIdHash
const expectedRpIdHash = crypto.createHash('sha256').update(rpId).digest();
if (!authData.rpIdHash.equals(expectedRpIdHash)) {
throw new Error('rpIdHash mismatch');
}
// 5. Check user presence flag (UP must be set for passkeys)
if (!authData.flags.UP) {
throw new Error('User presence flag not set');
}
// 6. Extract credential data
if (!authData.credentialData) {
throw new Error('No attested credential data in authenticatorData');
}
const { credentialId: credIdBuf, coseKey, aaguid } = authData.credentialData;
// Verify credentialId matches what the browser sent
const credIdFromAuthData = base64url(credIdBuf);
if (credIdFromAuthData !== credentialId) {
throw new Error('credentialId mismatch between attestation and response');
}
// 7. Convert COSE key to SPKI DER
const spkiBuf = coseToSpki(coseKey);
const spki = toBase64(spkiBuf);
const aaguidHex = Buffer.from(aaguid).toString('hex');
return {
credentialId,
spki,
signCount: authData.signCount,
aaguid: aaguidHex
};
}
// ---------------------------------------------------------------------------
// Authentication verification
// ---------------------------------------------------------------------------
/**
* Verify a WebAuthn authentication (assertion) response.
*
* @param {object} params
* @param {string} params.challenge - base64url challenge that was sent to client
* @param {string} params.clientDataJSON - base64url from CredentialAssertionResponse
* @param {string} params.authenticatorData - base64url from CredentialAssertionResponse
* @param {string} params.signature - base64url from CredentialAssertionResponse
* @param {string} params.spki - base64 DER SPKI stored at registration
* @param {number} params.storedSignCount - sign_count stored in DB
*
* @returns {{ newSignCount: number }} updated sign count
*/
export async function verifyAuthentication({ challenge, clientDataJSON, authenticatorData, signature, spki, storedSignCount, rpId: rpIdOverride }) {
// 1. Decode and verify clientDataJSON
const clientData = JSON.parse(fromBase64url(clientDataJSON).toString('utf8'));
if (clientData.type !== 'webauthn.get') {
throw new Error('clientData.type must be webauthn.get');
}
if (clientData.challenge !== challenge) {
throw new Error('Challenge mismatch');
}
const rpId = getRpId(rpIdOverride);
const expectedOrigins = getExpectedOrigins(rpId);
if (!expectedOrigins.includes(clientData.origin)) {
throw new Error(`Unexpected origin: ${clientData.origin}`);
}
// 2. Parse authenticatorData
const authDataBuf = fromBase64url(authenticatorData);
const authData = parseAuthenticatorData(authDataBuf);
// 3. Verify rpIdHash
const expectedRpIdHash = crypto.createHash('sha256').update(rpId).digest();
if (!authData.rpIdHash.equals(expectedRpIdHash)) {
throw new Error('rpIdHash mismatch');
}
// 4. Check user presence flag
if (!authData.flags.UP) {
throw new Error('User presence flag not set');
}
// 5. Verify signature
// sig = ECDSA-SHA256(authData || SHA256(clientDataJSON))
const clientDataHash = crypto.createHash('sha256').update(fromBase64url(clientDataJSON)).digest();
const verifyData = Buffer.concat([authDataBuf, clientDataHash]);
const sigBuf = fromBase64url(signature);
const spkiBuf = fromBase64(spki);
const keyObject = crypto.createPublicKey({ key: spkiBuf, format: 'der', type: 'spki' });
// Node.js crypto.verify: algorithm must be a string ('SHA256'), not a WebCrypto object.
// For ECDSA the digest algorithm is specified here; the curve is derived from the key.
const valid = crypto.verify('SHA256', verifyData, keyObject, sigBuf);
if (!valid) {
throw new Error('Signature verification failed');
}
// 6. Check sign counter (0 means authenticator doesn't support it — skip check)
const newSignCount = authData.signCount;
if (storedSignCount > 0 && newSignCount !== 0 && newSignCount <= storedSignCount) {
throw new Error(`Sign count replay attack detected: stored=${storedSignCount} got=${newSignCount}`);
}
return { newSignCount };
}
/**
* Build registration options to send to the client.
* @param {object} params
* @param {string} params.challenge - base64url challenge
* @param {string} params.userId - base64url user handle (should be opaque, e.g. SHA256 of user.id)
* @param {string} params.userName - e.g. "anon_abc123"
* @param {string} params.displayName - e.g. "Anonymous"
* @param {Array} params.excludeCredentials - list of {id, type} to exclude (prevent re-registration)
* @returns {object} PublicKeyCredentialCreationOptions-compatible JSON
*/
export function buildRegistrationOptions({ challenge, userId, userName, displayName, excludeCredentials = [], rpId: rpIdOverride }) {
const rpId = getRpId(rpIdOverride);
return {
rp: {
name: cfg.main?.sitename || 'f0ck.dev',
id: rpId
},
user: {
id: userId,
name: userName,
displayName: displayName || userName
},
challenge,
pubKeyCredParams: [
{ type: 'public-key', alg: -7 } // ES-256
],
timeout: 60000,
excludeCredentials,
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred'
},
attestation: 'none'
};
}
/**
* Build authentication options to send to the client.
* @param {object} params
* @param {string} params.challenge - base64url challenge
* @param {Array} params.allowCredentials - list of {id, type} (empty = discoverable / any)
* @returns {object} PublicKeyCredentialRequestOptions-compatible JSON
*/
export function buildAuthenticationOptions({ challenge, allowCredentials = [], rpId: rpIdOverride }) {
const rpId = getRpId(rpIdOverride);
return {
challenge,
rpId,
allowCredentials,
userVerification: 'preferred',
timeout: 60000
};
}
+145 -22
View File
@@ -11,6 +11,7 @@ import flummpress from "flummpress";
import { handleUpload } from "./upload_handler.mjs";
import { handleAvatarUpload, handleAvatarDelete } from "./avatar_handler.mjs";
import { handleBannerUpload, handleBannerDelete } from "./banner_handler.mjs";
import { handleBrandImageUpload, handleBrandImageDelete } from "./brand_image_handler.mjs";
import { handleRethumbUpload } from "./rethumb_handler.mjs";
import { handleMemeUpload, handleMemeEdit } from "./meme_upload_handler.mjs";
import { handleEmojiUpload, handleEmojiEdit } from "./emoji_upload_handler.mjs";
@@ -20,14 +21,13 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes } from "./inc/settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs";
import { initPrivateItems, getPrivateItemFromPath, isPrivateItemPath, render502, render451 } from "./inc/private_items.mjs";
import { verifySignature } from "./inc/anon_auth.mjs";
import { createRequire } from 'module';
const _require = createRequire(import.meta.url);
@@ -120,6 +120,16 @@ function getGateLoginInjection(req) {
<label style="font-size:12px;color:#555;display:flex;align-items:center;gap:6px;"><input type="checkbox" name="kmsi" style="margin:0;"> Stay signed in</label>
<button type="submit" id="gate-login-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Sign in</button>
<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">
<hr style="flex:1;border:none;border-top:1px solid #ccc;">
<span style="font-size:11px;color:#999;">or</span>
<hr style="flex:1;border:none;border-top:1px solid #ccc;">
</div>
<button type="button" id="gate-passkey-btn" style="background:#f5f5f5;color:#333;border:1px solid #ccc;padding:9px;font-weight:600;font-size:13px;cursor:pointer;font-family:inherit;display:flex;align-items:center;justify-content:center;gap:8px;"
onmouseover="this.style.background='#eaeaea'" onmouseout="this.style.background='#f5f5f5'">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
Sign in with Passkey
</button>
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
No account? <a href="#" id="gate-to-register" style="color:#0051c3;text-decoration:underline;">Register</a>
</p>
@@ -273,6 +283,85 @@ function getGateLoginInjection(req) {
if (_gateRcWidgetId !== null && window.grecaptcha) { try { grecaptcha.reset(_gateRcWidgetId); } catch(e) {} }
});
};
// Passkey sign-in button (works for both registered users and anonymous passkey holders)
var passkeyBtn = document.getElementById('gate-passkey-btn');
if (passkeyBtn && window.PublicKeyCredential) {
passkeyBtn.addEventListener('click', async function() {
gateSetError('gate-login-error', '');
passkeyBtn.disabled = true;
passkeyBtn.style.opacity = '0.65';
try {
// 1. Get challenge from server
var beginRes = await fetch('/api/v2/anon/passkey/auth/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
var beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Failed to begin passkey authentication');
var rawChallenge = beginData.options.challenge; // save original base64url string for finish
var opts = beginData.options;
// 2. Decode base64url fields for WebAuthn API
function b64urlToArr(b64) {
var bin = atob(b64.replace(/-/g,'+').replace(/_/g,'/'));
var arr = new Uint8Array(bin.length);
for (var i=0; i<bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function arrToB64url(arr) {
var bin = '';
new Uint8Array(arr).forEach(function(b) { bin += String.fromCharCode(b); });
return btoa(bin).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}
var pkOpts = {
challenge: b64urlToArr(rawChallenge),
rpId: opts.rpId,
userVerification: opts.userVerification || 'preferred',
timeout: opts.timeout || 60000,
allowCredentials: (opts.allowCredentials || []).map(function(c) {
return { type: c.type, id: b64urlToArr(c.id) };
})
};
// 3. Invoke browser passkey picker
var assertion = await navigator.credentials.get({ publicKey: pkOpts });
// 4. Send to server — use original base64url challenge string
var finishRes = await fetch('/api/v2/anon/passkey/auth/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: rawChallenge,
clientDataJSON: arrToB64url(assertion.response.clientDataJSON),
authenticatorData: arrToB64url(assertion.response.authenticatorData),
signature: arrToB64url(assertion.response.signature),
credentialId: arrToB64url(assertion.rawId)
})
});
var finishData = await finishRes.json();
if (finishData.banned) {
gateSetError('gate-login-error', 'You are banned: ' + (finishData.reason || ''));
return;
}
if (!finishData.success) throw new Error(finishData.msg || 'Passkey authentication failed');
window.location.reload();
} catch(err) {
if (err && err.name === 'NotAllowedError') {
gateSetError('gate-login-error', 'Passkey prompt was cancelled.');
} else {
gateSetError('gate-login-error', err.message || 'Passkey sign-in failed.');
}
} finally {
passkeyBtn.disabled = false;
passkeyBtn.style.opacity = '1';
}
});
} else if (passkeyBtn) {
passkeyBtn.style.display = 'none'; // hide if no WebAuthn support
}
});
var _sb = '';
@@ -610,6 +699,28 @@ process.on('uncaughtException', err => {
}
});
// Block all comment-related routes when enable_comments is disabled
app.use(async (req, res) => {
if (cfg.enable_comments !== false) return;
const p = req.url?.pathname || '';
const isCommentRoute =
/^\/api\/comments/.test(p) ||
/^\/api\/comment\//.test(p) ||
/^\/api\/subscribe\//.test(p) ||
/^\/api\/subscriptions/.test(p) ||
/^\/subscriptions(\/|$)/.test(p) ||
/^\/ajax\/subscriptions(\/|$)/.test(p) ||
/^\/api\/polls\//.test(p) ||
/^\/activity(\/|$)/.test(p) ||
/^\/user\/[^/]+\/comments/.test(p) ||
/^\/api\/v2\/comments/.test(p) ||
/^\/api\/v2\/user\/subscribe-all-uploads/.test(p);
if (isCommentRoute) {
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ success: false, message: "Comments are disabled" }));
req.url.pathname = '/comments_disabled_bypass';
}
});
// Global CORS & OPTIONS preflight handler for API routes (enables standalone config_editor.html)
app.use(async (req, res) => {
if (req.url?.pathname?.startsWith('/api/')) {
@@ -1302,20 +1413,6 @@ process.on('uncaughtException', err => {
// CSRF validation helper — used by route handlers and global middleware
const validateCsrf = async (req, res) => {
if (req.session && req.session.csrf_token) {
// Cryptographically proven requests signed by the client's private Ed25519 key are origin-bound and immune to CSRF
const sshPubkey = req.headers['x-ssh-pubkey'];
const sshTimestamp = parseInt(req.headers['x-ssh-timestamp'], 10);
const sshSig = req.headers['x-ssh-signature'];
if (sshPubkey && sshTimestamp && sshSig && getEnableAnonymousAccess()) {
const now = Date.now();
if (Math.abs(now - sshTimestamp) <= 300000) {
const message = `anon-auth:${sshTimestamp}:${sshPubkey}`;
if (verifySignature(sshPubkey, message, sshSig)) {
return true;
}
}
}
let token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
// If header/query token is missing and body is not parsed yet on a non-GET method, parse it now
@@ -1340,7 +1437,7 @@ process.on('uncaughtException', err => {
// 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/anon/session', '/api/v2/anon/logout', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import'].includes(req.url.pathname)) return;
if (['/login', '/register', '/api/v2/anon/session', '/api/v2/anon/logout', '/api/v2/anon/passkey/register/begin', '/api/v2/anon/passkey/register/finish', '/api/v2/anon/passkey/auth/begin', '/api/v2/anon/passkey/auth/finish', '/api/v2/settings/passkeys/register/begin', '/api/v2/settings/passkeys/register/finish', '/api/v2/settings/passkeys/delete', '/api/v2/settings/passkeys/login/begin', '/api/v2/settings/passkeys/login/finish', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import', '/admin/brand_image/upload', '/admin/brand_image/delete'].includes(req.url.pathname)) return;
// DM attachment upload validates CSRF internally
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
// Hall manager routes are handled by bypass middleware with their own session auth
@@ -1385,6 +1482,18 @@ process.on('uncaughtException', err => {
}
});
// Bypass middleware for brand image upload/delete (multipart — needs raw body before router)
// CSRF is validated inside handleBrandImageUpload/handleBrandImageDelete after their own session lookups
app.use(async (req, res) => {
if (req.method === 'POST' && req.url.pathname === '/admin/brand_image/upload') {
await handleBrandImageUpload(req, res);
req.url.pathname = '/handled_brand_image_upload_bypass';
} else if (req.method === 'POST' && req.url.pathname === '/admin/brand_image/delete') {
await handleBrandImageDelete(req, res);
req.url.pathname = '/handled_brand_image_delete_bypass';
}
});
// Bypass middleware for banner upload (needs raw body before router consumes it)
// CSRF is validated inside handleBannerUpload/handleBannerDelete after their own session lookups
app.use(async (req, res) => {
@@ -1638,6 +1747,18 @@ process.on('uncaughtException', err => {
console.warn(`[BOOT] Trusted Uploads fetch failed:`, e.message);
}
// Fetch brand_image_url setting (DB overrides config.json — no writes to config.json at runtime)
try {
const biSetting = await db`SELECT value FROM site_settings WHERE key = 'brand_image_url' LIMIT 1`;
if (biSetting.length > 0) {
setBrandImageUrl(biSetting[0].value);
console.log(`[BOOT] Brand image URL loaded from DB: ${getBrandImageUrl()}`);
} else {
console.log(`[BOOT] No brand image URL in DB, using config default: ${getBrandImageUrl()}`);
}
} catch (e) {
console.warn(`[BOOT] Brand image URL fetch failed:`, e.message);
}
// Set enable_pdf from config (pure config setting)
setEnablePdf(!!cfg.enable_pdf);
@@ -1799,6 +1920,7 @@ process.on('uncaughtException', err => {
halls_enabled: cfg.websrv.halls_enabled !== false,
userhalls_enabled: cfg.websrv.userhalls_enabled !== false,
enable_userhall_image_upload: cfg.websrv.enable_userhall_image_upload !== false,
enable_oc: cfg.websrv.enable_oc !== false,
abyss_enabled: cfg.websrv.abyss_enabled !== false,
smtp_enabled: !!(cfg.smtp && cfg.smtp.enabled && cfg.smtp.mail_reset_password),
recaptcha_enabled: !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key),
@@ -1822,6 +1944,7 @@ process.on('uncaughtException', err => {
default_font: cfg.websrv.default_font || "",
site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl,
enable_comments: cfg.enable_comments !== false,
public_nsfw: !!cfg.websrv.public_nsfw,
public_untagged: !!cfg.websrv.public_untagged,
onara: !!(cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara),
@@ -1876,7 +1999,7 @@ process.on('uncaughtException', err => {
return JSON.stringify(cfg.websrv.koepfe || []);
}
},
custom_brand_images_json: JSON.stringify(cfg.websrv.custom_brand_image || []),
custom_brand_images_json: JSON.stringify(getBrandImageUrl() ? [getBrandImageUrl()] : []),
allowed_comment_images: cfg.websrv.allowed_comment_images || [],
allowed_comment_images_json: JSON.stringify(cfg.websrv.allowed_comment_images || []),
paths_images: cfg.websrv.paths?.images || '/b',
@@ -1997,10 +2120,10 @@ process.on('uncaughtException', err => {
globals.is_anonymized = isAnonymized;
globals.anon_anonymize = anonAnonymize;
// 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)];
// Brand image per-render — sourced from live in-memory setting (DB-backed, not config.json)
const brandUrl = getBrandImageUrl();
if (brandUrl) {
data.custom_brand_image = brandUrl;
}
if (activeReq) {
+15 -11
View File
@@ -422,9 +422,11 @@ export const handleUpload = async (req, res, self) => {
addPrivateItem(itemid, filename, req.session.user);
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) {}
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) {}
}
try {
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, manualApproval);
@@ -1100,14 +1102,16 @@ export const handleUpload = async (req, res, self) => {
}
// 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);
if (cfg.enable_comments !== false) {
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
+121
View File
@@ -36,9 +36,36 @@
</ul>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<!-- Navbar Brand Image -->
<div class="settings-item" id="brand-image-section" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; margin-top: 10px;">
<label style="display: block; font-weight: bold; color: var(--accent); margin-bottom: 8px;">Navbar Brand Image</label>
<p style="margin: 0 0 12px 0; font-size: 0.8em; color: #aaa;">Upload a logo to display in the site navbar instead of plain text. Accepted: gif, jpg, png, webp, svg &mdash; max 2&nbsp;MB.</p>
<div style="display: flex; align-items: center; gap: 16px; flex-wrap: wrap;">
<!-- Preview -->
<div id="brand-preview-wrap" style="width: 160px; height: 54px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); border-radius: 4px; display: flex; align-items: center; justify-content: center; overflow: hidden; flex-shrink: 0;">
@if(current_brand_image)
<img id="brand-preview" src="{{ current_brand_image }}" alt="current brand" style="max-height: 48px; max-width: 150px; object-fit: contain;">
@else
<span id="brand-preview" style="font-size: 0.75em; color: #666; font-style: italic;">No image set</span>
@endif
</div>
<!-- Controls -->
<div style="display: flex; flex-direction: column; gap: 8px;">
<label for="brand-file-input" style="display: inline-block; background: var(--accent); color: #000; padding: 7px 16px; border-radius: 4px; cursor: pointer; font-size: 0.82em; font-weight: bold; transition: opacity 0.2s;"
onmouseover="this.style.opacity='0.85'" onmouseout="this.style.opacity='1'">Choose Image</label>
<input type="file" id="brand-file-input" accept="image/gif,image/jpeg,image/png,image/webp,image/svg+xml" style="display:none;" onchange="uploadBrandImage(this)">
<button id="brand-remove-btn" onclick="removeBrandImage()" style="background: rgba(220,53,69,0.15); border: 1px solid rgba(220,53,69,0.4); color: #dc3545; padding: 7px 16px; border-radius: 4px; cursor: pointer; font-size: 0.82em; font-weight: bold; transition: background 0.2s;"
onmouseover="this.style.background='rgba(220,53,69,0.3)'" onmouseout="this.style.background='rgba(220,53,69,0.15)'"@if(!current_brand_image) disabled style="background: rgba(220,53,69,0.05); border: 1px solid rgba(220,53,69,0.15); color: #884040; padding: 7px 16px; border-radius: 4px; cursor: not-allowed; font-size: 0.82em; font-weight: bold;"@endif>Remove</button>
</div>
</div>
<span id="brand-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold;"></span>
</div>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<div class="settings-toggle" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Manual Upload Approval</label>
@@ -200,6 +227,100 @@
}
}
async function uploadBrandImage(input) {
const file = input.files[0];
if (!file) return;
const status = document.getElementById('brand-status');
const removeBtn = document.getElementById('brand-remove-btn');
status.textContent = 'Uploading…';
status.style.color = 'var(--accent)';
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
const fd = new FormData();
fd.append('file', file);
try {
const res = await fetch('/admin/brand_image/upload', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
},
body: fd
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Upload failed');
status.textContent = '✓ Brand image updated!';
status.style.color = '#28a745';
// Update preview
const wrap = document.getElementById('brand-preview-wrap');
let preview = document.getElementById('brand-preview');
if (!preview || preview.tagName !== 'IMG') {
wrap.innerHTML = '';
preview = document.createElement('img');
preview.id = 'brand-preview';
preview.alt = 'current brand';
preview.style.cssText = 'max-height:48px;max-width:150px;object-fit:contain;';
wrap.appendChild(preview);
}
preview.src = data.url;
// Enable remove button
removeBtn.disabled = false;
removeBtn.style.cssText = 'background:rgba(220,53,69,0.15);border:1px solid rgba(220,53,69,0.4);color:#dc3545;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:0.82em;font-weight:bold;transition:background 0.2s;';
setTimeout(() => { status.textContent = ''; }, 3000);
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
} finally {
input.value = '';
}
}
async function removeBrandImage() {
const status = document.getElementById('brand-status');
const removeBtn = document.getElementById('brand-remove-btn');
if (!confirm('Remove the custom brand image? The navbar will revert to text.')) return;
status.textContent = 'Removing…';
status.style.color = 'var(--accent)';
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
try {
const res = await fetch('/admin/brand_image/delete', {
method: 'POST',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken
}
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Remove failed');
status.textContent = '✓ Brand image removed.';
status.style.color = '#28a745';
// Reset preview
const wrap = document.getElementById('brand-preview-wrap');
wrap.innerHTML = '<span id="brand-preview" style="font-size:0.75em;color:#666;font-style:italic;">No image set</span>';
// Disable remove button
removeBtn.disabled = true;
removeBtn.style.cssText = 'background:rgba(220,53,69,0.05);border:1px solid rgba(220,53,69,0.15);color:#884040;padding:7px 16px;border-radius:4px;cursor:not-allowed;font-size:0.82em;font-weight:bold;';
setTimeout(() => { status.textContent = ''; }, 3000);
} catch (err) {
status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f';
}
}
</script>
</div>
+39 -45
View File
@@ -124,17 +124,43 @@
<span class="user-infobox-timestamp"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
</div>
<div class="user-infobox-body">
<div class="user-infobox-description">
@if(!is_anonymized){!! item.author_description || '' !!}@endif
</div>
<div class="user-infobox-actions">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
<div class="gapRight">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@if(enable_comments)
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
@endif
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(halls_enabled)
<i class="iconset fa-solid fa-layer-group" id="a_hall" data-item-id="{{ item.id }}" data-halls="{{ halls_slugs }}" data-user-halls="{{ user_halls_slugs }}" data-current-hall="{{ (tmp.hall && typeof tmp.hall === 'object') ? tmp.hall.slug : (tmp.hall || '') }}" data-current-user-hall="{{ (tmp.userHall && typeof tmp.userHall === 'object') ? tmp.userHall.slug : (tmp.userHall || '') }}" data-current-user-hall-owner="{{ tmp.userHallOwner || '' }}" title="Add to Hall"></i>
@endif
@if(can_manage_item)
@if(enable_oc)
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
@endif
@if(can_extract_meta)
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
@endif
@if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie')
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
@endif
@endif
@if(is_mod_or_admin)
<i class="iconset fa-solid fa-thumbtack{{ item.is_pinned ? ' active' : '' }}" id="a_pin" data-pinned="{{ item.is_pinned }}" title="{{ item.is_pinned ? 'Unpin from main' : 'Pin to main' }}"></i>
<i class="iconset fa-solid fa-ban{{ item.visibility === 3 ? ' active' : '' }}" id="a_unavailable" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="{{ item.visibility === 3 ? 'Make Available (Public)' : 'Make Unavailable (451)' }}" @if(item.visibility === 3) style="color: var(--danger, #ff4444);" @endif></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
@else
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@endif
</div>
<span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
</div>
</div>
@@ -157,40 +183,6 @@
</span>@endif</span>
<div class="gapRight">
@if(!user_alternative_infobox && session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif
@endif
@if(session)
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(halls_enabled)
<i class="iconset fa-solid fa-layer-group" id="a_hall" data-item-id="{{ item.id }}" data-halls="{{ halls_slugs }}" data-user-halls="{{ user_halls_slugs }}" data-current-hall="{{ (tmp.hall && typeof tmp.hall === 'object') ? tmp.hall.slug : (tmp.hall || '') }}" data-current-user-hall="{{ (tmp.userHall && typeof tmp.userHall === 'object') ? tmp.userHall.slug : (tmp.userHall || '') }}" data-current-user-hall-owner="{{ tmp.userHallOwner || '' }}" title="Add to Hall"></i>
@endif
@if(can_manage_item)
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
@if(can_extract_meta)
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
@endif
@if(item.mime === 'application/x-shockwave-flash' || item.mime === 'application/vnd.adobe.flash.movie')
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
@endif
@endif
@if(is_mod_or_admin)
<i class="iconset fa-solid fa-thumbtack{{ item.is_pinned ? ' active' : '' }}" id="a_pin" data-pinned="{{ item.is_pinned }}" title="{{ item.is_pinned ? 'Unpin from main' : 'Pin to main' }}"></i>
<i class="iconset fa-solid fa-ban{{ item.visibility === 3 ? ' active' : '' }}" id="a_unavailable" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}" title="{{ item.visibility === 3 ? 'Make Available (Public)' : 'Make Unavailable (451)' }}" @if(item.visibility === 3) style="color: var(--danger, #ff4444);" @endif></i>
<i class="iconset fa-solid fa-xmark" id="a_delete" title="Delete"></i>
@endif
@else
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@endif
</div>
<span class="badge badge-dark" id="tags" data-item-id="{{ item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}">
<span class="tags-inner">
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
@@ -244,7 +236,7 @@
</div>
</div>
@if(session || !hide_comments_from_public)
@if(enable_comments && (session || !hide_comments_from_public))
<div id="comments-container"
data-item-id="{{ item.id }}"
@if(session) data-user="{{ session.user }}" @endif
@@ -262,7 +254,9 @@
</div>
@endif
<button class="mobile-scroll-to-top" title="Back to top" aria-label="Scroll to top"><i class="fa-solid fa-chevron-up"></i></button>
@if(enable_comments)
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
@endif
</div>
+7 -1
View File
@@ -13,7 +13,7 @@
</div>
@endif
@if(session || !hide_comments_from_public)
@if(enable_comments && (session || !hide_comments_from_public))
<div id="comments-container"
data-item-id="{{ item.id }}"
@if(session) data-user="{{ session.user }}" @endif
@@ -162,10 +162,14 @@
@endif
@if(session)
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@if(enable_comments)
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
@endif
<i class="iconset fa-solid fa-triangle-exclamation report-item-btn" data-item-id="{{ item.id }}" title="Report this post"></i>
@if(can_manage_item)
@if(enable_oc)
<i class="iconset {{ item.is_oc ? 'fa-solid' : 'fa-regular' }} fa-star" id="a_oc" data-item-id="{{ item.id }}" data-is-oc="{{ item.is_oc }}" title="{{ item.is_oc ? 'Remove OC status' : 'Mark as OC' }}"></i>
@endif
<i class="iconset fa-solid fa-magic" id="a_metadata" data-item-id="{{ item.id }}" @if(item.mime === 'video/youtube') data-src="https://www.youtube.com/watch?v={{ item.dest.replace('yt:', '') }}" @endif title="Extract Metadata"></i>
@if(is_flash_item)
<i class="iconset fa-solid fa-image" id="a_rethumb" data-item-id="{{ item.id }}" title="Re-upload Thumbnail"></i>
@@ -199,7 +203,9 @@
</div>
</div>
@if(enable_comments)
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
@endif
</div>
{{-- RIGHT SIDEBAR: recent activity --}}
+103 -2
View File
@@ -4,6 +4,93 @@
<h1>AUDIT LOG</h1>
<p>Actions performed by moderators and admins.</p>
<hr>
<!-- Filter bar -->
<form id="audit-filter-form" class="audit-filter-bar" method="get" action="/mod/audit">
<select name="action" id="audit-filter-action" class="audit-filter-input">
<option value="">All actions</option>
<optgroup label="Items">
<option value="approve_item" {!! filterAction === 'approve_item' ? 'selected' : '' !!}>approve_item</option>
<option value="deny_item" {!! filterAction === 'deny_item' ? 'selected' : '' !!}>deny_item</option>
<option value="deny_item_multi" {!! filterAction === 'deny_item_multi' ? 'selected' : '' !!}>deny_item_multi</option>
<option value="purge_item" {!! filterAction === 'purge_item' ? 'selected' : '' !!}>purge_item</option>
<option value="purge_item_multi" {!! filterAction === 'purge_item_multi' ? 'selected' : '' !!}>purge_item_multi</option>
<option value="delete_item" {!! filterAction === 'delete_item' ? 'selected' : '' !!}>delete_item</option>
<option value="pin_item" {!! filterAction === 'pin_item' ? 'selected' : '' !!}>pin_item</option>
<option value="unpin_item" {!! filterAction === 'unpin_item' ? 'selected' : '' !!}>unpin_item</option>
<option value="toggle_tag" {!! filterAction === 'toggle_tag' ? 'selected' : '' !!}>toggle_tag</option>
</optgroup>
<optgroup label="Comments">
<option value="delete_comment" {!! filterAction === 'delete_comment' ? 'selected' : '' !!}>delete_comment</option>
<option value="delete_attachment" {!! filterAction === 'delete_attachment' ? 'selected' : '' !!}>delete_attachment</option>
</optgroup>
<optgroup label="Halls">
<option value="add_to_hall" {!! filterAction === 'add_to_hall' ? 'selected' : '' !!}>add_to_hall</option>
<option value="remove_from_hall" {!! filterAction === 'remove_from_hall' ? 'selected' : '' !!}>remove_from_hall</option>
<option value="create_hall" {!! filterAction === 'create_hall' ? 'selected' : '' !!}>create_hall</option>
<option value="rename_hall" {!! filterAction === 'rename_hall' ? 'selected' : '' !!}>rename_hall</option>
<option value="update_hall" {!! filterAction === 'update_hall' ? 'selected' : '' !!}>update_hall</option>
<option value="update_hall_metadata" {!! filterAction === 'update_hall_metadata' ? 'selected' : '' !!}>update_hall_metadata</option>
</optgroup>
<optgroup label="Users">
<option value="ban_user" {!! filterAction === 'ban_user' ? 'selected' : '' !!}>ban_user</option>
<option value="unban_user" {!! filterAction === 'unban_user' ? 'selected' : '' !!}>unban_user</option>
<option value="ban_ip" {!! filterAction === 'ban_ip' ? 'selected' : '' !!}>ban_ip</option>
<option value="unban_ip" {!! filterAction === 'unban_ip' ? 'selected' : '' !!}>unban_ip</option>
<option value="ban_fingerprint" {!! filterAction === 'ban_fingerprint' ? 'selected' : '' !!}>ban_fingerprint</option>
<option value="unban_fingerprint" {!! filterAction === 'unban_fingerprint' ? 'selected' : '' !!}>unban_fingerprint</option>
<option value="ban_hardware" {!! filterAction === 'ban_hardware' ? 'selected' : '' !!}>ban_hardware</option>
<option value="unban_hardware" {!! filterAction === 'unban_hardware' ? 'selected' : '' !!}>unban_hardware</option>
<option value="issue_warning" {!! filterAction === 'issue_warning' ? 'selected' : '' !!}>issue_warning</option>
<option value="admin_set_role" {!! filterAction === 'admin_set_role' ? 'selected' : '' !!}>admin_set_role</option>
<option value="admin_reset_password" {!! filterAction === 'admin_reset_password' ? 'selected' : '' !!}>admin_reset_password</option>
<option value="admin_delete_user" {!! filterAction === 'admin_delete_user' ? 'selected' : '' !!}>admin_delete_user</option>
<option value="admin_rename_user" {!! filterAction === 'admin_rename_user' ? 'selected' : '' !!}>admin_rename_user</option>
<option value="admin_create_user" {!! filterAction === 'admin_create_user' ? 'selected' : '' !!}>admin_create_user</option>
<option value="admin_reassign_uploads" {!! filterAction === 'admin_reassign_uploads' ? 'selected' : '' !!}>admin_reassign_uploads</option>
<option value="admin_bulk_delete_items" {!! filterAction === 'admin_bulk_delete_items' ? 'selected' : '' !!}>admin_bulk_delete_items</option>
<option value="admin_bulk_delete_comments" {!! filterAction === 'admin_bulk_delete_comments' ? 'selected' : '' !!}>admin_bulk_delete_comments</option>
<option value="admin_bulk_delete_halls" {!! filterAction === 'admin_bulk_delete_halls' ? 'selected' : '' !!}>admin_bulk_delete_halls</option>
<option value="manual_verify_user" {!! filterAction === 'manual_verify_user' ? 'selected' : '' !!}>manual_verify_user</option>
<option value="lock_user_layout" {!! filterAction === 'lock_user_layout' ? 'selected' : '' !!}>lock_user_layout</option>
<option value="unlock_user_layout" {!! filterAction === 'unlock_user_layout' ? 'selected' : '' !!}>unlock_user_layout</option>
<option value="admin_set_display_name" {!! filterAction === 'admin_set_display_name' ? 'selected' : '' !!}>admin_set_display_name</option>
<option value="admin_reset_login_attempts" {!! filterAction === 'admin_reset_login_attempts' ? 'selected' : '' !!}>admin_reset_login_attempts</option>
</optgroup>
<optgroup label="Tags">
<option value="nsfp_add" {!! filterAction === 'nsfp_add' ? 'selected' : '' !!}>nsfp_add</option>
<option value="nsfp_remove" {!! filterAction === 'nsfp_remove' ? 'selected' : '' !!}>nsfp_remove</option>
</optgroup>
<optgroup label="Reports">
<option value="resolve_report" {!! filterAction === 'resolve_report' ? 'selected' : '' !!}>resolve_report</option>
</optgroup>
<optgroup label="System">
<option value="purge_trash" {!! filterAction === 'purge_trash' ? 'selected' : '' !!}>purge_trash</option>
<option value="update_motd" {!! filterAction === 'update_motd' ? 'selected' : '' !!}>update_motd</option>
<option value="update_config_file" {!! filterAction === 'update_config_file' ? 'selected' : '' !!}>update_config_file</option>
<option value="run_cleanup_manual" {!! filterAction === 'run_cleanup_manual' ? 'selected' : '' !!}>run_cleanup_manual</option>
<option value="update_about_text" {!! filterAction === 'update_about_text' ? 'selected' : '' !!}>update_about_text</option>
<option value="update_rules_text" {!! filterAction === 'update_rules_text' ? 'selected' : '' !!}>update_rules_text</option>
<option value="update_terms_text" {!! filterAction === 'update_terms_text' ? 'selected' : '' !!}>update_terms_text</option>
<option value="add_wordfilter" {!! filterAction === 'add_wordfilter' ? 'selected' : '' !!}>add_wordfilter</option>
<option value="delete_wordfilter" {!! filterAction === 'delete_wordfilter' ? 'selected' : '' !!}>delete_wordfilter</option>
</optgroup>
</select>
<input
type="text"
name="user"
id="audit-filter-user"
class="audit-filter-input"
placeholder="Filter by moderator…"
value="{!! filterUser !!}"
autocomplete="off"
>
<button type="submit" class="badge badge-secondary audit-filter-btn">Filter</button>
@if(filterAction || filterUser)
<a href="/mod/audit" class="badge badge-secondary audit-filter-btn audit-filter-clear">✕ Clear</a>
@endif
</form>
<div class="audit-grid" id="audit-grid">
@each(logs as entry)
<div class="audit-card">
@@ -11,7 +98,10 @@
<div class="audit-card-user">
<a href="/user/{!! entry.username !!}">{!! entry.username !!}</a>
</div>
<div class="audit-card-time">{!! entry.created_at_fmt !!}</div>
<div class="audit-card-header-right">
<div class="audit-card-time">{!! entry.created_at_fmt !!}</div>
<span class="audit-entry-id">#<span class="audit-entry-id-val">{!! entry.id !!}</span></span>
</div>
</div>
<div class="audit-card-body">
<div class="audit-card-row">
@@ -123,6 +213,10 @@
var loading = false;
var hasMore = currentPage < totalPages;
// Read active filters so infinite scroll preserves them
var activeFilterAction = '{{ filterAction }}' || '';
var activeFilterUser = '{{ filterUser }}' || '';
if (pagination) pagination.style.display = 'none';
window.addEventListener('scroll', function () {
@@ -143,7 +237,11 @@
try {
var next = currentPage + 1;
var res = await fetch('/mod/audit?page=' + next, {
var url = '/mod/audit?page=' + next;
if (activeFilterAction) url += '&action=' + encodeURIComponent(activeFilterAction);
if (activeFilterUser) url += '&user=' + encodeURIComponent(activeFilterUser);
var res = await fetch(url, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
var data = await res.json();
@@ -159,7 +257,10 @@
'<div class="audit-card-user">' +
'<a href="/user/' + encodeURIComponent(log.username) + '">' + log.username + '</a>' +
'</div>' +
'<div class="audit-card-header-right">' +
'<div class="audit-card-time">' + (log.created_at || '') + '</div>' +
'<span class="audit-entry-id">#<span class="audit-entry-id-val">' + (log.id || '') + '</span></span>' +
'</div>' +
'</div>' +
'<div class="audit-card-body">' +
'<div class="audit-card-row">' +
+236 -420
View File
@@ -3,448 +3,264 @@
<div class="pagewrapper">
<div id="main">
<style>
.mod-reports-table {
width: 100%;
border-collapse: separate;
border-spacing: 0 8px;
color: var(--white);
.rp-page { max-width: 900px; margin: 0 auto; padding: 30px 15px 60px; }
.rp-header { margin-bottom: 28px; }
.rp-header h2 { margin: 0; font-weight: 800; letter-spacing: -0.5px; }
.rp-header p { color: #888; margin: 5px 0 0 0; font-size: 0.9em; }
.rp-filter-bar { display: flex; gap: 10px; align-items: center; margin-bottom: 24px; flex-wrap: wrap; }
.rp-filter-bar select {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #fff;
padding: 7px 12px;
outline: none;
cursor: pointer;
font-size: 0.88em;
}
.mod-reports-table th {
padding: 15px;
text-align: left;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 1px;
color: #888;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.mod-reports-table tr {
transition: all 0.2s ease;
}
.mod-reports-table tbody tr {
background: rgba(255, 255, 255, 0.02);
}
.mod-reports-table tbody tr:hover:not(.expanded-report) {
background: rgba(255, 255, 255, 0.05);
}
.mod-reports-table td {
padding: 15px;
vertical-align: middle;
}
.mod-reports-table .btn, .mod-reports-table button, .btn-modern {
border-radius: 4px !important;
font-size: 0.75rem;
.rp-filter-bar button {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
color: #fff;
padding: 7px 14px;
cursor: pointer;
font-size: 0.85em;
font-weight: 600;
transition: background 0.15s;
}
.rp-filter-bar button:hover { background: rgba(255,255,255,0.1); }
.rp-feed { display: flex; flex-direction: column; gap: 14px; }
.rp-state-msg { color: #666; font-style: italic; text-align: center; padding: 40px 0; }
/* Card */
.rp-card {
background: rgba(255,255,255,0.025);
border: 1px solid rgba(255,255,255,0.07);
border-radius: 10px;
overflow: hidden;
transition: border-color 0.2s;
}
.rp-card:hover { border-color: rgba(255,255,255,0.14); }
.rp-card.illegal-flag { border-left: 3px solid #dc3545; }
/* Card top bar */
.rp-card-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: rgba(0,0,0,0.2);
border-bottom: 1px solid rgba(255,255,255,0.05);
gap: 12px;
flex-wrap: wrap;
}
.rp-card-bar-left { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.rp-card-id {
font-family: monospace;
font-size: 0.78em;
color: #666;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 4px;
padding: 1px 7px;
}
.rp-card-time { font-size: 0.78em; color: #666; }
.rp-status-badge {
font-size: 0.7em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 8px 16px;
border: 0;
cursor: pointer;
transition: all 0.2s ease;
padding: 2px 8px;
border-radius: 10px;
}
.btn-modern {
background: rgba(255, 255, 255, 0.05);
.rp-status-pending { background: rgba(255,193,7,0.15); color: #ffc107; border: 1px solid rgba(255,193,7,0.3); }
.rp-status-resolved { background: rgba(40,167,69,0.15); color: #28a745; border: 1px solid rgba(40,167,69,0.3); }
.rp-status-rejected { background: rgba(108,117,125,0.2); color: #aaa; border: 1px solid rgba(108,117,125,0.3); }
/* Card body */
.rp-card-body {
display: flex;
gap: 0;
}
/* Media preview column */
.rp-preview {
width: 180px;
min-width: 180px;
background: #000;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.rp-preview img,
.rp-preview video {
width: 180px;
height: 140px;
object-fit: cover;
display: block;
}
.rp-preview-link {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.55);
opacity: 0;
transition: opacity 0.2s;
color: #fff;
font-size: 1.4em;
text-decoration: none;
}
.rp-preview:hover .rp-preview-link { opacity: 1; }
.rp-no-preview {
width: 180px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
color: #444;
font-size: 2em;
}
/* Info column */
.rp-info {
flex: 1;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.rp-meta-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 0.83em;
align-items: center;
}
.rp-meta-label { color: #555; text-transform: uppercase; font-size: 0.75em; font-weight: 700; letter-spacing: 0.5px; }
.rp-reporter-link { color: var(--accent); font-weight: 600; text-decoration: none; }
.rp-reporter-link:hover { text-decoration: underline; }
.rp-reporter-ip { color: #555; font-size: 0.88em; }
.rp-target-link { color: #ddd; font-weight: 500; text-decoration: none; }
.rp-target-link:hover { color: #fff; }
.rp-open-link { color: var(--accent); margin-left: 4px; font-size: 0.85em; }
/* Category badges */
.rp-cats { display: flex; flex-wrap: wrap; gap: 5px; }
.rp-cat {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* Reason */
.rp-reason {
font-size: 0.85em;
color: #bbb;
line-height: 1.5;
word-break: break-word;
background: rgba(0,0,0,0.2);
border-left: 2px solid rgba(255,255,255,0.08);
padding: 6px 10px;
border-radius: 0 4px 4px 0;
max-height: 80px;
overflow-y: auto;
}
/* Actions footer */
.rp-card-actions {
display: flex;
gap: 7px;
flex-wrap: wrap;
align-items: center;
padding: 10px 16px;
border-top: 1px solid rgba(255,255,255,0.05);
background: rgba(0,0,0,0.1);
}
.rp-btn {
border: none;
border-radius: 5px;
padding: 5px 13px;
font-size: 0.78em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
cursor: pointer;
transition: opacity 0.15s, transform 0.1s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
line-height: 1.5;
}
.rp-btn:hover { opacity: 0.85; transform: translateY(-1px); }
.rp-btn-resolve { background: #28a745; color: #fff; }
.rp-btn-reject { background: #495057; color: #fff; }
.rp-btn-delete { background: #dc3545; color: #fff; }
.rp-btn-warn { background: #ffc107; color: #000; }
.rp-btn-ban { background: #b71c1c; color: #fff; }
.rp-btn-unavail { background: #6f42c1; color: #fff; }
.rp-btn-avail { background: #28a745; color: #fff; }
.rp-btn-view { background: rgba(255,255,255,0.08); color: #fff; border: 1px solid rgba(255,255,255,0.12); }
.rp-btn-secondary { background: #6c757d; color: #fff; }
.rp-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.08); margin: 0 2px; }
.rp-anon-note { color: #555; font-size: 0.78em; font-style: italic; }
/* Pagination */
.rp-pagination { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 24px; }
.rp-pagination button {
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px;
color: #fff;
padding: 6px 16px;
cursor: pointer;
font-size: 0.85em;
transition: background 0.15s;
}
.btn-modern:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255,255,255,0.2);
.rp-pagination button:hover { background: rgba(255,255,255,0.1); }
.rp-pagination .rp-page-info { color: #666; font-size: 0.85em; }
@media (max-width: 600px) {
.rp-preview { width: 120px; min-width: 120px; }
.rp-preview img, .rp-preview video, .rp-no-preview { width: 120px; }
}
.btn-success { background: #28a745 !important; color: #fff !important; }
.btn-danger { background: #dc3545 !important; color: #fff !important; }
.btn-warning { background: #ffc107 !important; color: #000 !important; }
.btn-secondary { background: #6c757d !important; color: #fff !important; }
</style>
<div class="container mod-reports-page">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 30px; gap: 20px; flex-wrap: wrap;">
<div>
<h2 style="margin: 0; font-weight: 800; letter-spacing: -0.5px;">User Reports</h2>
<p style="color: #888; margin: 5px 0 0 0;">Review and resolve content flags from the community.</p>
</div>
<div class="container rp-page">
<div class="rp-header">
<h2>User Reports</h2>
<p>Review and resolve content flags from the community.</p>
</div>
<div style="margin-bottom: 25px; display: flex; gap: 10px; align-items: center;">
<select id="report-status-filter" style="width: 180px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 6px; color: #fff; padding: 8px 12px; outline: none; cursor: pointer;">
<div class="rp-filter-bar">
<select id="report-status-filter">
<option value="pending">Pending</option>
<option value="resolved">Resolved</option>
<option value="rejected">Rejected</option>
</select>
<button class="btn-modern" onclick="loadReports(1)">Refresh</button>
<button onclick="loadReports(1)"><i class="fa-solid fa-rotate-right"></i> Refresh</button>
</div>
<div class="table-responsive" style="border: none;">
<table class="mod-reports-table responsive-table">
<thead>
<tr>
<th>ID</th>
<th>Reporter</th>
<th>Target</th>
<th>Reason</th>
<th>Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="reports-table-body">
<tr><td colspan="6" class="text-center">Loading reports...</td></tr>
</tbody>
</table>
<div id="reports-feed" class="rp-feed">
<div class="rp-state-msg">Loading reports…</div>
</div>
<div id="reports-pagination" style="text-align: center; margin-top: 15px;"></div>
<div id="reports-pagination" class="rp-pagination"></div>
</div>
<script>
window.currentPage = window.currentPage || 1;
window.loadReports = async function(page = 1) {
window.currentPage = page;
const status = document.getElementById('report-status-filter').value;
const tbody = document.getElementById('reports-table-body');
const pag = document.getElementById('reports-pagination');
tbody.innerHTML = '<tr><td colspan="6" class="text-center">Loading reports...</td></tr>';
try {
const res = await fetch('/api/v2/mod/reports?status=' + status + '&page=' + page);
const data = await res.json();
if (data.success) {
window.currentReports = data.reports;
window.emojiMap = new Map();
if (data.emojis) {
data.emojis.forEach(emojiObj => window.emojiMap.set(emojiObj.name.toLowerCase(), emojiObj.url));
}
tbody.innerHTML = '';
if (data.reports.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center">No reports found.</td></tr>';
return;
}
data.reports.forEach(r => {
let targetHtml = '';
if (r.comment_id) {
targetHtml += 'Comment: <a href="#" onclick="window.expandItem(event, ' + r.id + ')">#' + r.comment_id + '</a>';
} else if (r.resolved_item_id) {
targetHtml += 'Item: <a href="#" onclick="window.expandItem(event, ' + r.id + ')">#' + r.resolved_item_id + '</a>';
} else if (r.reported_user_name) {
targetHtml += 'User: <a href="/user/' + r.reported_user_name + '">' + r.reported_user_name + '</a>';
}
let actionHtml = '';
if (status === 'pending') {
actionHtml =
'<button class="btn btn-sm btn-success" onclick="window.resolveReport(' + r.id + ', &quot;resolved&quot;)">Resolve</button> ' +
'<button class="btn btn-sm btn-danger" onclick="window.resolveReport(' + r.id + ', &quot;rejected&quot;)">Reject</button>';
}
let reporterHtml = '';
if (r.reporter_name) {
reporterHtml = '<a href="/user/' + r.reporter_name + '" style="color: var(--accent); font-weight: bold;">' + r.reporter_name + '</a>' + (r.reporter_ip ? ' <span style="font-size: 0.75rem; color: #888;">(' + r.reporter_ip + ')</span>' : '');
} else {
reporterHtml = '<span style="color: #aaa; font-style: italic;">Guest' + (r.reporter_ip ? ' (' + r.reporter_ip + ')' : '') + '</span>';
}
const tr = document.createElement('tr');
tr.innerHTML =
'<td data-label="ID">' + r.id + '</td>' +
'<td data-label="Reporter">' + reporterHtml + '</td>' +
'<td data-label="Target">' + targetHtml + '</td>' +
'<td data-label="Reason"><span style="opacity: 0.8;">' + r.reason + '</span></td>' +
'<td data-label="Date"><span style="font-size: 0.85rem; color: #888;">' + new Date(r.created_at).toLocaleString() + '</span></td>' +
'<td data-label="Actions">' + actionHtml + '</td>';
tbody.appendChild(tr);
});
// Pagination
pag.innerHTML = '';
if (data.pages > 1) {
if (data.page > 1) {
pag.innerHTML += '<button class="btn-modern" style="margin-right: 5px;" onclick="window.loadReports(' + (data.page - 1) + ')">Prev</button> ';
}
pag.innerHTML += '<span style="font-size: 0.85rem; color: #888; margin: 0 10px;">Page ' + data.page + ' of ' + data.pages + '</span>';
if (data.page < data.pages) {
pag.innerHTML += '<button class="btn-modern" style="margin-left: 5px;" onclick="window.loadReports(' + (data.page + 1) + ')">Next</button>';
}
}
} else {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Error: ' + data.msg + '</td></tr>';
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-danger">Network Error</td></tr>';
}
};
window.resolveReport = async function(id, action) {
if (!confirm('Mark report #' + id + ' as ' + action + '?')) return;
try {
const params = new URLSearchParams();
params.append('action', action);
const res = await fetch('/api/v2/mod/reports/' + id + '/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
window.loadReports(window.currentPage);
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
window.NotificationSystemInstance.pollDebounced();
}
} else {
alert('Error: ' + data.msg);
}
} catch (e) {
alert('Network error');
}
};
window.expandItem = function(e, id) {
e.preventDefault();
const tr = e.target.closest('tr');
// Toggle logic: If the next row is an expanded row, remove it and return.
if (tr.nextElementSibling && tr.nextElementSibling.classList.contains('expanded-report')) {
tr.nextElementSibling.remove();
return;
}
// Lookup the report locally
const r = window.currentReports.find(x => x.id === id);
if (!r) return;
// Checking if the moderator is also a superadmin for ban abilities
const isAdmin = window.f0ckSession && window.f0ckSession.admin;
// Build the Expansion Row
const expTr = document.createElement('tr');
expTr.className = 'expanded-report';
const isComment = !!r.comment_id;
const isItem = !!r.resolved_item_id && r.resolved_item_dest;
let previewHtml = '';
// Only show media preview for direct Item reports
if (isItem && !isComment) {
const mime = r.resolved_item_mime || '';
const src = '/b/' + r.resolved_item_dest;
const baseStyle = 'max-height: 250px; border: 1px solid #333; border-radius: 4px;';
if (mime === 'video/youtube') {
const ytId = r.resolved_item_dest.replace('yt:', '');
previewHtml = '<div><iframe width="444" height="250" src="https://www.youtube.com/embed/' + ytId + '" frameborder="0" allowfullscreen style="' + baseStyle + '"></iframe></div>';
} else if (mime === 'application/pdf') {
previewHtml = '<div><iframe src="' + src + '#toolbar=0" style="' + baseStyle + ' width: 444px; height: 250px;" frameborder="0" allowfullscreen></iframe></div>';
} else if (mime.startsWith('image/')) {
previewHtml = '<div><img src="' + src + '" style="' + baseStyle + ' background: #000;"></div>';
} else if (mime.startsWith('audio/')) {
previewHtml = '<div><audio src="' + src + '" controls style="' + baseStyle + '"></audio></div>';
} else {
previewHtml = '<div><video src="' + src + '" controls loop style="' + baseStyle + ' background: #000;"></video></div>';
}
}
if (isComment) {
let escapedContent = (r.comment_body || '[Deleted or Empty]')
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// Handle Emojis
if (window.emojiMap) {
escapedContent = escapedContent.replace(/:([a-z0-9_]+):/g, function(match, code) {
var url = window.emojiMap.get(code.toLowerCase());
if (url) {
return '<img src="' + url + '" style="height:24px;vertical-align:middle;" alt="' + code + '" title=":' + code + ':">';
}
return match;
});
}
previewHtml += '<div style="background: rgba(0,0,0,0.5); padding: 15px; border: 1px solid #444; color: #eee; font-family: monospace; max-height: 250px; overflow-y: auto; white-space: pre-wrap; font-size: 0.9rem;">' +
'<strong>Reported Comment:</strong><br><br>' + escapedContent +
'</div>';
}
let buttonsHtml = '';
// Delete Item and Make Unavailable buttons for direct Item reports
if (isItem && !isComment) {
buttonsHtml += '<button class="btn btn-danger" onclick="window.adminDeleteItem(' + r.resolved_item_id + ')">Delete Item</button>';
const isUnav = r.resolved_item_visibility === 3;
buttonsHtml += ' <button class="btn ' + (isUnav ? 'btn-success' : 'btn-warning') + '" onclick="window.modToggleUnavailable(' + r.resolved_item_id + ', ' + (r.resolved_item_visibility || 0) + ')">' + (isUnav ? 'Make Available' : 'Make Unavailable (451)') + '</button>';
}
if (isComment) {
buttonsHtml += '<button class="btn btn-danger" onclick="window.adminDeleteComment(' + r.comment_id + ')">Delete Comment</button>';
if (r.resolved_item_id) {
buttonsHtml += '<a href="/' + r.resolved_item_id + '" class="btn btn-info" style="text-decoration: none; color: white;" target="_blank">View Video</a>';
}
}
// Punitive actions target the reported party
if (r.reported_user_id) {
// Only show punitive actions if viewer is admin OR reported user is NOT an admin
if (isAdmin || !r.reported_user_is_admin) {
const warnLabel = isItem ? 'Warn Uploader' : (isComment ? 'Warn Commenter' : 'Warn User');
buttonsHtml += '<button class="btn btn-warning" onclick="window.modWarnUser(' + r.reported_user_id + ')">' + warnLabel + ' (' + r.reported_user_name + ')</button>';
const banLabel = isItem ? 'Ban Uploader' : (isComment ? 'Ban Commenter' : 'Ban User');
buttonsHtml += '<button class="btn btn-danger" onclick="window.adminBanUser(' + r.reported_user_id + ')">' + banLabel + ' (' + r.reported_user_name + ')</button>';
} else {
buttonsHtml += '<span style="color:var(--gray); font-style: italic; opacity:0.8; margin-left: 10px;">(Admin Protection Active)</span>';
}
} else {
buttonsHtml += '<span style="color:var(--gray); opacity:0.6;">(Anonymous/Unknown Source)</span>';
}
expTr.innerHTML =
'<td colspan="6" style="background: rgba(255,255,255,0.01); border-left: 4px solid var(--accent); padding: 30px; box-shadow: inset 0 0 20px rgba(0,0,0,0.4);">' +
'<div style="display: flex; gap: 40px; align-items: center;">' +
previewHtml +
'<div style="flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px;">' +
'<div style="font-weight: bold; opacity: 1; text-transform: uppercase; font-size: 0.8rem; letter-spacing: 1.5px; margin-bottom: 5px;">Moderation Action:</div>' +
'<div style="display: flex; gap: 12px; flex-wrap: wrap; justify-content: center;">' +
buttonsHtml +
(r.reporter_id && r.reporter_name ? '<button class="btn btn-secondary" onclick="window.modWarnUser(' + r.reporter_id + ')">Warn Reporter (' + r.reporter_name + ')</button>' : (r.reporter_ip ? '<span style="color: #888; font-size: 0.85rem;">Reporter: Guest (' + r.reporter_ip + ')</span>' : '')) +
'</div>' +
'</div>' +
'</div>' +
'</td>';
tr.insertAdjacentElement('afterend', expTr);
};
window.adminDeleteComment = function(id) {
window.ModAction.confirm('Delete Comment #' + id, 'Are you sure you want to delete this comment? This action is permanent.', async (reason) => {
const params = new URLSearchParams();
params.append('reason', reason);
const res = await fetch('/api/comments/' + id + '/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('comment deleted', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
window.adminDeleteItem = function(id) {
window.ModAction.confirm('Delete Item #' + id, 'Are you sure you want to delete this item? This action is permanent.', async (reason) => {
const params = new URLSearchParams();
params.append('postid', id);
params.append('reason', reason);
const res = await fetch('/api/v2/admin/deletepost', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('item deleted', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
window.modToggleUnavailable = function(id, currentVis) {
const willBeUnavailable = currentVis !== 3;
const targetVis = willBeUnavailable ? 3 : 0;
const actionText = willBeUnavailable ? 'Make Unavailable (serves HTTP 451 to non-logged in visitors)' : 'Make Available (Public)';
window.ModAction.confirm('Item Visibility', actionText + ' for item #' + id + '?', async () => {
const params = new URLSearchParams();
params.append('postid', id);
params.append('id', id);
params.append('visibility', targetVis);
const res = await fetch('/api/v2/item/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash(willBeUnavailable ? 'Item marked unavailable (451)' : 'Item restored to public', 'success');
const item = window.currentReports.find(x => x.resolved_item_id === id);
if (item) item.resolved_item_visibility = targetVis;
window.loadReports(window.currentPage);
} else {
throw new Error(data.msg || 'Failed to update visibility');
}
});
};
window.modWarnUser = function(userId) {
window.ModAction.confirm('Warn User ID ' + userId, '', async (reason) => {
const params = new URLSearchParams();
params.append('user_id', userId);
params.append('reason', reason);
const res = await fetch('/api/v2/mod/warnings/issue', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('user has been warned', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
window.adminBanUser = function(userId) {
const isAdmin = window.f0ckSession && window.f0ckSession.admin;
const promptHtml =
'<p>This will restrict the user from accessing their account and performing most actions.</p>' +
'<div style="margin-top:10px;">' +
'<label>Ban Duration:</label>' +
'<select id="ban-duration-select" class="form-control" style="margin-top:5px;">' +
(isAdmin ? '<option value="permanent">Permanent</option>' : '') +
'<option value="1">1 Hour</option>' +
'<option value="6">6 Hours</option>' +
'<option value="24">24 Hours (1 Day)</option>' +
(!isAdmin ? '<option value="48">48 Hours (2 Days)</option>' : '') +
(isAdmin ? '<option value="168">168 Hours (1 Week)</option>' : '') +
(isAdmin ? '<option value="720">720 Hours (1 Month)</option>' : '') +
'</select>' +
'</div>';
window.ModAction.confirm('Ban User ID ' + userId, promptHtml, async (reason) => {
const duration = document.getElementById('ban-duration-select').value;
const params = new URLSearchParams();
params.append('user_id', userId);
params.append('reason', reason);
params.append('duration', duration);
const res = await fetch('/api/v2/admin/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash('User banned cleanly.', 'success');
} else {
throw new Error(data.msg || 'Unknown error');
}
});
};
(function() {
const filter = document.getElementById('report-status-filter');
if (filter) {
// Prevent stacking, although safe here
filter.onchange = () => window.loadReports(1);
}
window.loadReports(1);
})();
</script>
<script src="/s/js/mod-reports.js?v=1789785562"></script>
</div>
</div>
</div>
@include(snippets/footer)
+4
View File
@@ -7,8 +7,12 @@
<button id="mark-all-read-page" class="btn-small">{{ t('notifications.mark_all_read') }}</button>
</div>
<div class="notif-page-tabs">
@if(enable_comments)
<button class="notif-page-tab @if(activeTab === 'user') active @endif" data-tab="user">{{ t('nav.notif_tab_user') }}</button>
<button class="notif-page-tab @if(activeTab === 'system') active @endif" data-tab="system">{{ t('nav.notif_tab_system') }}</button>
@else
<button class="notif-page-tab active" data-tab="system">{{ t('nav.notif_tab_system') }}</button>
@endif
</div>
<div id="notifications-container" class="posts notifications-list-full" data-page="{{ pagination.page }}" data-tab="{{ activeTab }}">
@include(snippets/notifications-list)
+8
View File
@@ -1077,12 +1077,20 @@
<div id="scroller-notif-dropdown" class="notif-dropdown" style="position:fixed; z-index:99999; display:none;">
<div class="notif-header">
<div class="notif-tabs">
@if(enable_comments)
<button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-user" style="display:none">0</span></button>
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button>
@else
<button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="scroller-notif-tab-badge-system" style="display:none">0</span></button>
@endif
</div>
<button id="scroller-mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div>
@if(enable_comments)
<div class="notif-list" id="scroller-notif-list" data-active-tab="user">
@else
<div class="notif-list" id="scroller-notif-list" data-active-tab="system">
@endif
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
</div>
<div class="notif-footer">
+92 -28
View File
@@ -356,6 +356,7 @@
</fieldset>
<!-- 4. Comments & Discussion -->
@if(enable_comments)
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-comments"></i> Comments & Discussion</legend>
@@ -390,6 +391,7 @@
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
</div>
</fieldset>
@endif
<!-- 5. Content Preferences & Blurring -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
@@ -528,30 +530,20 @@
</div>
@if(session.is_anon)
<!-- ═══════════════════════════════ ANONYMOUS SSH IDENTITY ═══════════════════════════════ -->
<h2 id="anon-ssh"><i class="fa-solid fa-key"></i> Anonymous SSH Identity</h2>
<!-- ═══════════════════════════════ ANONYMOUS PASSKEY IDENTITY ═══════════════════════════════ -->
<h2 id="anon-passkey"><i class="fa-solid fa-fingerprint"></i> Anonymous Passkey Identity</h2>
<div class="account-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p style="color: var(--text-muted); margin-bottom: 16px;">
You are currently browsing anonymously using an OpenSSH Ed25519 keypair. Your favorites and comments are tied to this cryptographic identity without requiring a username, password, or email.
You are browsing anonymously. Your identity is protected by a <strong>passkey</strong> stored in your OS or password manager. No private key ever touches this browser's storage.
</p>
<div style="margin-bottom: 16px;">
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">OpenSSH SHA256 Fingerprint</label>
<code id="settings-anon-fp" style="background: rgba(0,0,0,0.3); padding: 6px 12px; border-radius: 4px; display: inline-block; color: #5bc0be; font-family: monospace;">{{ session.fingerprint || 'Loading...' }}</code>
</div>
<div style="margin-bottom: 20px;">
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">OpenSSH Public Key</label>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<input type="text" id="settings-anon-pubkey" readonly class="input" style="flex: 1; min-width: 250px; font-family: monospace; font-size: 0.85em;" value="" placeholder="Loading public key...">
<button type="button" class="button" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.copyPublicKey();"><i class="fa-solid fa-copy"></i> Copy</button>
</div>
<label style="font-weight: bold; color: var(--text-muted); display: block; margin-bottom: 6px;">Short Fingerprint</label>
<code id="settings-anon-fp" style="background: rgba(0,0,0,0.3); padding: 6px 12px; border-radius: 4px; display: inline-block; color: #5bc0be; font-family: monospace;">{{ session.fingerprint ? session.fingerprint.slice(7, 15) : 'Loading...' }}</code>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 20px;">
<button type="button" class="button button-primary" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPrivateKey();"><i class="fa-solid fa-download"></i> Download id_ed25519</button>
<button type="button" class="button" onclick="if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPublicKey();"><i class="fa-solid fa-download"></i> Download id_ed25519.pub</button>
<button type="button" class="button" onclick="const m = document.getElementById('anon-ssh-modal'); if(m) m.style.display='flex';"><i class="fa-solid fa-key"></i> Import / Supply Key</button>
<button type="button" class="button button-primary" onclick="if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if(m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Manage Passkey</button>
</div>
<div style="padding: 12px 16px; background: rgba(255,255,255,0.03); border-radius: 6px; border: 1px solid rgba(255,255,255,0.08); font-size: 0.88em; color: var(--text-muted);">
@@ -559,20 +551,92 @@
<strong>Want full features?</strong> Registered accounts can upload items, customize avatars, create API keys, and invite friends. You can <a href="#" onclick="event.preventDefault(); const m=document.getElementById('register-modal'); if(m) m.style.display='flex';" style="color: var(--accent); text-decoration: underline;">register an account</a> at any time.
</div>
</div>
@endif
@if(!session.is_anon && session)
<!-- ═══════════════════════════════ PASSKEYS ═══════════════════════════════ -->
<h2 id="passkeys"><i class="fa-solid fa-fingerprint"></i> Passkeys</h2>
<div class="account-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p style="color: var(--text-muted); margin-bottom: 16px;">
Passkeys let you sign in without a password using your OS, Bitwarden, or any compatible password manager. They are phishing-resistant and device-bound.
</p>
<div id="settings-passkey-list" style="margin-bottom: 16px;">
<p style="color: var(--text-muted); font-size: 0.9em;">Loading...</p>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="button" class="button button-primary" id="settings-passkey-add-btn" onclick="settingsPasskeyAdd()">
<i class="fa-solid fa-plus"></i> Add a passkey
</button>
</div>
<div id="settings-passkey-status" style="margin-top: 10px; font-size: 0.88em; display: none;"></div>
</div>
<script>
(function() {
const syncKey = () => {
if (window.f0ckAnonSSH) {
const pk = document.getElementById('settings-anon-pubkey');
if (pk && window.f0ckAnonSSH.pubkey) pk.value = window.f0ckAnonSSH.pubkey;
const fp = document.getElementById('settings-anon-fp');
if (fp && window.f0ckAnonSSH.fingerprint) fp.textContent = window.f0ckAnonSSH.fingerprint;
(function() {
async function loadPasskeys() {
const container = document.getElementById('settings-passkey-list');
if (!container) return;
try {
const res = await fetch('/api/v2/settings/passkeys');
const data = await res.json();
const passkeys = data.passkeys || [];
if (passkeys.length === 0) {
container.innerHTML = '<p style="color: var(--text-muted); font-size: 0.9em;">No passkeys registered yet.</p>';
return;
}
};
syncKey();
window.addEventListener('f0ck:anon_session_ready', syncKey);
document.addEventListener('DOMContentLoaded', syncKey);
})();
container.innerHTML = passkeys.map(function(pk) {
return '<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: rgba(0,0,0,0.25); border-radius: 5px; border: 1px solid rgba(255,255,255,0.07); margin-bottom: 8px;">' +
'<i class="fa-solid fa-key" style="color: var(--accent); font-size: 1.1em; flex-shrink: 0;"></i>' +
'<div style="flex: 1; min-width: 0;">' +
'<div style="font-weight: 600; font-size: 0.9em;">' + escHtml(pk.name || 'Passkey') + '</div>' +
'<div style="font-size: 0.78em; color: var(--text-muted);">Added ' + new Date(pk.created_at).toLocaleDateString() + ' &nbsp;·&nbsp; Last used ' + new Date(pk.last_used).toLocaleDateString() + '</div>' +
'</div>' +
'<button class="button button-danger" style="font-size: 0.8em; padding: 4px 10px;" onclick="settingsPasskeyDelete(' + JSON.stringify(pk.credential_id) + ')"><i class="fa-solid fa-trash"></i></button>' +
'</div>';
}).join('');
} catch (e) {
container.innerHTML = '<p style="color: #e06c75; font-size: 0.9em;">Failed to load passkeys.</p>';
}
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
window.settingsPasskeyAdd = async function() {
var status = document.getElementById('settings-passkey-status');
var btn = document.getElementById('settings-passkey-add-btn');
if (!window.f0ckPasskeyManager) { alert('Passkey manager not loaded.'); return; }
if (!window.PublicKeyCredential) { alert('Passkeys are not supported in this browser.'); return; }
var name = prompt('Name this passkey (e.g. "Bitwarden", "iPhone"):', 'Passkey') || 'Passkey';
if (btn) btn.disabled = true;
if (status) status.style.display = 'none';
try {
await window.f0ckPasskeyManager.addPasskey(name);
if (status) { status.style.display = 'block'; status.style.color = '#98c379'; status.textContent = 'Passkey added!'; }
await loadPasskeys();
} catch (err) {
if (err.name !== 'NotAllowedError') {
if (status) { status.style.display = 'block'; status.style.color = '#e06c75'; status.textContent = 'Error: ' + err.message; }
}
} finally {
if (btn) btn.disabled = false;
}
};
window.settingsPasskeyDelete = async function(credentialId) {
if (!confirm('Remove this passkey? You will no longer be able to use it to sign in.')) return;
var status = document.getElementById('settings-passkey-status');
try {
await window.f0ckPasskeyManager.deletePasskey(credentialId);
if (status) { status.style.display = 'block'; status.style.color = '#98c379'; status.textContent = 'Passkey removed.'; }
await loadPasskeys();
} catch (err) {
if (status) { status.style.display = 'block'; status.style.color = '#e06c75'; status.textContent = 'Error: ' + err.message; }
}
};
loadPasskeys();
})();
</script>
@endif
+31 -1
View File
@@ -70,10 +70,36 @@
<div id="report-modal" class="modal-overlay" style="display:none;">
<div class="modal-content" style="max-height: 90vh; overflow-y: auto;">
<h3>{{ t('report.title') }}</h3>
<p>Please describe why you want to report this f0ck.</p>
<p style="margin-bottom: 12px; color: var(--text-muted, #aaa); font-size: 0.9em;">Select one or more reasons and optionally add details below.</p>
<input type="hidden" id="report-item-id">
<input type="hidden" id="report-comment-id">
<input type="hidden" id="report-user-id">
<div id="report-categories" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px;">
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="wrong_rating" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Wrong Rating</strong> <span style="color: var(--text-muted, #aaa);">— Content is marked SFW but should be NSFW, or vice versa.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="spam" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Spam</strong> <span style="color: var(--text-muted, #aaa);">— Repeated, unwanted, or promotional content.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="duplicate" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Duplicate</strong> <span style="color: var(--text-muted, #aaa);">— This content has already been posted before.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="copyright" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Copyright</strong> <span style="color: var(--text-muted, #aaa);">— Content infringes on intellectual property rights.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="illegal" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff); color: var(--danger, #ff4444);">Illegal Content</strong> <span style="color: var(--text-muted, #aaa);">— Content that may violate laws or platform rules.</span></span>
</label>
<label class="report-category-option" style="display: flex; align-items: center; gap: 10px; padding: 9px 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03)); transition: border-color 0.15s, background 0.15s; user-select: none;">
<input type="checkbox" name="report_category" value="other" class="report-cat-check" style="accent-color: var(--accent, #e65c00); width: 16px; height: 16px; cursor: pointer; flex-shrink: 0;">
<span style="font-size: 0.9em;"><strong style="color: var(--text-color, #fff);">Other</strong> <span style="color: var(--text-muted, #aaa);">— Something else not listed above.</span></span>
</label>
</div>
<textarea id="report-reason" class="mod-reason" placeholder="{{ t('report.placeholder') }}"></textarea>
@if(recaptcha_enabled && !session)
<div id="modal-report-recaptcha" data-sitekey="{{ recaptcha_site_key }}" style="margin: 10px 0; display: flex; justify-content: center;"></div>
@@ -122,9 +148,11 @@
<div class="global-sidebar-right">
<div class="sidebar-activity">
<div class="sidebar-tabs">
@if(enable_comments)
<button type="button" class="sidebar-tab active" data-tab="comments" title="{{ t('sidebar.recent_comments') }}" aria-label="{{ t('sidebar.recent_comments') }}">
<i class="fa-solid fa-comments"></i>
</button>
@endif
<button type="button" class="sidebar-tab" data-tab="recommendations" title="{{ t('sidebar.recommendations') }}" aria-label="{{ t('sidebar.recommendations') }}">
<i class="fa-solid fa-compass"></i>
</button>
@@ -138,12 +166,14 @@
<i class="fa-solid fa-sliders"></i>
</button>
</div>
@if(enable_comments)
<div id="sidebar-activity-container" class="sidebar-comments-list sidebar-tab-content active" data-tab-content="comments">
<div class="sidebar-loading-state">
<i class="fa-solid fa-circle-notch fa-spin"></i>
<span>{{ t('sidebar.loading_activity') }}</span>
</div>
</div>
@endif
<div id="sidebar-recommendations-container" class="sidebar-recommendations-list sidebar-tab-content" data-tab-content="recommendations" style="display: none;">
<div class="sidebar-loading-state">
<i class="fa-solid fa-circle-notch fa-spin"></i>
+3 -1
View File
@@ -67,11 +67,13 @@
@endif
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
@endif
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }};</script>
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDefaultLayout = "{{ default_layout }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif; window.f0ckServerAudioTuner = {{ audio_tuner_json || 'null' }}; window.f0ckEnableComments = {{ enable_comments ? 'true' : 'false' }};</script>
@if(!private_society || session)
@if(enable_comments)
<script src="/s/js/marked.min.js" defer></script>
<script src="/s/js/comments.js?v={{ ts }}" defer></script>
@endif
@endif
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
@if(typeof item !== 'undefined')
+7 -2
View File
@@ -31,6 +31,11 @@
<div class="album-counter-pill" title="Subf0cks">
<i class="fa-solid fa-layer-group"></i>
<span class="album-current-idx">1</span> / <span class="album-total-count">{{ item.album.length }}</span>
@if(can_manage_item)
<button type="button" class="album-sub-delete-btn" id="a_delete_sub" title="Delete current slide from album" aria-label="Delete slide" style="background: none; border: none; color: #ff6b6b; margin-left: 8px; cursor: pointer; pointer-events: auto; padding: 0 2px;">
<i class="fa-solid fa-trash-can"></i>
</button>
@endif
</div>
<button type="button" class="album-btn album-btn-next" title="{{ t('album.next') || 'Next' }}" aria-label="Next">
<i class="fa-solid fa-chevron-right"></i>
@@ -63,7 +68,7 @@
</div>
@elseif(item.mime.startsWith("video"))
<div class="embed-responsive embed-responsive-16by9">
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}" preload="auto" data-size="{{ item.size }}" loop playsinline></video>
<video id="my-video" class="embed-responsive-item" width="640" height="360" src="{{ item.dest }}" preload="auto" data-size="{{ item.size }}" loop playsinline @if(!session || session.disable_autoplay !== true) autoplay @endif></video>
</div>
@elseif(item.mime.startsWith("audio"))
<div class="embed-responsive embed-responsive-16by9" style="background: #000;">
@@ -79,7 +84,7 @@
<i class="fa-solid fa-music"></i>
</div>
</div>
<audio id="my-video" class="embed-responsive-item" preload="auto" loop crossorigin="anonymous" src="{{ item.dest }}" data-setup="{}" data-size="{{ item.size }}" @if(item.coverart)poster="{{ item.coverart }}"@endif type="{{ item.mime }}"></audio>
<audio id="my-video" class="embed-responsive-item" preload="auto" loop crossorigin="anonymous" src="{{ item.dest }}" data-setup="{}" data-size="{{ item.size }}" @if(item.coverart)poster="{{ item.coverart }}"@endif type="{{ item.mime }}" @if(!session || session.disable_autoplay !== true) autoplay @endif></audio>
<img id="f0ck-audio-cover" @if(item.coverart)src="{{ item.coverart }}"@endif style="display: none;">
</div>
@elseif(item.mime.startsWith("image"))
+271 -164
View File
@@ -66,7 +66,7 @@
@endif
@endif
@if(enable_anonymous_access && session.is_anon)
<a href="#" id="nav-user-anon-identity-btn" onclick="event.preventDefault(); if(window.f0ckAnonSSH) { window.f0ckAnonSSH.openModal(); } else { const m = document.getElementById('anon-ssh-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-key"></i> Key Management</a>
<a href="#" id="nav-user-anon-identity-btn" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Passkey Identity</a>
@endif
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a>
<a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
@@ -84,20 +84,30 @@
<div id="notif-dropdown" class="notif-dropdown">
<div class="notif-header">
<div class="notif-tabs">
@if(enable_comments)
<button class="notif-tab active" data-tab="user">{{ t('nav.notif_tab_user') }} <span class="notif-tab-badge" id="notif-tab-badge-user" style="display:none">0</span></button>
<button class="notif-tab" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button>
@else
<button class="notif-tab active" data-tab="system">{{ t('nav.notif_tab_system') }} <span class="notif-tab-badge" id="notif-tab-badge-system" style="display:none">0</span></button>
@endif
</div>
<button id="mark-all-read" title="{{ t('nav.mark_all_read') }}"><i class="fa-solid fa-check-double"></i></button>
</div>
@if(enable_comments)
<div class="notif-list" data-active-tab="user">
@else
<div class="notif-list" data-active-tab="system">
@endif
<div class="notif-empty">{{ t('nav.no_notifications') }}</div>
</div>
<div class="notif-footer">
<a href="/notifications" class="view-all-notifs">{{ t('nav.view_all_notifications') }}</a>
</div>
@if(enable_comments)
<div class="submanage">
<a href="/subscriptions">{{ t('nav.manage_subscriptions') }}</a>
</div>
@endif
</div>
</div>
@@ -286,7 +296,7 @@
@endif
@if(enable_anonymous_access)
<a href="#" id="nav-anon-identity-btn" @if(!session || !session.is_anon) style="display:none;" @endif onclick="event.preventDefault(); if(window.f0ckAnonSSH) { window.f0ckAnonSSH.openModal(); } else { const m = document.getElementById('anon-ssh-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-key"></i> Key Management</a>
<a href="#" id="nav-anon-identity-btn" @if(!session || !session.is_anon) style="display:none;" @endif onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }"><i class="fa-solid fa-fingerprint"></i> Passkey Identity</a>
<a href="/settings" id="nav-anon-settings-btn" @if(!session || !session.is_anon) style="display:none;" @endif><i class="fa-solid fa-gear"></i> Settings</a>
@endif
<a href="#" id="nav-login-btn">{{ t('nav.login') }}</a>
@@ -318,40 +328,141 @@
<script>
// Passkey login for registered users — fires only on explicit button click
async function loginWithPasskey() {
const btn = document.getElementById('modal-login-passkey-btn');
const errEl = document.getElementById('modal-passkey-error');
if (errEl) errEl.style.display = 'none';
if (!window.PublicKeyCredential) {
if (errEl) { errEl.style.display = ''; errEl.textContent = 'Passkeys are not supported in this browser.'; }
return;
}
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Waiting for passkey...'; }
function b64urlToArr(s) { return Uint8Array.from(atob(s.split('-').join('+').split('_').join('/')), c=>c.charCodeAt(0)); }
function arrToB64url(buf) { return btoa(String.fromCharCode(...new Uint8Array(buf))).split('+').join('-').split('/').join('_').replace(/=+$/,''); }
try {
const beginRes = await fetch('/api/v2/settings/passkeys/login/begin', { method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}' });
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
const opts = beginData.options;
const cred = await navigator.credentials.get({ publicKey: {
challenge: b64urlToArr(opts.challenge),
rpId: opts.rpId,
allowCredentials: (opts.allowCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
userVerification: opts.userVerification || 'preferred',
timeout: opts.timeout || 60000
}});
const finishRes = await fetch('/api/v2/settings/passkeys/login/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: opts.challenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
authenticatorData: arrToB64url(cred.response.authenticatorData),
signature: arrToB64url(cred.response.signature)
})
});
const finishData = await finishRes.json();
if (!finishData.success) throw new Error(finishData.msg || 'Authentication failed');
window.location.reload();
} catch (e) {
if (e && e.name !== 'NotAllowedError' && errEl) {
errEl.style.display = '';
errEl.textContent = e.message || 'Passkey login failed.';
}
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Login with passkey'; }
}
}
</script>
<div id="login-modal" style="display: none;">
<div class="login-modal-content">
<button id="login-modal-close">&times;</button>
<!-- Login View -->
<div id="modal-login-view">
<form class="login-form" method="post" action="/login" novalidate>
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.login_title') }}</h2>
<input type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" autocomplete="off" required />
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder_min') }}" autocomplete="off" required />
<p style="text-align: left; font-size: 0.9em; margin: 0;"><input type="checkbox" id="kmsi-modal" name="kmsi" />
<label for="kmsi-modal">{{ t('auth.stay_signed_in') }}</label>
<h2 style="text-align: center; margin-bottom: 18px;">{{ t('auth.login_title') }}</h2>
<!-- Tab bar -->
<div id="login-tabs" style="display: flex; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 20px;">
<button type="button" id="login-tab-creds" onclick="switchLoginTab('creds')"
style="flex: 1; background: none; border: none; border-bottom: 2px solid var(--accent, #0096ff); color: var(--text-color, #fff); padding: 8px 0; font-size: 0.9em; cursor: pointer; font-weight: 600; transition: color 0.15s;">
<i class="fa-solid fa-key" style="margin-right:5px;"></i>Credentials
</button>
<button type="button" id="login-tab-passkey" onclick="switchLoginTab('passkey')"
style="flex: 1; background: none; border: none; border-bottom: 2px solid transparent; color: var(--text-muted, #888); padding: 8px 0; font-size: 0.9em; cursor: pointer; font-weight: 600; transition: color 0.15s;">
<i class="fa-solid fa-fingerprint" style="margin-right:5px;"></i>Passkey
</button>
</div>
<!-- Tab: Credentials -->
<div id="login-panel-creds">
<form class="login-form" method="post" action="/login" novalidate style="padding: 0; background: none; box-shadow: none; border: none;">
<input type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" autocomplete="off" required />
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder_min') }}" autocomplete="off" required />
<p style="text-align: left; font-size: 0.9em; margin: 0;"><input type="checkbox" id="kmsi-modal" name="kmsi" />
<label for="kmsi-modal">{{ t('auth.stay_signed_in') }}</label>
</p>
<button type="submit">{{ t('auth.login_title') }}</button>
@if(smtp_enabled)
<div style="text-align: center; margin-top: 10px;">
<a href="#" id="modal-forgot-btn" style="font-size: 0.85em; color: var(--accent); text-decoration: underline;">{{ t('auth.forgot_password') }}</a>
</div>
@endif
@if(registration_open || private_society)
<p style="text-align: center; font-size: 0.9em; margin-top: 15px; color: #888;">
{{ t('auth.no_account') }} <a href="#" id="login-to-register" style="color: var(--accent); text-decoration: underline;">{{ t('auth.register_now') }}</a>
</p>
@endif
</form>
</div>
<!-- Tab: Passkey -->
<div id="login-panel-passkey" style="display: none; text-align: center; padding: 8px 0 4px;">
<div style="font-size: 2.6em; margin-bottom: 14px;">🔑</div>
<p style="font-size: 0.88em; color: var(--text-muted, #aaa); margin: 0 0 20px; line-height: 1.55;">
Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password.
</p>
<button type="submit">{{ t('auth.login_title') }}</button>
@if(smtp_enabled)
<div style="text-align: center; margin-top: 10px;">
<a href="#" id="modal-forgot-btn" style="font-size: 0.85em; color: var(--accent); text-decoration: underline;">{{ t('auth.forgot_password') }}</a>
</div>
@endif
@if(registration_open || private_society)
<p style="text-align: center; font-size: 0.9em; margin-top: 15px; color: #888;">
{{ t('auth.no_account') }} <a href="#" id="login-to-register" style="color: var(--accent); text-decoration: underline;">{{ t('auth.register_now') }}</a>
</p>
@endif
@if(enable_anonymous_access)
<div style="margin-top: 15px; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 12px; text-align: center;">
<button type="button" id="modal-login-as-anon-btn" class="btn btn-sm" style="background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.2); color: #ddd; border-radius: 4px; padding: 7px 14px; font-size: 0.88em; cursor: pointer; width: 100%; transition: background 0.2s;">
<i class="fa-solid fa-user-secret"></i> Login as anonymous
</button>
</div>
@endif
</form>
<button type="button" id="modal-login-passkey-btn"
style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 8px;"
onclick="loginWithPasskey()">
<i class="fa-solid fa-fingerprint"></i> Use my passkey
</button>
<div id="modal-passkey-error" style="display:none; color:#e06c75; font-size:0.82em; margin-top:6px;"></div>
</div>
@if(enable_anonymous_access)
<div style="margin-top: 16px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 12px; text-align: center;">
<button type="button" id="modal-login-as-anon-btn"
style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); color: #999; border-radius: 4px; padding: 7px 14px; font-size: 0.85em; cursor: pointer; width: 100%; transition: background 0.2s;"
onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }">
<i class="fa-solid fa-user-secret"></i> Login as anonymous
</button>
</div>
@endif
</div>
<script>
function switchLoginTab(tab) {
var isCreds = tab === 'creds';
document.getElementById('login-panel-creds').style.display = isCreds ? '' : 'none';
document.getElementById('login-panel-passkey').style.display = isCreds ? 'none' : '';
var tCreds = document.getElementById('login-tab-creds');
var tPk = document.getElementById('login-tab-passkey');
if (tCreds) { tCreds.style.borderBottomColor = isCreds ? 'var(--accent, #0096ff)' : 'transparent'; tCreds.style.color = isCreds ? 'var(--text-color, #fff)' : 'var(--text-muted, #888)'; }
if (tPk) { tPk.style.borderBottomColor = isCreds ? 'transparent' : 'var(--accent, #0096ff)'; tPk.style.color = isCreds ? 'var(--text-muted, #888)' : 'var(--text-color, #fff)'; }
}
</script>
@if(smtp_enabled)
<!-- Forgot Password View -->
<div id="modal-forgot-view" style="display: none;">
@@ -387,169 +498,165 @@
</div>
@if(enable_anonymous_access)
<!-- Anonymous OpenSSH Ed25519 Identity Modal -->
<div id="anon-ssh-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
<div class="login-modal-content" style="max-width: 540px; width: 92vw; max-height: 90vh; overflow-y: auto; text-align: left; padding: 25px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: var(--bg-primary, #111); box-shadow: 0 10px 40px rgba(0,0,0,0.8);">
<button id="anon-ssh-modal-close" style="position: absolute; top: 15px; right: 15px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.4em; cursor: pointer; line-height: 1;">&times;</button>
<!-- Anonymous Passkey Identity Modal -->
<div id="anon-passkey-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
<div class="login-modal-content" style="max-width: 500px; width: 92vw; max-height: 90vh; overflow-y: auto; text-align: left; padding: 25px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: var(--bg-primary, #111); box-shadow: 0 10px 40px rgba(0,0,0,0.8); position: relative;">
<button id="anon-passkey-modal-close" style="position: absolute; top: 15px; right: 15px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.4em; cursor: pointer; line-height: 1;">&times;</button>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;">
<i class="fa-solid fa-key" style="color: var(--accent, #0096ff); font-size: 1.3em;"></i>
<h3 style="margin: 0; font-size: 1.25em; color: var(--text-color, #fff);">Anonymous SSH Identity</h3>
<i class="fa-solid fa-fingerprint" style="color: var(--accent, #0096ff); font-size: 1.3em;"></i>
<h3 style="margin: 0; font-size: 1.25em; color: var(--text-color, #fff);">Anonymous Passkey Identity</h3>
</div>
<p style="margin: 0 0 15px 0; font-size: 0.85em; color: var(--text-muted, #aaa); line-height: 1.4;">
Your browser holds an <strong>OpenSSH Ed25519</strong> private key. Your comments and favorites belong to this key without needing a password.
<p style="margin: 0 0 18px 0; font-size: 0.85em; color: var(--text-muted, #aaa); line-height: 1.4;">
Your anonymous identity is protected by a <strong>passkey</strong> stored in your OS or password manager (e.g. Bitwarden).
No private key ever touches this browser's storage.
</p>
<!-- Tabs -->
<div style="display: flex; gap: 8px; margin-bottom: 15px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 8px;">
<button type="button" id="anon-tab-btn-identity" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 5px 12px;">My Identity</button>
<button type="button" id="anon-tab-btn-import" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 5px 12px;">Import / Supply Key</button>
</div>
<!-- Tab 1: Current Identity -->
<div id="anon-tab-identity">
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 4px;">Fingerprint</label>
<div style="display: flex; align-items: center; gap: 8px; background: rgba(0,0,0,0.4); padding: 8px 12px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.08);">
<code id="anon-ssh-fp-display" style="font-family: monospace; font-size: 0.88em; color: var(--accent, #00d2ff); word-break: break-all; flex: 1;">Generating...</code>
<button type="button" id="anon-copy-fp-btn" title="Copy Fingerprint" class="btn btn-sm" style="background: transparent; border: none; color: #aaa; cursor: pointer; padding: 4px;"><i class="fa-solid fa-copy"></i></button>
</div>
<!-- Identity info -->
<div style="margin-bottom: 14px; padding: 12px; background: rgba(0,0,0,0.35); border-radius: 6px; border: 1px solid rgba(255,255,255,0.07);">
<div style="margin-bottom: 8px;">
<label style="display: block; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 3px;">Short Fingerprint</label>
<code id="anon-pk-fp-display" style="font-family: monospace; font-size: 1em; color: var(--accent, #00d2ff);"></code>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 4px;">OpenSSH Public Key (<code>id_ed25519.pub</code>)</label>
<textarea id="anon-ssh-pub-display" readonly rows="2" style="width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.82em; background: rgba(0,0,0,0.4); color: #ddd; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; padding: 8px; resize: none;"></textarea>
</div>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<button type="button" id="anon-copy-pub-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-copy"></i> Copy Public Key</button>
<button type="button" id="anon-dl-priv-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-download"></i> Download id_ed25519</button>
<button type="button" id="anon-dl-pub-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-download"></i> Download id_ed25519.pub</button>
<div>
<label style="display: block; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 3px;">Credential ID (short)</label>
<code id="anon-pk-cred-display" style="font-family: monospace; font-size: 0.78em; color: var(--text-muted, #aaa); word-break: break-all;"></code>
</div>
</div>
<!-- Tab 2: Import Key -->
<div id="anon-tab-import" style="display: none;">
<p style="font-size: 0.85em; color: var(--text-muted, #aaa); margin-top: 0; margin-bottom: 10px;">
Paste your existing <code>id_ed25519</code> OpenSSH private key or raw 32-byte seed to restore your anonymous identity on this browser.
</p>
<textarea id="anon-import-key-input" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----" style="width: 100%; box-sizing: border-box; font-family: monospace; font-size: 0.82em; background: rgba(0,0,0,0.4); color: #ddd; border: 1px solid rgba(255,255,255,0.08); border-radius: 4px; padding: 8px; resize: vertical; margin-bottom: 10px;"></textarea>
<input type="file" id="anon-import-file-elem" style="display: none;" />
<div style="display: flex; gap: 8px; align-items: center;">
<button type="button" id="anon-upload-key-btn" class="btn btn-sm btn-secondary" style="font-size: 0.85em; padding: 6px 12px;"><i class="fa-solid fa-upload"></i> Upload File</button>
<button type="button" id="anon-submit-import-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 16px;"><i class="fa-solid fa-check"></i> Activate Key</button>
</div>
<div id="anon-import-status" style="margin-top: 10px; font-size: 0.85em; display: none;"></div>
<!-- Actions -->
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
<button type="button" id="anon-pk-add-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 14px;">
<i class="fa-solid fa-plus"></i> Add another passkey
</button>
</div>
<p style="margin: 14px 0 0; font-size: 0.8em; color: var(--text-muted, #888); line-height: 1.4;">
To use this identity on another device, simply sign in with your passkey manager (Bitwarden, iCloud Keychain, etc.) — it syncs automatically.
</p>
</div>
</div>
<script>
(function(){
// Tab switching
const tabBtnId = document.getElementById('anon-tab-btn-identity');
const tabBtnImp = document.getElementById('anon-tab-btn-import');
const tabId = document.getElementById('anon-tab-identity');
const tabImp = document.getElementById('anon-tab-import');
if(tabBtnId && tabBtnImp && tabId && tabImp){
tabBtnId.addEventListener('click', function(){
tabId.style.display = 'block';
tabImp.style.display = 'none';
tabBtnId.classList.remove('btn-secondary'); tabBtnId.classList.add('btn-primary');
tabBtnImp.classList.remove('btn-primary'); tabBtnImp.classList.add('btn-secondary');
});
tabBtnImp.addEventListener('click', function(){
tabId.style.display = 'none';
tabImp.style.display = 'block';
tabBtnImp.classList.remove('btn-secondary'); tabBtnImp.classList.add('btn-primary');
tabBtnId.classList.remove('btn-primary'); tabBtnId.classList.add('btn-secondary');
// Keep legacy modal ID working for any nav onclick handlers that reference anon-ssh-modal
var legacyAlias = document.getElementById('anon-ssh-modal');
if (!legacyAlias) {
// Create a minimal alias element that delegates to the passkey modal
var alias = document.createElement('div');
alias.id = 'anon-ssh-modal';
alias.style.display = 'none';
Object.defineProperty(alias.style, 'display', {
set: function(v) { if (v === 'flex' || v === 'block') { var m = document.getElementById('anon-passkey-modal'); if (m) m.style.display = 'flex'; } }
});
document.body.appendChild(alias);
}
// Copy buttons
const copyPubBtn = document.getElementById('anon-copy-pub-btn');
if(copyPubBtn){
copyPubBtn.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.copyPublicKey();
var modalClose = document.getElementById('anon-passkey-modal-close');
if (modalClose) {
modalClose.addEventListener('click', function() {
var m = document.getElementById('anon-passkey-modal');
if (m) m.style.display = 'none';
});
}
const copyFpBtn = document.getElementById('anon-copy-fp-btn');
if(copyFpBtn){
copyFpBtn.addEventListener('click', function(){
const fp = document.getElementById('anon-ssh-fp-display')?.textContent;
if(fp && navigator.clipboard){
navigator.clipboard.writeText(fp).then(function(){
if(typeof window.showToastNotification === 'function') window.showToastNotification('Fingerprint copied!');
});
}
});
var overlay = document.getElementById('anon-passkey-modal');
if (overlay) {
overlay.addEventListener('click', function(e) { if (e.target === overlay) overlay.style.display = 'none'; });
}
})();
</script>
@endif
// Download buttons
const dlPriv = document.getElementById('anon-dl-priv-btn');
if(dlPriv){
dlPriv.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPrivateKey();
});
}
const dlPub = document.getElementById('anon-dl-pub-btn');
if(dlPub){
dlPub.addEventListener('click', function(){
if(window.f0ckAnonSSH) window.f0ckAnonSSH.downloadPublicKey();
});
}
@if(enable_anonymous_access)
<!-- Anonymous Passkey Setup Modal — shown when user clicks "Login as anonymous" -->
<div id="anon-setup-modal" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.8); backdrop-filter: blur(4px); z-index: 99999; align-items: center; justify-content: center;">
<div style="max-width: 420px; width: 92vw; padding: 28px; border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; background: var(--bg-primary, #111); box-shadow: 0 12px 48px rgba(0,0,0,0.9); position: relative; text-align: center;">
<button id="anon-setup-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">&times;</button>
// File upload trigger
const uploadBtn = document.getElementById('anon-upload-key-btn');
const fileElem = document.getElementById('anon-import-file-elem');
const inputElem = document.getElementById('anon-import-key-input');
if(uploadBtn && fileElem){
uploadBtn.addEventListener('click', function(){ fileElem.click(); });
fileElem.addEventListener('change', function(){
if(fileElem.files && fileElem.files[0]){
const reader = new FileReader();
reader.onload = function(e){
if(inputElem) inputElem.value = e.target.result;
};
reader.readAsText(fileElem.files[0]);
}
});
}
<!-- New user view (no passkey yet) -->
<div id="anon-setup-new">
<div style="font-size: 2.4em; margin-bottom: 12px;">🔑</div>
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Create an anonymous passkey</h3>
<p style="margin: 0 0 18px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;">
Your browser will ask you to save a <strong>passkey</strong> — a secure credential stored in Bitwarden, iCloud Keychain, or your OS.
No account, no email, no password. Your comments and favorites are tied to this passkey.
</p>
<p style="margin: 0 0 20px; font-size: 0.82em; color: var(--text-muted, #888); line-height: 1.4;">
You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
</p>
<button type="button" id="anon-setup-create-btn" style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 10px;">
<i class="fa-solid fa-fingerprint"></i> Create my passkey
</button>
<div id="anon-setup-new-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div>
<button type="button" id="anon-setup-switch-existing" style="background: none; border: none; color: var(--text-muted, #888); font-size: 0.8em; cursor: pointer; text-decoration: underline; margin-top: 4px;">
Already have one? Use existing passkey
</button>
</div>
// Submit import
const submitImpBtn = document.getElementById('anon-submit-import-btn');
const statusEl = document.getElementById('anon-import-status');
if(submitImpBtn){
submitImpBtn.addEventListener('click', async function(){
const keyVal = inputElem?.value;
if(!keyVal || !keyVal.trim()){
if(statusEl){ statusEl.style.display = 'block'; statusEl.style.color = '#ff4444'; statusEl.textContent = 'Please paste a key or select a file'; }
return;
}
try {
submitImpBtn.disabled = true;
submitImpBtn.textContent = 'Importing...';
await window.f0ckAnonSSH.importKey(keyVal);
if(statusEl){
statusEl.style.display = 'block';
statusEl.style.color = '#00C851';
statusEl.textContent = 'Key activated successfully! Reloading...';
}
setTimeout(function(){ window.location.reload(); }, 800);
} catch(err){
submitImpBtn.disabled = false;
submitImpBtn.innerHTML = '<i class="fa-solid fa-check"></i> Activate Key';
if(statusEl){
statusEl.style.display = 'block';
statusEl.style.color = '#ff4444';
statusEl.textContent = 'Import error: ' + (err.message || err);
}
}
});
<!-- Returning user view (has passkey) -->
<div id="anon-setup-returning" style="display: none;">
<div style="font-size: 2.4em; margin-bottom: 12px;">👤</div>
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Use your anonymous passkey</h3>
<p style="margin: 0 0 20px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;">
Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user.
</p>
<button type="button" id="anon-setup-auth-btn" style="width: 100%; padding: 11px; background: var(--accent, #0096ff); color: #fff; border: none; border-radius: 5px; font-size: 0.95em; font-weight: 600; cursor: pointer; margin-bottom: 10px;">
<i class="fa-solid fa-fingerprint"></i> Use my passkey
</button>
<div id="anon-setup-ret-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div>
<button type="button" id="anon-setup-switch-new" style="background: none; border: none; color: var(--text-muted, #888); font-size: 0.8em; cursor: pointer; text-decoration: underline; margin-top: 4px;">
Create a new passkey instead
</button>
</div>
</div>
</div>
<script>
(function() {
function closeSetupModal() {
var m = document.getElementById('anon-setup-modal'); if (m) m.style.display = 'none';
}
var closeBtn = document.getElementById('anon-setup-modal-close');
if (closeBtn) closeBtn.addEventListener('click', closeSetupModal);
var overlay = document.getElementById('anon-setup-modal');
if (overlay) overlay.addEventListener('click', function(e) { if (e.target === overlay) closeSetupModal(); });
var switchToExisting = document.getElementById('anon-setup-switch-existing');
var switchToNew = document.getElementById('anon-setup-switch-new');
if (switchToExisting) switchToExisting.addEventListener('click', function() {
document.getElementById('anon-setup-new').style.display = 'none';
document.getElementById('anon-setup-returning').style.display = '';
});
if (switchToNew) switchToNew.addEventListener('click', function() {
document.getElementById('anon-setup-returning').style.display = 'none';
document.getElementById('anon-setup-new').style.display = '';
});
var createBtn = document.getElementById('anon-setup-create-btn');
if (createBtn) createBtn.addEventListener('click', async function() {
var errEl = document.getElementById('anon-setup-new-error');
if (errEl) errEl.style.display = 'none';
createBtn.disabled = true;
createBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving passkey...';
try {
await window.f0ckAnonPasskey.doRegister();
} catch(e) {
if (errEl && e && e.name !== 'NotAllowedError') { errEl.style.display = ''; errEl.textContent = e.message || 'Something went wrong.'; }
createBtn.disabled = false;
createBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Create my passkey';
}
});
var authBtn = document.getElementById('anon-setup-auth-btn');
if (authBtn) authBtn.addEventListener('click', async function() {
var errEl = document.getElementById('anon-setup-ret-error');
if (errEl) errEl.style.display = 'none';
authBtn.disabled = true;
authBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Waiting for passkey...';
try {
await window.f0ckAnonPasskey.doAuthenticate();
} catch(e) {
if (errEl && e && e.name !== 'NotAllowedError') { errEl.style.display = ''; errEl.textContent = e.message || 'Authentication failed.'; }
authBtn.disabled = false;
authBtn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Use my passkey';
}
});
})();
</script>
@endif
+2
View File
@@ -104,7 +104,9 @@
<div class="stat-joined" tooltip="{{ user.timestamp.timefull }}" data-iso="{{ user.timestamp.timefull }}">{{ t('profile.age_days', { n: user.age_days }) }}</div>
@if(!user.is_ghost)
@if(enable_comments)
<div class="stat-comments">{{ t('profile.stat_comments') }} <a href="/user/{!! user.user !!}/comments">{{ count.comments }}</a></div>
@endif
<div class="stat-tags">{{ t('profile.stat_tags') }} <a href="/user/{!! user.user !!}/tags">{{ count.tags }}</a></div>
@if(!user.is_ghost)
<div class="stat-halls">{{ t('profile.stat_halls') }} <a href="/user/{!! user.user !!}/halls">{{ count.halls }}</a></div>