alotta good shit
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS categories text[] DEFAULT '{}';
|
||||
+109
-9
@@ -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
@@ -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')) {
|
||||
|
||||
+442
-843
File diff suppressed because it is too large
Load Diff
+458
-36
@@ -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,10 +3602,26 @@ window.cancelAnimFrame = (function () {
|
||||
if (isAutoplayAllowed()) {
|
||||
const playPromise = videoEl.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(() => {
|
||||
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 {
|
||||
try { videoEl.pause(); } catch {}
|
||||
playerWrap.classList.add('v0ck_initial');
|
||||
@@ -3509,10 +3714,26 @@ window.cancelAnimFrame = (function () {
|
||||
if (isAutoplayAllowed()) {
|
||||
const playPromise = audioEl.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(() => {
|
||||
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 {
|
||||
try { audioEl.pause(); } catch {}
|
||||
playerWrap.classList.add('v0ck_initial');
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
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;">→ ' + 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);
|
||||
})();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+22
-2
@@ -815,14 +815,34 @@ class v0ck {
|
||||
// Attempt autoplay and show overlay if blocked
|
||||
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
|
||||
if (shouldAutoplay) {
|
||||
const playPromise = togglePlay();
|
||||
if (!video.paused) {
|
||||
player.classList.remove('v0ck_initial');
|
||||
} else {
|
||||
const playPromise = video.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise.catch(() => {
|
||||
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 {
|
||||
player.classList.add('v0ck_initial');
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
+343
-129
@@ -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,63 +39,19 @@ 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 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 = await security.isFingerprintBanned(parsed.fingerprint);
|
||||
if (!alreadyFpBanned) {
|
||||
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
|
||||
if (!alreadyFpBanned && fingerprint) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
fingerprint,
|
||||
hwFingerprint: hwFingerprint || tombstoneHw,
|
||||
bannedBy: activeTombstoneBan.banned_by,
|
||||
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
|
||||
@@ -95,28 +60,18 @@ export default router => {
|
||||
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);
|
||||
return activeTombstoneBan;
|
||||
}
|
||||
}
|
||||
|
||||
// Check hardware fingerprint ban
|
||||
// Hardware fingerprint ban
|
||||
if (hwFingerprint) {
|
||||
const hwBan = await security.isHardwareBanned(hwFingerprint);
|
||||
if (hwBan) {
|
||||
const alreadyFpBanned = await security.isFingerprintBanned(parsed.fingerprint);
|
||||
if (!alreadyFpBanned) {
|
||||
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
|
||||
if (!alreadyFpBanned && fingerprint) {
|
||||
await security.banAnonymousUser({
|
||||
fingerprint: parsed.fingerprint,
|
||||
fingerprint,
|
||||
hwFingerprint,
|
||||
bannedBy: hwBan.banned_by,
|
||||
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
|
||||
@@ -125,28 +80,19 @@ export default router => {
|
||||
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);
|
||||
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,
|
||||
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',
|
||||
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, 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -481,9 +481,11 @@ export default router => {
|
||||
}
|
||||
|
||||
// Auto-subscribe uploader
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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" }) });
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
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
|
||||
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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || ''; };
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -422,9 +422,11 @@ export const handleUpload = async (req, res, self) => {
|
||||
addPrivateItem(itemid, filename, req.session.user);
|
||||
}
|
||||
|
||||
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,6 +1102,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
}
|
||||
|
||||
// Automatically subscribe uploader to comment thread
|
||||
if (cfg.enable_comments !== false) {
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
@@ -1109,6 +1112,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD HANDLER] Failed to auto-subscribe uploader:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail & Coverart
|
||||
const isPending = linkedToExisting ? manualApproval : true;
|
||||
|
||||
@@ -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 — max 2 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>
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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 --}}
|
||||
|
||||
+102
-1
@@ -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-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
@@ -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 class="container rp-page">
|
||||
<div class="rp-header">
|
||||
<h2>User Reports</h2>
|
||||
<p>Review and resolve content flags from the community.</p>
|
||||
</div>
|
||||
</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 + ', "resolved")">Resolve</button> ' +
|
||||
'<button class="btn btn-sm btn-danger" onclick="window.resolveReport(' + r.id + ', "rejected")">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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
// 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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
+89
-25
@@ -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,19 +551,91 @@
|
||||
<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;
|
||||
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;
|
||||
}
|
||||
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() + ' · 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
syncKey();
|
||||
window.addEventListener('f0ck:anon_session_ready', syncKey);
|
||||
document.addEventListener('DOMContentLoaded', syncKey);
|
||||
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"))
|
||||
|
||||
+245
-138
@@ -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,14 +328,83 @@
|
||||
|
||||
|
||||
|
||||
<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">×</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>
|
||||
<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" />
|
||||
@@ -342,16 +421,48 @@
|
||||
{{ 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="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: 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;">
|
||||
<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
|
||||
</form>
|
||||
</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;">×</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;">×</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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- Actions -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<!-- 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 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>
|
||||
<textarea id="anon-import-key-input" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----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>
|
||||
</div>
|
||||
|
||||
</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;">×</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;
|
||||
<!-- 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 {
|
||||
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);
|
||||
}
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user