esifawafwu

This commit is contained in:
2026-09-28 19:03:31 +02:00
parent 5eb33f97c8
commit dde0a2d271
62 changed files with 3760 additions and 1865 deletions
+1
View File
@@ -50,6 +50,7 @@
"rate_item": false, "rate_item": false,
"filter": true, "filter": true,
"exclude_tags": true, "exclude_tags": true,
"chan": false,
"anonymize_users": false, "anonymize_users": false,
"allowed_modes": [ "allowed_modes": [
"sfw", "sfw",
+1
View File
@@ -45,6 +45,7 @@ anonymous_permissions:
rate_item: false rate_item: false
filter: true filter: true
exclude_tags: true exclude_tags: true
chan: false
anonymize_users: false anonymize_users: false
allowed_modes: allowed_modes:
- sfw - sfw
+1
View File
@@ -34,6 +34,7 @@ services:
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z - ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z - ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z - ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
- ./f0ckm-data/s/img/navbar/:/opt/f0ckm/public/s/img/navbar/:Z
environment: environment:
- GIT_HASH=${f0ckm_TAG:-unknown} - GIT_HASH=${f0ckm_TAG:-unknown}
+9 -2
View File
@@ -5,8 +5,14 @@
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
"scripts": { "scripts": {
"prestart": "node scripts/generate-config.mjs",
"start": "node --trace-uncaught src/index.mjs", "start": "node --trace-uncaught src/index.mjs",
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch src/index.mjs", "predev": "node scripts/generate-config.mjs",
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch --watch-path src src/index.mjs",
"devv2": "STORAGE_DIR=/home/kibi/Projects/f0ckmv2/f0ckm/f0ckm-data DB_HOST=localhost DB_PORT=5455 node --trace-uncaught --watch --watch-path src src/index.mjs",
"config:gen": "node scripts/generate-config.mjs",
"config:watch": "node scripts/generate-config.mjs --watch",
"config:to-yaml": "node scripts/generate-config.mjs --to-yaml",
"trigger": "node debug/trigger.mjs", "trigger": "node debug/trigger.mjs",
"autotagger": "node debug/autotagger.mjs", "autotagger": "node debug/autotagger.mjs",
"thumbnailer": "node debug/thumbnailer.mjs", "thumbnailer": "node debug/thumbnailer.mjs",
@@ -30,6 +36,7 @@
"jszip": "3.10.1", "jszip": "3.10.1",
"marked": "18.0.2", "marked": "18.0.2",
"matrix-js-sdk": "^40.3.0-rc.0", "matrix-js-sdk": "^40.3.0-rc.0",
"postgres": "^3.3.4" "postgres": "^3.3.4",
"yaml": "^2.9.1"
} }
} }
+126
View File
@@ -3829,6 +3829,25 @@ body.sidebar-right-hidden #sidebar-drag-zone {
margin-right: 8px; margin-right: 8px;
} }
/* Sub-tab nav + background mode bar: scroll horizontally inside themselves instead of overflowing the sidebar */
.f0ck-tuner-subtabs-nav,
.f0ck-tuner-mode-bar {
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.f0ck-tuner-subtabs-nav::-webkit-scrollbar,
.f0ck-tuner-mode-bar::-webkit-scrollbar { height: 4px; }
.f0ck-tuner-subtabs-nav::-webkit-scrollbar-thumb,
.f0ck-tuner-mode-bar::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); }
/* Buttons keep their natural width (grow to fill when there is room); mode-bar buttons set flex:1 inline */
.f0ck-tuner-subtabs-nav > .f0ck-tuner-subtab-btn { flex: 1 0 auto; }
.f0ck-tuner-mode-bar > .f0ck-tuner-mode-btn { flex: 1 0 auto !important; }
.f0ck-tuner-header > .f0ck-tuner-close { flex-shrink: 0; }
.f0ck-tuner-subtab-btn, .f0ck-tuner-subtab-btn,
.f0ck-tuner-mode-btn { .f0ck-tuner-mode-btn {
flex: 1; flex: 1;
@@ -3958,6 +3977,112 @@ body.sidebar-right-hidden #sidebar-drag-zone {
color: #fff; color: #fff;
} }
/* ── Tuner: style guide (square, flat, theme colours, no glow) + touch-friendly sliders ── */
.f0ck-audio-tuner-panel,
.f0ck-audio-tuner-panel *,
.f0ck-audio-tuner-panel *::before,
.f0ck-audio-tuner-panel *::after { border-radius: 0 !important; }
.f0ck-audio-tuner-panel:not(.in-sidebar) {
background: var(--bg, #111);
border: 1px solid var(--accent, #99ff00);
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.f0ck-audio-tuner-panel { font-family: inherit; color: var(--text-color, var(--white, #e6e6e6)); }
.f0ck-audio-tuner-panel .f0ck-tuner-header { border-bottom-color: rgba(255, 255, 255, 0.1); }
.f0ck-audio-tuner-panel .f0ck-tuner-section-title {
color: var(--text-muted, #8a8f98);
letter-spacing: 0.12em;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex; align-items: center; gap: 6px;
}
.f0ck-audio-tuner-panel .f0ck-tuner-section-title::before {
content: ''; width: 3px; height: 10px; background: var(--accent, #99ff00); flex-shrink: 0;
}
/* Sub-tabs + mode switch: segmented control, active = solid accent, no glow */
.f0ck-audio-tuner-panel .f0ck-tuner-subtabs-nav,
.f0ck-audio-tuner-panel .f0ck-tuner-mode-bar {
background: rgba(0, 0, 0, 0.35) !important;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn,
.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn { color: var(--text-muted, #8e8e93); }
.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn:hover,
.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn:hover { color: var(--text-color, #fff); background: rgba(255, 255, 255, 0.06); }
.f0ck-audio-tuner-panel .f0ck-tuner-subtab-btn.active,
.f0ck-audio-tuner-panel .f0ck-tuner-mode-btn.active { background: var(--accent, #99ff00); color: #000; box-shadow: none; }
/* Panels / cards */
.f0ck-audio-tuner-panel :is(.f0ck-tuner-presets-bar, .f0ck-danmaku-debug-card) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Inputs & selects */
.f0ck-audio-tuner-panel :is(.f0ck-tuner-preset-select, .f0ck-tuner-preset-input, input[type="text"], input[type="number"], select) {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.12);
color: var(--text-color, #fff);
min-height: 30px;
}
.f0ck-audio-tuner-panel :is(.f0ck-tuner-preset-select, .f0ck-tuner-preset-input, input[type="text"], input[type="number"], select):focus {
border-color: var(--accent, #99ff00); outline: none;
}
/* Buttons: primary = solid accent (outline on hover), the rest outlined; no glows */
.f0ck-audio-tuner-panel :is(.f0ck-tuner-btn-copy, .f0ck-danmaku-debug-btn.btn-primary) {
background: var(--accent, #99ff00); border: 1px solid var(--accent, #99ff00); color: #000; filter: none;
}
.f0ck-audio-tuner-panel :is(.f0ck-tuner-btn-copy, .f0ck-danmaku-debug-btn.btn-primary):hover {
background: transparent; color: var(--accent, #99ff00); filter: none;
}
.f0ck-audio-tuner-panel .f0ck-tuner-btn-save-default { background: transparent; border-color: var(--accent, #99ff00); }
.f0ck-audio-tuner-panel .f0ck-tuner-btn-save-default:hover { box-shadow: none; }
.f0ck-audio-tuner-panel :is(button, select, input):focus-visible { outline: 2px solid var(--accent, #99ff00); outline-offset: 1px; }
/* Value readout next to each label */
.f0ck-audio-tuner-panel .f0ck-tuner-row-label { color: var(--text-muted, #bbb); }
.f0ck-audio-tuner-panel .f0ck-tuner-row-label span:last-child {
color: var(--accent, #99ff00); font-variant-numeric: tabular-nums;
}
/* Sliders: flat track, square accent thumb, generous hit area.
touch-action pan-y: vertical drags still scroll the panel, horizontal drags move the slider. */
.f0ck-audio-tuner-panel input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 100%; height: 22px; margin: 0; padding: 0;
background: transparent; cursor: pointer; touch-action: pan-y; display: block;
}
.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-runnable-track {
height: 4px; background: rgba(255, 255, 255, 0.15); border: 0;
}
.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 14px; height: 14px; margin-top: -5px;
background: var(--accent, #99ff00); border: 0; box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.45);
transition: transform 0.1s;
}
.f0ck-audio-tuner-panel input[type="range"]:active::-webkit-slider-thumb { transform: scale(1.15); }
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-track { height: 4px; background: rgba(255, 255, 255, 0.15); border: 0; }
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-progress { height: 4px; background: var(--accent, #99ff00); }
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px; background: var(--accent, #99ff00); border: 0; box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.45);
}
.f0ck-audio-tuner-panel input[type="range"]:focus { outline: none; }
.f0ck-audio-tuner-panel input[type="range"]:focus-visible::-webkit-slider-thumb { outline: 2px solid var(--text-color, #fff); outline-offset: 1px; }
/* Touch screens: bigger thumb and row so the slider is easy to grab */
@media (pointer: coarse) {
.f0ck-audio-tuner-panel input[type="range"] { height: 34px; }
.f0ck-audio-tuner-panel input[type="range"]::-webkit-slider-thumb { width: 22px; height: 22px; margin-top: -9px; }
.f0ck-audio-tuner-panel input[type="range"]::-moz-range-thumb { width: 22px; height: 22px; }
.f0ck-audio-tuner-panel .f0ck-tuner-row { margin-bottom: 6px; }
}
.sidebar-video-details { .sidebar-video-details {
display: flex; display: flex;
margin-top: 8px; margin-top: 8px;
@@ -5281,6 +5406,7 @@ body.layout-legacy .blahlol {
background: var(--bg); background: var(--bg);
border-left: 1px solid var(--nav-border-color); border-left: 1px solid var(--nav-border-color);
border-right: 1px solid var(--nav-border-color); border-right: 1px solid var(--nav-border-color);
border-top: 1px solid var(--nav-border-color);
} }
body.layout-legacy .comments-list:not(:has(.comment)) { body.layout-legacy .comments-list:not(:has(.comment)) {
+77 -11
View File
@@ -369,6 +369,55 @@
return finishData; return finishData;
} }
// ── Add a passkey to the current anonymous identity ───────────────────
async addPasskey() {
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token)
|| document.querySelector('meta[name="csrf-token"]')?.content || '';
const beginRes = await fetch('/api/v2/anon/passkey/add/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({})
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
const cred = await navigator.credentials.create({ publicKey: {
rp: opts.rp,
user: {
id: b64urlToArr(opts.user.id),
name: opts.user.name,
displayName: opts.user.displayName
},
challenge: b64urlToArr(rawChallenge),
pubKeyCredParams: opts.pubKeyCredParams,
timeout: opts.timeout || 60000,
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
authenticatorSelection: opts.authenticatorSelection,
attestation: opts.attestation || 'none'
}});
const finishRes = await fetch('/api/v2/anon/passkey/add/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
attestationObject: arrToB64url(cred.response.attestationObject),
hw_fingerprint: await getHardwareFingerprint()
})
});
const finishData = await finishRes.json();
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
return finishData;
}
// ── Helpers ─────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────
_hasLocalPasskey() { _hasLocalPasskey() {
@@ -490,12 +539,26 @@
const res = await fetch('/api/v2/anon/identity'); const res = await fetch('/api/v2/anon/identity');
const data = await res.json(); const data = await res.json();
const fpEl = document.getElementById('anon-pk-fp-display'); const fpEl = document.getElementById('anon-pk-fp-display');
const credEl = document.getElementById('anon-pk-cred-display');
if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none'; if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none';
if (credEl) credEl.textContent = data.credential_id || '—'; this._renderPasskeyCount(data.passkey_count, data.passkey_max);
} catch (e) {} } catch (e) {}
} }
_renderPasskeyCount(count, max) {
const countEl = document.getElementById('anon-pk-count');
const maxEl = document.getElementById('anon-pk-max');
const addBtn = document.getElementById('anon-pk-add-btn');
if (countEl && typeof count === 'number') countEl.textContent = count;
if (maxEl && typeof max === 'number') maxEl.textContent = max;
if (addBtn && typeof count === 'number' && typeof max === 'number') {
const full = count >= max;
addBtn.disabled = full;
addBtn.innerHTML = full
? '<i class="fa-solid fa-lock"></i> Limit reached'
: '<i class="fa-solid fa-plus"></i> Add passkey';
}
}
// ── Init ────────────────────────────────────────────────────────────────── // ── Init ──────────────────────────────────────────────────────────────────
async init() { async init() {
@@ -568,24 +631,27 @@
const modalOverlay = document.getElementById('anon-passkey-modal'); const modalOverlay = document.getElementById('anon-passkey-modal');
if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); }); if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); });
// Add-passkey button inside modal (for anonymous users to add a second passkey) // Add-passkey button inside modal — attaches a new passkey to this identity (max enforced server-side)
const addBtn = document.getElementById('anon-pk-add-btn'); const addBtn = document.getElementById('anon-pk-add-btn');
if (addBtn) { if (addBtn) {
addBtn.addEventListener('click', async () => { addBtn.addEventListener('click', async () => {
const errEl = document.getElementById('anon-pk-error');
if (errEl) errEl.style.display = 'none';
addBtn.disabled = true; addBtn.disabled = true;
try { try {
await this.register(); const data = await this.addPasskey();
if (typeof window.showToastNotification === 'function') window.showToastNotification('New passkey registered!'); if (typeof window.showToastNotification === 'function') window.showToastNotification('Passkey added');
this._refreshModalContent(); this._renderPasskeyCount(data.passkey_count, data.passkey_max);
} catch (err) { } catch (err) {
console.warn('[ANON_PASSKEY] Modal add passkey error:', err); console.warn('[ANON_PASSKEY] Modal add passkey error:', err);
if (err && err.name === 'NotAllowedError') { if (errEl) {
alert('Passkey creation was cancelled or blocked. If Bitwarden or another password manager is locked, please unlock it.'); errEl.style.display = '';
} else { errEl.textContent = (err && err.name === 'NotAllowedError')
alert('Failed to add passkey: ' + ((err && err.message) || 'Unknown error')); ? 'Cancelled or blocked. Unlock your password manager and try again.'
: ((err && err.message) || 'Failed to add passkey.');
} }
} finally {
addBtn.disabled = false; addBtn.disabled = false;
this._refreshModalContent();
} }
}); });
} }
+39 -14
View File
@@ -1,3 +1,12 @@
// Pages whose content depends on the rating/mime filter and must reload when it changes.
// Profile pages count even when both previews are empty (then no .posts grid is rendered).
// Elements whose horizontal drags belong to them (sliders, sideways-scrolling rows): swipe gestures ignore touches starting here.
window.F0CK_NO_SWIPE_SELECTOR = 'input[type="range"], .f0ck-tuner-subtabs-nav, .f0ck-tuner-mode-bar, [data-no-swipe]';
window.isFilterReloadableView = function () {
return !!document.querySelector('.posts, .tags-grid') || /^\/user\/[^/]+\/?$/.test(window.location.pathname);
};
// Normalize percent-encoded characters in the URL bar that are safe to show decoded. // Normalize percent-encoded characters in the URL bar that are safe to show decoded.
// Runs immediately so the address bar is clean before any other JS runs. // Runs immediately so the address bar is clean before any other JS runs.
@@ -1437,7 +1446,7 @@ window.cancelAnimFrame = (function () {
window.flashMessage('ALL MODE ACTIVATED'); window.flashMessage('ALL MODE ACTIVATED');
gridCacheMap.clear(); gridCacheMap.clear();
const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isOnaraOpen = document.body.classList.contains('onara-modal-open');
const isGridView = document.querySelector('.posts, .tags-grid'); const isGridView = window.isFilterReloadableView();
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname))); const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
let reloadPromise = null; let reloadPromise = null;
if (isOnaraOpen) { if (isOnaraOpen) {
@@ -1501,7 +1510,7 @@ window.cancelAnimFrame = (function () {
window.flashMessage(label); window.flashMessage(label);
gridCacheMap.clear(); gridCacheMap.clear();
const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isOnaraOpen = document.body.classList.contains('onara-modal-open');
const isGridView = document.querySelector('.posts, .tags-grid'); const isGridView = window.isFilterReloadableView();
const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname))); const isItemView = document.getElementById('prev') || document.getElementById('next') || (typeof isItemPath === 'function' ? isItemPath(window.location.pathname) : (!window.location.pathname.startsWith('/4/') && /^\/\d+(?:[?#]|$)/.test(window.location.pathname)));
let reloadPromise = null; let reloadPromise = null;
if (isOnaraOpen) { if (isOnaraOpen) {
@@ -13173,7 +13182,7 @@ window.cancelAnimFrame = (function () {
// Refresh content // Refresh content
const isOnaraOpen = document.body.classList.contains('onara-modal-open'); const isOnaraOpen = document.body.classList.contains('onara-modal-open');
const isGridView = document.querySelector('.posts, .tags-grid'); const isGridView = window.isFilterReloadableView();
const isItemView = document.getElementById('prev') || document.getElementById('next'); const isItemView = document.getElementById('prev') || document.getElementById('next');
let reloadPromise = null; let reloadPromise = null;
@@ -13789,6 +13798,7 @@ window.cancelAnimFrame = (function () {
}, 50); }, 50);
const updateDomAfterSave = (resData) => { const updateDomAfterSave = (resData) => {
const itemPath = (resData && resData.item_path) || itemId;
try { try {
const finalRating = resData.rating || selectedRating || 'untagged'; const finalRating = resData.rating || selectedRating || 'untagged';
@@ -13838,11 +13848,11 @@ window.cancelAnimFrame = (function () {
sep.textContent = '→'; sep.textContent = '→';
const a = document.createElement('a'); const a = document.createElement('a');
a.href = `/${itemId}`; a.href = `/${itemPath}`;
a.className = 'location-link is-rehosted'; a.className = 'location-link is-rehosted';
a.title = `View rehosted post #${itemId} on f0ckm`; a.title = `View rehosted post /${itemPath} on f0ckm`;
a.style.cssText = 'color: #4ade80; font-weight: 700;'; a.style.cssText = 'color: #4ade80; font-weight: 700;';
a.innerHTML = `<i class="fa-solid fa-check"></i> #${itemId}`; a.innerHTML = `<i class="fa-solid fa-check"></i> /${itemPath}`;
locEl.appendChild(sep); locEl.appendChild(sep);
locEl.appendChild(a); locEl.appendChild(a);
@@ -13860,8 +13870,8 @@ window.cancelAnimFrame = (function () {
if (rehostBtn) { if (rehostBtn) {
rehostBtn.outerHTML = ` rehostBtn.outerHTML = `
<span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;"> <span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;">
<a href="/${itemId}" class="chan-rehost-action-btn rehosted" title="View Post #${itemId}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"> <a href="/${itemPath}" class="chan-rehost-action-btn rehosted" title="View /${itemPath}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;">
<i class="fa-solid fa-check"></i> #${itemId} <i class="fa-solid fa-check"></i> /${itemPath}
</a> </a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="${itemId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"> <button type="button" class="chan-rehost-edit-btn" data-item-id="${itemId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;">
<i class="fa-solid fa-pen"></i> <i class="fa-solid fa-pen"></i>
@@ -13985,8 +13995,8 @@ window.cancelAnimFrame = (function () {
btn.outerHTML = ` btn.outerHTML = `
<span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;"> <span class="chan-rehost-btn-group" style="display:inline-flex;gap:4px;align-items:center;">
<a href="/${data.item_id}" class="chan-rehost-action-btn rehosted" title="View Post #${data.item_id}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"> <a href="/${data.item_path || data.item_id}" class="chan-rehost-action-btn rehosted" title="View /${data.item_path || data.item_id}" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;">
<i class="fa-solid fa-check"></i> #${data.item_id} <i class="fa-solid fa-check"></i> /${data.item_path || data.item_id}
</a> </a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="${data.item_id}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"> <button type="button" class="chan-rehost-edit-btn" data-item-id="${data.item_id}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;">
<i class="fa-solid fa-pen"></i> <i class="fa-solid fa-pen"></i>
@@ -13997,7 +14007,7 @@ window.cancelAnimFrame = (function () {
window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!'); window.showToast(data.repost ? 'Already on site — linked to existing post' : 'Rehosted successfully!');
} }
if (typeof window.openChanRehostModal === 'function') { if (typeof window.openChanRehostModal === 'function') {
const newAnchor = document.querySelector(`.chan-rehost-edit-btn[data-item-id="${data.item_id}"]`) || document.querySelector(`.chan-rehost-action-btn[href="/${data.item_id}"]`); const newAnchor = document.querySelector(`.chan-rehost-edit-btn[data-item-id="${data.item_id}"]`) || document.querySelector(`.chan-rehost-action-btn[href="/${data.item_path || data.item_id}"]`);
window.openChanRehostModal({ itemId: data.item_id, anchorEl: newAnchor }); window.openChanRehostModal({ itemId: data.item_id, anchorEl: newAnchor });
} }
} else { } else {
@@ -16562,6 +16572,19 @@ window.cancelAnimFrame = (function () {
if (window.f0ckSession?.disable_swiping) return; if (window.f0ckSession?.disable_swiping) return;
if (document.body.classList.contains('modal-open')) return; if (document.body.classList.contains('modal-open')) return;
// Sliders and horizontally scrolling rows own their horizontal drags: never start a
// sidebar-drag or item-swipe from them (the swipe's preventDefault would freeze the slider)
if (e.target?.closest?.(window.F0CK_NO_SWIPE_SELECTOR)) {
swipeRT.xDown = null;
swipeRT.yDown = null;
swipeRT.xDiff = 0;
swipeRT.yDiff = 0;
swipeRT.isSidebarCandidate = false;
swipeRT.isDraggingSidebar = false;
swipeRT.startedInSidebarZone = true; // also blocks prev/next navigation on touchend
return;
}
const touch = e.touches[0]; const touch = e.touches[0];
// Check if touch is over a video player, controls, or video element // Check if touch is over a video player, controls, or video element
@@ -17474,13 +17497,15 @@ window.cancelAnimFrame = (function () {
if (document.body.classList.contains('modal-open')) return; if (document.body.classList.contains('modal-open')) return;
if (!isLSActive()) return; if (!isLSActive()) return;
swipeRT.isDraggingLeftSidebar = false;
swipeRT.isLeftSidebarCandidate = false;
if (e.target?.closest?.(window.F0CK_NO_SWIPE_SELECTOR)) return;
const { sb, layout } = getLS(); const { sb, layout } = getLS();
if (!sb || !layout) return; if (!sb || !layout) return;
const touch = e.touches[0]; const touch = e.touches[0];
const isHidden = layout.classList.contains('sidebar-hidden'); const isHidden = layout.classList.contains('sidebar-hidden');
swipeRT.isDraggingLeftSidebar = false;
swipeRT.isLeftSidebarCandidate = false;
if (isHidden) { if (isHidden) {
if (touch.clientX < 60) { if (touch.clientX < 60) {
@@ -20145,7 +20170,7 @@ document.addEventListener("DOMContentLoaded", function () {
// Trigger reload context-aware // Trigger reload context-aware
const fromModal = !!menu.closest('#excluded-tags-overlay'); const fromModal = !!menu.closest('#excluded-tags-overlay');
const postsEl = document.querySelector('.posts, .tags-grid'); const postsEl = window.isFilterReloadableView();
let reloadPromise = null; let reloadPromise = null;
// Set flag so hideAllModals (called inside loadPageAjax) skips the filter overlay // Set flag so hideAllModals (called inside loadPageAjax) skips the filter overlay
+7 -5
View File
@@ -1455,7 +1455,7 @@
${item.is_external && item.external_board && item.external_tid ${item.is_external && item.external_board && item.external_tid
? `<a class="scroll-id-link" href="https://boards.4chan.org/${item.external_board}/thread/${item.external_tid}#p${item.external_id}" target="_blank">/${item.external_board}/thread/${item.external_tid}</a>` ? `<a class="scroll-id-link" href="https://boards.4chan.org/${item.external_board}/thread/${item.external_tid}#p${item.external_id}" target="_blank">/${item.external_board}/thread/${item.external_tid}</a>`
: (item.local_id : (item.local_id
? `<a class="scroll-id-link" href="/${item.local_id}" target="_blank">#${item.local_id}</a>` ? `<a class="scroll-id-link" href="/${item.local_path || item.local_id}" target="_blank">/${item.local_path || item.local_id}</a>`
: `<a class="scroll-id-link" href="/abyss/${item.id}">#${item.id}</a>`)} : `<a class="scroll-id-link" href="/abyss/${item.id}">#${item.id}</a>`)}
</div>`; </div>`;
slide.appendChild(meta); slide.appendChild(meta);
@@ -1486,7 +1486,7 @@
</button> </button>
${item.is_external ? ( ${item.is_external ? (
item.local_id item.local_id
? `<a class="scroll-btn rehost-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}"> ? `<a class="scroll-btn rehost-btn success" href="/${item.local_path || item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div> <div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
<span class="scroll-btn-label">${_i.view_label || 'View'}</span> <span class="scroll-btn-label">${_i.view_label || 'View'}</span>
</a>` </a>`
@@ -1761,6 +1761,7 @@
const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`; const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`;
const local_id = allRehosts[external_media_url] || null; const local_id = allRehosts[external_media_url] || null;
const local_path = (local_id && data.rehost_paths && data.rehost_paths[local_id]) || local_id;
return { return {
id: `${data.board}/${p.no}`, id: `${data.board}/${p.no}`,
@@ -1770,6 +1771,7 @@
external_source: '4chan', external_source: '4chan',
is_external: true, is_external: true,
local_id, local_id,
local_path,
external_media_url, external_media_url,
mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'), mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'),
dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`, dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`,
@@ -1972,7 +1974,7 @@
// Update button to link to the new site-internal post // Update button to link to the new site-internal post
setTimeout(() => { setTimeout(() => {
btn.outerHTML = ` btn.outerHTML = `
<a href="/${data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;"> <a href="/${data.item_path || data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div> <div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
<span class="scroll-btn-label">View</span> <span class="scroll-btn-label">View</span>
</a> </a>
@@ -1983,8 +1985,8 @@
if (slide) { if (slide) {
const idLink = slide.querySelector('.scroll-id-link'); const idLink = slide.querySelector('.scroll-id-link');
if (idLink) { if (idLink) {
idLink.href = `/${data.item_id}`; idLink.href = `/${data.item_path || data.item_id}`;
idLink.textContent = `#${data.item_id}`; idLink.textContent = `/${data.item_path || data.item_id}`;
} }
// Reflect rehoster's username and local timestamp // Reflect rehoster's username and local timestamp
if (window.scrollerUsername) { if (window.scrollerUsername) {
+2 -2
View File
@@ -3401,7 +3401,7 @@ window.initUploadForm = (selector) => {
} }
} }
if (shouldRedirectToItem) { if (shouldRedirectToItem && !lastData?.manual_approval) {
const isMultiNonAlbumShitpost = isShitpost && !isAlbum && (uploadedResults.length > 1 || successCount > 1 || totalFilesToUpload > 1); const isMultiNonAlbumShitpost = isShitpost && !isAlbum && (uploadedResults.length > 1 || successCount > 1 || totalFilesToUpload > 1);
let targetUrl; let targetUrl;
if (isMultiNonAlbumShitpost) { if (isMultiNonAlbumShitpost) {
@@ -3416,7 +3416,7 @@ window.initUploadForm = (selector) => {
window.location.href = targetUrl; window.location.href = targetUrl;
} }
} }
// else: stay on current page // else: stay on current page (including manual_approval pending)
} }
} else { } else {
restoreBtn(); restoreBtn();
+5 -2
View File
@@ -60,6 +60,9 @@
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase()) (window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
); );
// Anonymous uploaders may change the rating of their own item without full manage rights
const canRate = canManage || !!document.querySelector('#tags[data-can-rate="true"]');
// Only remove existing dynamically generated tags // Only remove existing dynamically generated tags
[...inner.querySelectorAll(".badge")].forEach(tag => { [...inner.querySelectorAll(".badge")].forEach(tag => {
// Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself // Don't remove the one containing the add/toggle buttons, and don't remove the autocomplete input itself
@@ -100,7 +103,7 @@
span.classList.add('rating-tag', `is-${tag.normalized}`); span.classList.add('rating-tag', `is-${tag.normalized}`);
span.dataset.rating = tag.normalized; span.dataset.rating = tag.normalized;
if (activePostId) span.dataset.itemId = activePostId; if (activePostId) span.dataset.itemId = activePostId;
if (canManage) { if (canRate) {
span.classList.add('can-cycle'); span.classList.add('can-cycle');
} }
} else { } else {
@@ -144,7 +147,7 @@
const hasRating = !!lastRatingTag; const hasRating = !!lastRatingTag;
if (!hasRating) { if (!hasRating) {
const untaggedSpan = document.createElement("span"); const untaggedSpan = document.createElement("span");
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`; untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canRate ? ' can-cycle' : ''}`;
untaggedSpan.dataset.rating = 'untagged'; untaggedSpan.dataset.rating = 'untagged';
if (activePostId) untaggedSpan.dataset.itemId = activePostId; if (activePostId) untaggedSpan.dataset.itemId = activePostId;
const lbl = document.createElement("span"); const lbl = document.createElement("span");
+137
View File
@@ -0,0 +1,137 @@
/**
* chan_http.mjs — curl invocation for all outgoing 4chan requests (API JSON, media, rehost downloads).
*
* Every call gets a randomized browser identity:
* - If curl-impersonate is installed (wrapper binaries like curl_chrome116, curl_ff117, ...), a random
* profile is used. These reproduce a real browser's TLS + HTTP/2 handshake and send matching headers,
* so we must NOT override the User-Agent (a mismatch would defeat the point).
* - Otherwise plain curl is used with a random, current User-Agent and a matching Accept-Language.
*
* config: main.curl_impersonate
* (unset) / true → auto-detect wrappers on PATH
* false → never use curl-impersonate
* "<dir>" → look for wrappers in that directory (in addition to PATH)
*/
import fs from 'fs';
import path from 'path';
import cfg from './config.mjs';
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
// Browser majors derived from the date so the pool never goes stale.
// Chrome 100 shipped 2022-03-29, Firefox 100 on 2022-05-03; both release every 4 weeks.
const majorSince = (base, isoDate) => base + Math.floor((Date.now() - Date.parse(isoDate)) / (28 * 86400000));
const randomUserAgent = () => {
const chrome = majorSince(100, '2022-03-29') - Math.floor(Math.random() * 3); // current or up to 2 behind
const firefox = majorSince(100, '2022-05-03') - Math.floor(Math.random() * 3);
const templates = [
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36 Edg/${chrome}.0.0.0`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
`Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`
];
return pick(templates);
};
const ACCEPT_LANGUAGES = [
'en-US,en;q=0.9',
'en-US,en;q=0.8',
'en-GB,en;q=0.9,en-US;q=0.8',
'en-US,en;q=0.9,de;q=0.8',
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
];
// ── curl-impersonate detection (cached after first lookup) ──────────────────
// Wrapper names look like curl_chrome146, curl_firefox147, curl_edge101, curl_safari260, curl_chrome131_android.
const IMPERSONATE_RE = /^curl_(chrome|edge|firefox|ff|safari)(\d+)([a-z0-9_]*)$/i;
// Only the newest desktop profiles per browser: an old fingerprint (chrome99) stands out as much as plain curl.
const PROFILES_PER_BROWSER = 3;
let _impersonateBins = null;
const findImpersonateBins = () => {
if (_impersonateBins) return _impersonateBins;
const setting = cfg.main?.curl_impersonate;
if (setting === false) return (_impersonateBins = []);
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
if (typeof setting === 'string' && setting.trim()) dirs.unshift(setting.trim());
const byBrowser = new Map(); // family → [{ version, full }]
const seen = new Set();
for (const dir of dirs) {
let entries;
try { entries = fs.readdirSync(dir); } catch { continue; }
for (const name of entries) {
const m = name.match(IMPERSONATE_RE);
if (!m || m[3] || seen.has(name)) continue; // m[3] = suffix like _android/_ios/a → skip non-desktop variants
const full = path.join(dir, name);
try { fs.accessSync(full, fs.constants.X_OK); } catch { continue; }
seen.add(name);
const family = m[1].toLowerCase() === 'ff' ? 'firefox' : m[1].toLowerCase();
if (!byBrowser.has(family)) byBrowser.set(family, []);
byBrowser.get(family).push({ version: parseInt(m[2], 10), full });
}
}
// Edge shares Chrome's version numbers; drop Edge profiles that lag far behind the newest Chrome
// (releases have shipped edge99/edge101 next to chrome146, which would stand out as ancient).
const newestChrome = Math.max(0, ...(byBrowser.get('chrome') || []).map(p => p.version));
if (newestChrome && byBrowser.has('edge')) {
byBrowser.set('edge', byBrowser.get('edge').filter(p => p.version >= newestChrome - 10));
}
_impersonateBins = [];
for (const list of byBrowser.values()) {
list.sort((a, b) => b.version - a.version);
_impersonateBins.push(...list.slice(0, PROFILES_PER_BROWSER).map(p => p.full));
}
console.log(_impersonateBins.length
? `[BOOT] 4chan requests: curl-impersonate enabled (${_impersonateBins.map(b => path.basename(b)).join(', ')})`
: '[BOOT] 4chan requests: curl-impersonate not found, using curl with randomized User-Agent');
return _impersonateBins;
};
// The static curl-impersonate build looks for CAs at the Debian/Alpine path only. On other distros
// (openSUSE, Fedora, macOS) point it at the local bundle explicitly.
const CA_DEFAULT = '/etc/ssl/certs/ca-certificates.crt';
const CA_FALLBACKS = ['/etc/ssl/ca-bundle.pem', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'];
let _caArgs = null;
const caArgs = () => {
if (_caArgs) return _caArgs;
if (fs.existsSync(CA_DEFAULT)) return (_caArgs = []);
const found = CA_FALLBACKS.find(f => fs.existsSync(f));
return (_caArgs = found ? ['--cacert', found] : []);
};
const socksArgs = () => {
const socks = cfg.main?.socks;
if (!socks || socks === 'undefined') return [];
const host = socks.includes('://') ? socks.split('://')[1] : socks;
return ['--socks5-hostname', host];
};
/**
* Build a curl command for a 4chan URL with a randomized browser identity.
* @param {string} url
* @param {string[]} [extraArgs] additional curl flags (e.g. ['-o', file, '--max-time', '300'])
* @returns {{ bin: string, args: string[] }}
*/
export const chanCurl = (url, extraArgs = []) => {
const base = ['-s', '-f', '-L', ...extraArgs, ...socksArgs()];
const impersonate = findImpersonateBins();
if (impersonate.length) {
// Wrapper supplies its own UA, header order and TLS/HTTP2 fingerprint
return { bin: pick(impersonate), args: [...base, ...caArgs(), url] };
}
return {
bin: 'curl',
args: [...base, '-A', randomUserAgent(), '-H', `Accept-Language: ${pick(ACCEPT_LANGUAGES)}`, url]
};
};
export default { chanCurl };
+2 -2
View File
@@ -4,7 +4,7 @@ import db from "./sql.mjs";
import cfg from "./config.mjs"; import cfg from "./config.mjs";
import { createI18n } from "./i18n.mjs"; import { createI18n } from "./i18n.mjs";
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo } from "./settings.mjs"; import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo, canUseChan } from "./settings.mjs";
@@ -552,7 +552,7 @@ export default new class {
} }
return res.redirect('/login'); return res.redirect('/login');
} }
const hasGroup = req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan')); const hasGroup = canUseChan(req.session);
if (!hasGroup) { if (!hasGroup) {
if (isApi) { if (isApi) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' }); return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
+16 -7
View File
@@ -1,7 +1,7 @@
import db from "../sql.mjs"; import db from "../sql.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession } from "../settings.mjs"; import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, getAnonAllowedMimes, isAnonSession, getSessionOwnerName } from "../settings.mjs";
import { updateHallsCache } from "../halls_cache.mjs"; import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs"; import queue from "../queue.mjs";
import fs from "fs"; import fs from "fs";
@@ -129,6 +129,13 @@ const computeBaseMode = (mode, ratings, session) => {
if (!allowedModes.includes('sfw')) { if (!allowedModes.includes('sfw')) {
baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`; baseMode = `(${baseMode}) and not exists (select 1 from tags_assign where item_id = items.id and tag_id = 1)`;
} }
// Uploaders always see their own untagged items, even when 'untagged' isn't an allowed anon mode.
// The name is interpolated into raw SQL, so only accept the plain shadow-login charset.
// Only `items.id` may reference the outer row: some callers alias items (e.g. comments.mjs rewrites items.id → i.id).
const ownerName = getSessionOwnerName(session);
if (!allowedModes.includes('untagged') && ownerName && /^[a-z0-9_]+$/i.test(ownerName)) {
baseMode = `((${baseMode}) or items.id in (select own.id from items own where lower(own.username) = '${ownerName.toLowerCase()}' and not exists (select 1 from tags_assign ota where ota.item_id = own.id and ota.tag_id in (1, 2, ${nsflId}))))`;
}
} }
return baseMode; return baseMode;
}; };
@@ -477,11 +484,11 @@ const buildFeedFilters = async ({
} }
const isAdmin = !!session?.admin; const isAdmin = !!session?.admin;
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator)); const ownerName = getSessionOwnerName(session);
const visibilityFilter = isAdmin const visibilityFilter = isAdmin
? db`` ? db``
: (session && session.user : (ownerName
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))` ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`); : db`and coalesce(items.visibility, 0) = 0`);
return { return {
@@ -1217,10 +1224,11 @@ const f0cklib = {
// Helper to construct shared filter conditions // Helper to construct shared filter conditions
const buildConditions = () => { const buildConditions = () => {
const isAdmin = !!session?.admin; const isAdmin = !!session?.admin;
const ownerName = getSessionOwnerName(session);
const visibilityFilter = isAdmin const visibilityFilter = isAdmin
? db`` ? db``
: (session && session.user : (ownerName
? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${session.user.toLowerCase()} and items.visibility != 3))` ? db`and (coalesce(items.visibility, 0) = 0 or (lower(items.username) = ${ownerName.toLowerCase()} and items.visibility != 3))`
: db`and coalesce(items.visibility, 0) = 0`); : db`and coalesce(items.visibility, 0) = 0`);
return db` return db`
@@ -1284,8 +1292,9 @@ const f0cklib = {
const itemid = actitem.id; const itemid = actitem.id;
// Check visibility permissions: // Check visibility permissions:
const ownerName = getSessionOwnerName(session);
const isOwnerOrAdmin = session && ( const isOwnerOrAdmin = session && (
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) || (ownerName && ownerName.toLowerCase() === (actitem.username || '').toLowerCase()) ||
session.admin || session.is_moderator session.admin || session.is_moderator
); );
+19
View File
@@ -286,8 +286,27 @@ export default (router, tpl) => {
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
// Dashboard counters (cheap aggregate queries; failures just show 0)
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0 };
try {
const [[r], [u], [t]] = await Promise.all([
db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`,
db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon
FROM "user"`,
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`
]);
dash.trash = t?.n || 0;
dash.open_reports = r?.n || 0;
dash.users = u?.users || 0;
dash.anon_users = u?.anon || 0;
} catch (e) {
console.error('[ADMIN] dashboard counters failed:', e.message);
}
res.reply({ res.reply({
body: tpl.render("admin", { body: tpl.render("admin", {
dash,
totals: await lib.countf0cks(), totals: await lib.countf0cks(),
session: req.session, session: req.session,
manual_approval: getManualApproval(), manual_approval: getManualApproval(),
+13 -15
View File
@@ -3,7 +3,7 @@ import lib from "../lib.mjs";
import url from "url"; import url from "url";
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs"; import { createI18n } from "../i18n.mjs";
import { isAnonymizeSession } from "../settings.mjs"; import { isAnonymizeSession, canAnonDo, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
export default (router, tpl) => { export default (router, tpl) => {
// ── Merged random + item load: single request instead of two ──────────── // ── Merged random + item load: single request instead of two ────────────
@@ -177,6 +177,8 @@ export default (router, tpl) => {
if (data.item) { if (data.item) {
const session = data.session; const session = data.session;
const item = data.item; const item = data.item;
// Keep the real uploader for permission checks — anonymization below overwrites item.username
const _realUsername = item.username;
if (isAnonymizeSession(req.session)) { if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null; if (item.src) item.src = null;
item.username = 'anonymous'; item.username = 'anonymous';
@@ -205,6 +207,9 @@ export default (router, tpl) => {
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites); data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
@@ -229,13 +234,7 @@ export default (router, tpl) => {
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`); console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
let itemPage = null; let itemPage = null;
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (query.onara === '1' || cookieOnara === true);
if (effectiveOnara) { if (effectiveOnara) {
try { try {
itemPage = await f0cklib.getItemPage({ itemPage = await f0cklib.getItemPage({
@@ -444,6 +443,8 @@ export default (router, tpl) => {
if (data.item) { if (data.item) {
const session = data.session; const session = data.session;
const item = data.item; const item = data.item;
// Keep the real uploader for permission checks — anonymization below overwrites item.username
const _realUsername = item.username;
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL // When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) { if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null; if (item.src) item.src = null;
@@ -483,6 +484,9 @@ export default (router, tpl) => {
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites); data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
@@ -510,13 +514,7 @@ export default (router, tpl) => {
- Render: ${tAjaxRender - tAjaxAux}ms`); - Render: ${tAjaxRender - tAjaxAux}ms`);
let itemPage = null; let itemPage = null;
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (query.onara === '1' || cookieOnara === true);
if (effectiveOnara || query.get_page === '1') { if (effectiveOnara || query.get_page === '1') {
try { try {
itemPage = await f0cklib.getItemPage({ itemPage = await f0cklib.getItemPage({
+133
View File
@@ -16,6 +16,14 @@ import {
} from '../../webauthn.mjs'; } from '../../webauthn.mjs';
import { getEnableAnonymousAccess } from '../../settings.mjs'; import { getEnableAnonymousAccess } from '../../settings.mjs';
// Maximum number of passkeys a single anonymous identity may hold
const MAX_ANON_PASSKEYS = 4;
const countPasskeys = async userId => {
const rows = await db`SELECT COUNT(*)::int AS n FROM passkey_credentials WHERE user_id = ${userId}`;
return rows[0]?.n || 0;
};
export default router => { export default router => {
router.group(/^\/api\/v2\/anon/, group => { router.group(/^\/api\/v2\/anon/, group => {
@@ -426,6 +434,128 @@ export default router => {
} }
}); });
// ─── Add Passkey to current anonymous identity ────────────────────────────
// Resolves the logged-in anonymous user, or null if the session isn't an anon identity
const getAnonSessionUserId = async req => {
if (!req.session?.id) return null;
const rows = await db`SELECT 1 FROM anon_identities WHERE user_id = ${req.session.id} LIMIT 1`;
return rows.length > 0 ? req.session.id : null;
};
/**
* POST /api/v2/anon/passkey/add/begin
* Registration options for an additional passkey on the current anonymous identity.
*/
group.post(/\/passkey\/add\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const userId = await getAnonSessionUserId(req);
if (!userId) {
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
}
const existing = await db`SELECT credential_id FROM passkey_credentials WHERE user_id = ${userId}`;
if (existing.length >= MAX_ANON_PASSKEYS) {
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
}
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
const challenge = generateChallenge({ type: 'anon-add', userId });
const options = buildRegistrationOptions({
challenge,
userId: userHandle,
userName: `anon@${cfg.main?.url?.domain || 'f0ck.dev'}`,
displayName: 'Anonymous',
rpId: getRpIdFromHost(req.headers.host)
});
// Stop the authenticator from registering a second copy of a passkey it already holds
options.excludeCredentials = existing.map(r => ({ type: 'public-key', id: r.credential_id }));
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] add/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/add/finish
* Verify attestation and attach the new passkey to the current anonymous identity.
*/
group.post(/\/passkey\/add\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const userId = await getAnonSessionUserId(req);
if (!userId) {
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
}
if (!req.session.csrf_token || req.headers['x-csrf-token'] !== req.session.csrf_token) {
return res.json({ success: false, msg: 'Invalid CSRF token' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: hwFingerprint } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-add' || challengeMeta.userId !== userId) {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Re-check here too: two add flows could have been started in parallel
if (await countPasskeys(userId) >= MAX_ANON_PASSKEYS) {
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
}
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[ANON_PASSKEY] Add verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
const taken = await db`SELECT user_id FROM passkey_credentials WHERE credential_id = ${credentialId} LIMIT 1`;
if (taken.length > 0) {
return res.json({ success: false, msg: 'This passkey is already registered.' }, 409);
}
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
const auditIp = resolveAuditIP(req);
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'})
`;
await db`
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint || null})
ON CONFLICT (credential_id) DO NOTHING
`;
const passkeyCount = await countPasskeys(userId);
return res.json({ success: true, passkey_count: passkeyCount, passkey_max: MAX_ANON_PASSKEYS });
} catch (err) {
console.error('[ANON_PASSKEY] add/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Identity ───────────────────────────────────────────────────────────── // ─── Identity ─────────────────────────────────────────────────────────────
/** /**
@@ -448,6 +578,7 @@ export default router => {
FROM anon_identities ai FROM anon_identities ai
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
WHERE ai.user_id = ${req.session.id} WHERE ai.user_id = ${req.session.id}
ORDER BY ai.created_at ASC
LIMIT 1 LIMIT 1
`; `;
@@ -462,6 +593,8 @@ export default router => {
hw_fingerprint: rows[0].hw_fingerprint, hw_fingerprint: rows[0].hw_fingerprint,
credential_id: rows[0].credential_id, credential_id: rows[0].credential_id,
passkey_name: rows[0].passkey_name, passkey_name: rows[0].passkey_name,
passkey_count: await countPasskeys(req.session.id),
passkey_max: MAX_ANON_PASSKEYS,
csrf_token: req.session.csrf_token csrf_token: req.session.csrf_token
}); });
} }
+3 -2
View File
@@ -2,7 +2,7 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs'; import db from '../../sql.mjs';
import lib from '../../lib.mjs'; import lib from '../../lib.mjs';
import cfg from '../../config.mjs'; import cfg from '../../config.mjs';
import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from '../../settings.mjs'; import { getEnableItemSlugs, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession, getSessionOwnerName } from '../../settings.mjs';
import queue from '../../queue.mjs'; import queue from '../../queue.mjs';
import search from '../../routeinc/search.mjs'; import search from '../../routeinc/search.mjs';
import path from "path"; import path from "path";
@@ -2315,7 +2315,8 @@ export default router => {
return res.json({ success: false, msg: 'Item not found' }, 404); return res.json({ success: false, msg: 'Item not found' }, 404);
} }
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase()); const ownerName = getSessionOwnerName(req.session);
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(req.session.admin || req.session.is_moderator); const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) { if (!isOwner && !isAdmin) {
+5 -4
View File
@@ -6,7 +6,7 @@ import cfg from "../../config.mjs";
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { logAnonActivity } from "../../anon_auth.mjs"; import { logAnonActivity } from "../../anon_auth.mjs";
import { canAnonDo, isAnonSession } from "../../settings.mjs"; import { canAnonDo, isAnonSession, getSessionOwnerName } from "../../settings.mjs";
export default router => { export default router => {
router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => { router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
@@ -156,7 +156,7 @@ export default router => {
WHERE item_id IN ${db(itemIds)} WHERE item_id IN ${db(itemIds)}
AND tag_id IN ${db(tagIds)} AND tag_id IN ${db(tagIds)}
AND item_id IN ( AND item_id IN (
SELECT id FROM items WHERE username = ${req.session.user} SELECT id FROM items WHERE username = ${getSessionOwnerName(req.session)}
) )
`; `;
} }
@@ -206,7 +206,7 @@ export default router => {
const ownedItems = await db` const ownedItems = await db`
SELECT id FROM items SELECT id FROM items
WHERE id IN ${db(itemIds)} WHERE id IN ${db(itemIds)}
AND username = ${req.session.user} AND username = ${getSessionOwnerName(req.session)}
AND active = true AND is_deleted = false AND active = true AND is_deleted = false
`; `;
eligibleIds = ownedItems.map(r => r.id); eligibleIds = ownedItems.map(r => r.id);
@@ -440,7 +440,8 @@ export default router => {
return res.json({ success: false, msg: 'Item not found' }, 404); return res.json({ success: false, msg: 'Item not found' }, 404);
} }
const isOwner = !!(item[0].username && req.session.user && item[0].username.toLowerCase() === req.session.user.toLowerCase()); const ownerName = getSessionOwnerName(req.session);
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(req.session.admin || req.session.is_moderator); const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) { if (!isOwner && !isAdmin) {
return res.json({ success: false, msg: 'Unauthorized' }, 403); return res.json({ success: false, msg: 'Unauthorized' }, 403);
+30 -25
View File
@@ -2,6 +2,11 @@ import cfg from "../config.mjs";
import db from "../sql.mjs"; import db from "../sql.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import queue from "../queue.mjs"; import queue from "../queue.mjs";
import { chanCurl } from "../chan_http.mjs";
import { getSessionOwnerName, getEnableItemSlugs, isOnaraEnabledFor } from "../settings.mjs";
// Link path for a local item: its slug when slugs are enabled, else the numeric id
const itemPath = (id, slug) => (getEnableItemSlugs() && slug) ? slug : id;
/** /**
* chan.mjs — 4chan thread viewer & catalogue routes * chan.mjs — 4chan thread viewer & catalogue routes
@@ -28,17 +33,8 @@ export default (router, tpl) => {
* Helper to fetch data via curl respecting SOCKS5 proxy if configured. * Helper to fetch data via curl respecting SOCKS5 proxy if configured.
*/ */
async function fetchWithProxy(url) { async function fetchWithProxy(url) {
const curlArgs = [ const { bin, args } = chanCurl(url, ['--max-time', '30']);
'-s', '-f', '-L', const { stdout } = await queue.spawn(bin, args, { encoding: 'utf8' });
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '30',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: 'utf8' });
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim(); const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
if (!text.startsWith('{') && !text.startsWith('[')) { if (!text.startsWith('{') && !text.startsWith('[')) {
throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`); throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`);
@@ -123,10 +119,12 @@ export default (router, tpl) => {
} }
}); });
const rehosts = {}; const rehosts = {};
const rehostPaths = {};
if (opUrls.length > 0) { if (opUrls.length > 0) {
try { try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${opUrls})`; const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${opUrls})`;
rows.forEach(r => { rows.forEach(r => {
rehostPaths[r.id] = itemPath(r.id, r.slug);
rehosts[r.src] = r.id; rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/); const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id; if (m) rehosts[m[1]] = r.id;
@@ -135,6 +133,7 @@ export default (router, tpl) => {
} }
threads.forEach(t => { threads.forEach(t => {
t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null; t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null;
t.local_path = t.local_id ? rehostPaths[t.local_id] : null;
}); });
const data = { const data = {
@@ -219,6 +218,7 @@ export default (router, tpl) => {
is_video: isVideo, is_video: isVideo,
is_image: isImage, is_image: isImage,
local_id: localId, local_id: localId,
local_path: rehostInfo ? rehostInfo.path : null,
rehosted: !!localId, rehosted: !!localId,
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false, user_has_favorited: localId ? userFavSet.has(Number(localId)) : false,
is_onara_active: targetPostNo ? p.no === targetPostNo : false, is_onara_active: targetPostNo ? p.no === targetPostNo : false,
@@ -255,11 +255,13 @@ export default (router, tpl) => {
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
}); });
const rehostMap = {}; const rehostMap = {};
const rehostPaths = {};
if (cdnUrls.length > 0) { if (cdnUrls.length > 0) {
try { try {
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`; const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => { rows.forEach(r => {
const info = { id: r.id, stamp: r.stamp }; const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
rehostPaths[r.id] = info.path;
rehosts[r.src] = r.id; rehosts[r.src] = r.id;
rehostMap[r.src] = info; rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/); const m = r.src.match(/(\d{13,20})/);
@@ -363,11 +365,13 @@ export default (router, tpl) => {
}); });
const rehosts = {}; const rehosts = {};
const rehostMap = {}; const rehostMap = {};
const rehostPaths = {};
if (cdnUrls.length > 0) { if (cdnUrls.length > 0) {
try { try {
const rows = await db`SELECT id, src, stamp FROM items WHERE src = ANY(${cdnUrls})`; const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => { rows.forEach(r => {
const info = { id: r.id, stamp: r.stamp }; const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
rehostPaths[r.id] = info.path;
rehosts[r.src] = r.id; rehosts[r.src] = r.id;
rehostMap[r.src] = info; rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/); const m = r.src.match(/(\d{13,20})/);
@@ -412,7 +416,7 @@ export default (router, tpl) => {
localThumb = `/t/${li.id}.webp`; localThumb = `/t/${li.id}.webp`;
} }
if (req.session) { if (req.session) {
canManage = !!((li.username && req.session.user && li.username.toLowerCase() === req.session.user.toLowerCase()) || canManage = !!((li.username && getSessionOwnerName(req.session) && li.username.toLowerCase() === getSessionOwnerName(req.session).toLowerCase()) ||
req.session.admin || req.session.is_moderator); req.session.admin || req.session.is_moderator);
} }
} }
@@ -456,9 +460,11 @@ export default (router, tpl) => {
external_board: board, external_board: board,
external_tid: tid, external_tid: tid,
external_id: targetPost.no, external_id: targetPost.no,
external_thread_url: `https://boards.4chan.org/${board}/thread/${tid}#p${targetPost.no}`,
external_media_url: externalMediaUrl, external_media_url: externalMediaUrl,
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null, original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
local_id: localId, local_id: localId,
local_path: localId ? (rehostPaths[localId] || localId) : null,
rehosted: !!localId, rehosted: !!localId,
is_sfw: itemRating === 'sfw', is_sfw: itemRating === 'sfw',
is_nsfw: itemRating === 'nsfw', is_nsfw: itemRating === 'nsfw',
@@ -510,6 +516,7 @@ export default (router, tpl) => {
is_active: p.no === targetPost.no, is_active: p.no === targetPost.no,
index: idx + 1, index: idx + 1,
local_id: pLocalId, local_id: pLocalId,
local_path: pLocalId ? (rehostPaths[pLocalId] || pLocalId) : null,
rehosted: !!pLocalId, rehosted: !!pLocalId,
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false, user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
width: p.w || null, width: p.w || null,
@@ -542,6 +549,7 @@ export default (router, tpl) => {
user_alternative_steuerung: req.session?.user_alternative_steuerung, user_alternative_steuerung: req.session?.user_alternative_steuerung,
user_alternative_infobox: req.session?.user_alternative_infobox, user_alternative_infobox: req.session?.user_alternative_infobox,
can_manage_item: canManage, can_manage_item: canManage,
can_rate_item: canManage,
can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))), can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))),
user_has_favorited: userHasFavorited, user_has_favorited: userHasFavorited,
isSubscribed: false, isSubscribed: false,
@@ -583,13 +591,7 @@ export default (router, tpl) => {
} }
// Full page render: if Onara is active, render thread page with onara modal open // Full page render: if Onara is active, render thread page with onara modal open
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const isOnara = isOnaraEnabledFor(req);
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
const isOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (cookieOnara !== null ? cookieOnara : !!req.session?.onara);
if (isOnara) { if (isOnara) {
const isModern = req.session?.use_new_layout; const isModern = req.session?.use_new_layout;
@@ -677,10 +679,12 @@ export default (router, tpl) => {
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`); cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
}); });
const rehosts = {}; const rehosts = {};
const rehostPaths = {};
if (cdnUrls.length > 0) { if (cdnUrls.length > 0) {
try { try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdnUrls})`; const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => { rows.forEach(r => {
rehostPaths[r.id] = itemPath(r.id, r.slug);
rehosts[r.src] = r.id; rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/); const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id; if (m) rehosts[m[1]] = r.id;
@@ -705,6 +709,7 @@ export default (router, tpl) => {
is_image: !isVideo, is_image: !isVideo,
index: idx + 1, index: idx + 1,
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null, local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
local_path: rehostPaths[(p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])] || null,
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])) rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
}; };
}); });
+33 -43
View File
@@ -2,9 +2,17 @@ import cfg from "../config.mjs";
import db from "../sql.mjs"; import db from "../sql.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import queue from "../queue.mjs"; import queue from "../queue.mjs";
import { chanCurl } from "../chan_http.mjs";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
import path from "path"; import path from "path";
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs"; import { getManualApproval, getBypassDuplicateCheck, canUseChan, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
// Link path for a local item: its slug when slugs are enabled, else the numeric id
const itemPath = async (id, slug) => {
if (!getEnableItemSlugs()) return id;
if (slug === undefined) slug = (await db`SELECT slug FROM items WHERE id = ${id} LIMIT 1`)[0]?.slug;
return slug || id;
};
import { applyWordFilter } from "../wordfilter.mjs"; import { applyWordFilter } from "../wordfilter.mjs";
/** /**
@@ -45,18 +53,8 @@ export default (router) => {
* This ensures we respect the SOCKS5 proxy for all external 4chan requests. * This ensures we respect the SOCKS5 proxy for all external 4chan requests.
*/ */
async function fetchWithProxy(url, asBuffer = false) { async function fetchWithProxy(url, asBuffer = false) {
const curlArgs = [ const { bin, args } = chanCurl(url, ['--max-time', '30']);
'-s', '-f', '-L', const { stdout } = await queue.spawn(bin, args, { encoding: asBuffer ? 'buffer' : 'utf8' });
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '30',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' });
if (asBuffer) return stdout; if (asBuffer) return stdout;
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim(); const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
if (!text.startsWith('{') && !text.startsWith('[')) { if (!text.startsWith('{') && !text.startsWith('[')) {
@@ -86,6 +84,7 @@ export default (router) => {
// Check which media URLs are already rehosted on this platform // Check which media URLs are already rehosted on this platform
const rehosts = {}; const rehosts = {};
const rehostPaths = {};
const mediaPosts = posts.filter(p => p.tim && p.ext); const mediaPosts = posts.filter(p => p.tim && p.ext);
const cdn4Urls = []; const cdn4Urls = [];
mediaPosts.forEach(p => { mediaPosts.forEach(p => {
@@ -98,7 +97,8 @@ export default (router) => {
}); });
if (cdn4Urls.length > 0) { if (cdn4Urls.length > 0) {
try { try {
const rows = await db`SELECT id, src FROM items WHERE src = ANY(${cdn4Urls})`; const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdn4Urls})`;
for (const r of rows) rehostPaths[r.id] = await itemPath(r.id, r.slug);
rows.forEach(r => { rows.forEach(r => {
rehosts[r.src] = r.id; rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/); const m = r.src.match(/(\d{13,20})/);
@@ -111,7 +111,7 @@ export default (router) => {
return res.reply({ return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' }, headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
body: JSON.stringify({ success: true, posts, board, tid, rehosts }) body: JSON.stringify({ success: true, posts, board, tid, rehosts, rehost_paths: rehostPaths })
}); });
} catch (err) { } catch (err) {
@@ -295,19 +295,10 @@ export default (router) => {
}; };
const contentType = mimes[ext] || 'application/octet-stream'; const contentType = mimes[ext] || 'application/octet-stream';
const curlArgs = [ const { bin: curlBin, args: curlArgs } = chanCurl(url, ['--max-time', '60']);
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '60',
url
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
const { spawn } = await import('child_process'); const { spawn } = await import('child_process');
const curl = spawn('curl', curlArgs); const curl = spawn(curlBin, curlArgs);
res.writeHead(200, { res.writeHead(200, {
'Content-Type': contentType, 'Content-Type': contentType,
@@ -355,25 +346,22 @@ export default (router) => {
: null; : null;
const session = req.session; const session = req.session;
// Anon sessions carry user = 'anonymous'; credit the upload to the real shadow account
const ownerName = getSessionOwnerName(session);
try { try {
const uuid = await queue.genuuid(); const uuid = await queue.genuuid();
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`); const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
// Download via curl (lightweight) // Download via curl (lightweight)
const curlArgs = [ const { bin: curlBin, args: curlArgs } = chanCurl(url, [
'-s', '-f', '-L', url, '-o', tmpPath, '-o', tmpPath,
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`, '--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
'--connect-timeout', '30', '--connect-timeout', '30',
'--max-time', '300', '--max-time', '300'
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ]);
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
await queue.spawn('curl', curlArgs); await queue.spawn(curlBin, curlArgs);
// Detect MIME // Detect MIME
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim(); const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
@@ -402,7 +390,7 @@ export default (router) => {
return res.reply({ return res.reply({
code: 200, code: 200,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' }) body: JSON.stringify({ success: true, repost: true, item_id: repost, item_path: await itemPath(repost), msg: 'Already on site' })
}); });
} }
} }
@@ -424,7 +412,7 @@ export default (router) => {
return res.reply({ return res.reply({
code: 200, code: 200,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' }) body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, item_path: await itemPath(phashMatch), msg: 'Already on site (visual match)' })
}); });
} }
} }
@@ -469,7 +457,7 @@ export default (router) => {
size: (await fs.stat(path.join(destDir, filename))).size, size: (await fs.stat(path.join(destDir, filename))).size,
checksum: insertChecksum, checksum: insertChecksum,
phash: phash, phash: phash,
username: session.user, username: ownerName,
userchannel: 'web', userchannel: 'web',
usernetwork: 'web', usernetwork: 'web',
stamp: ~~(Date.now() / 1000), stamp: ~~(Date.now() / 1000),
@@ -542,7 +530,7 @@ export default (router) => {
id: itemid, id: itemid,
dest: filename, dest: filename,
mime: mime, mime: mime,
username: session.user, username: ownerName,
display_name: session.display_name || null, display_name: session.display_name || null,
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: false, is_oc: false,
@@ -575,7 +563,7 @@ export default (router) => {
return res.reply({ return res.reply({
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, item_id: itemid }) body: JSON.stringify({ success: true, item_id: itemid, item_path: await itemPath(itemid) })
}); });
} catch (err) { } catch (err) {
@@ -630,9 +618,10 @@ export default (router) => {
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) }); return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) });
} }
const isOwner = !!(rows[0].username && session.user && rows[0].username.toLowerCase() === session.user.toLowerCase()); const ownerName = getSessionOwnerName(session);
const isOwner = !!(rows[0].username && ownerName && rows[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(session.admin || session.is_moderator); const isAdmin = !!(session.admin || session.is_moderator);
const isChanUser = !!(session.admin || (Array.isArray(session.groups) && session.groups.includes('4chan'))); const isChanUser = canUseChan(session);
const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) || const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) ||
(comment && typeof comment === 'string' && comment.trim().length > 0); (comment && typeof comment === 'string' && comment.trim().length > 0);
@@ -722,6 +711,7 @@ export default (router) => {
body: JSON.stringify({ body: JSON.stringify({
success: true, success: true,
item_id: itemId, item_id: itemId,
item_path: await itemPath(itemId),
rating: ratingTag ? ratingTag.normalized : 'untagged', rating: ratingTag ? ratingTag.normalized : 'untagged',
tags: cleanTagObjects tags: cleanTagObjects
}) })
+11 -11
View File
@@ -4,7 +4,7 @@ import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs";
import { createI18n } from "../i18n.mjs"; import { createI18n } from "../i18n.mjs";
import { render502 } from "../private_items.mjs"; import { render502 } from "../private_items.mjs";
import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession } from "../settings.mjs"; import { canAnonDo, canAnonMode, isAnonSession, isAnonymizeSession, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
const auth = async (req, res, next) => { const auth = async (req, res, next) => {
if (!req.session) if (!req.session)
@@ -18,8 +18,12 @@ export default (router, tpl) => {
// But allow anon users to view their own profile // But allow anon users to view their own profile
if (isAnonymizeSession(req.session)) { if (isAnonymizeSession(req.session)) {
const requestedUser = decodeURIComponent(req.params.user).toLowerCase(); const requestedUser = decodeURIComponent(req.params.user).toLowerCase();
const sessionUser = (req.session?.user || req.session?.login || '').toLowerCase(); // Anon sessions have user = 'anonymous'; their real profile name is the shadow login (anon_xxxx)
if (requestedUser !== sessionUser) { const isSelf = !!req.session && (
requestedUser === (req.session.user || '').toLowerCase() ||
requestedUser === (req.session.login || '').toLowerCase()
);
if (!isSelf) {
return req.session ? res.redirect('/') : res.redirect('/login'); return req.session ? res.redirect('/') : res.redirect('/login');
} }
} }
@@ -427,6 +431,9 @@ export default (router, tpl) => {
// Can the current user manage this item (owner, admin, or mod)? // Can the current user manage this item (owner, admin, or mod)?
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && item.username && _ownerName.toLowerCase() === item.username.toLowerCase()));
// Is the item's MIME type suitable for metadata extraction? // Is the item's MIME type suitable for metadata extraction?
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible. // YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
@@ -499,14 +506,7 @@ export default (router, tpl) => {
return res.reply({ body }); return res.reply({ body });
} }
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const isOnara = isOnaraEnabledFor(req);
const cookieOnara = req.cookies?.f0ck_onara !== undefined
? (req.cookies.f0ck_onara === '1' || req.cookies.f0ck_onara === 'true')
: (req.cookies?.onara !== undefined ? (req.cookies.onara === '1' || req.cookies.onara === 'true') : null);
// Global config onara overweighs always the user pref
const isOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (cookieOnara !== null ? cookieOnara : !!req.session?.onara);
if (mode === 'item' && isOnara) { if (mode === 'item' && isOnara) {
const itemHtml = tpl.render('ajax-item', data, req); const itemHtml = tpl.render('ajax-item', data, req);
+209 -134
View File
@@ -16,12 +16,16 @@ export default (router, tpl) => {
// Moderator Dashboard // Moderator Dashboard
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => { router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c; const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
const trashCount = (await db`select count(*) as c from "items" where active = false and is_deleted = true and is_purged = false`)[0].c;
const reportsCount = (await db`select count(*)::int as c from reports where status = 'pending'`.catch(() => [{ c: 0 }]))[0].c;
res.reply({ res.reply({
body: tpl.render("mod", { body: tpl.render("mod", {
session: req.session, session: req.session,
pendingCount: parseInt(pendingCount), pendingCount: parseInt(pendingCount),
trashCount: parseInt(trashCount),
reportsCount: parseInt(reportsCount) || 0,
manualApproval: getManualApproval(), manualApproval: getManualApproval(),
tmp: null tmp: null
}, req) }, req)
@@ -39,11 +43,31 @@ export default (router, tpl) => {
}); });
// Approval Queue (View only — GET is safe, no state change) // Approval Queue (View only — GET is safe, no state change)
// Tag badge classes for queue cards (shared by the approval queue and soft-deleted views)
const processQueueItems = (items) => items.map(p => ({
...p,
tags: (p.tags || [])
.filter(t => t.tag !== null)
.map(t => {
let badge = "badge-light";
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
else if (t.normalized === "german") badge = "badge-german badge-light";
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") badge = "badge-success";
else if (t.normalized === "nsfw") badge = "badge-danger";
return { ...t, badge };
})
}));
const QUEUE_PAGE_SIZE = 20;
const queuePage = (req) => Math.max(1, +req.url.qs.page || 1);
// Approval queue: uploads waiting for approval (not deleted)
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => { router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
// View Queue const page = queuePage(req);
const page = +req.url.qs.page || 1; const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
const limit = 20;
// Fetch Pending (not deleted)
const pending = await db` const pending = await db`
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
from "items" i from "items" i
@@ -52,10 +76,25 @@ export default (router, tpl) => {
where i.active = false and i.is_deleted = false where i.active = false and i.is_deleted = false
group by i.id group by i.id
order by i.id desc order by i.id desc
limit ${limit} offset ${(page - 1) * limit} limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
`; `;
// Fetch Trash (deleted) res.reply({
body: tpl.render('mod/approve', {
pending: processQueueItems(pending),
total,
page,
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
session: req.session,
tmp: null
}, req)
});
});
// Soft deleted: removed items that still exist on disk and can be restored or purged
router.get(/^\/mod\/trash\/?$/, lib.modAuth, async (req, res) => {
const page = queuePage(req);
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = true and is_purged = false`;
const trash = await db` const trash = await db`
select i.id, i.mime, i.username, i.dest, select i.id, i.mime, i.username, i.dest,
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
@@ -66,45 +105,121 @@ export default (router, tpl) => {
where i.active = false and i.is_deleted = true and i.is_purged = false where i.active = false and i.is_deleted = true and i.is_purged = false
group by i.id group by i.id
order by i.id desc order by i.id desc
limit 20 limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
`; `;
const processItems = (items) => {
return items.map(p => {
const tags = (p.tags || [])
.filter(t => t.tag !== null)
.map(t => {
let badge = "badge-light";
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
else if (t.normalized === "german") badge = "badge-german badge-light";
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") badge = "badge-success";
else if (t.normalized === "nsfw") badge = "badge-danger";
return { ...t, badge };
});
return {
...p,
tags
};
});
};
res.reply({ res.reply({
body: tpl.render('mod/approve', { body: tpl.render('mod/trash', {
pending: processItems(pending), trash: processQueueItems(trash),
trash: processItems(trash), total,
page, page,
stats: { total: pending.length + trash.length }, pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
session: req.session, session: req.session,
tmp: null tmp: null
}, req) }, req)
}); });
}); });
const jsonReply = (res, code, obj) => {
const body = JSON.stringify(obj);
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
// Move an item's media (and album sub-items) from the pending or deleted folder back to the public folders.
const moveItemFilesToPublic = async (item, id) => {
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.deleted, 'b', item.dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
];
const isYouTube = item.mime === 'video/youtube';
for (const p of movePaths) {
try {
if (isYouTube) {
await fs.access(p.t);
} else {
await fs.access(p.b);
}
console.log(`[MOD MOVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.warn(`[MOD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
}
};
const bDst = path.join(cfg.paths.b, item.dest);
const tDst = path.join(cfg.paths.t, `${id}.webp`);
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
if (!isYouTube) {
await moveSafe(p.b, bDst);
}
await moveSafe(p.t, tDst);
const blurSrc = p.t.replace('.webp', '_blur.webp');
await moveSafe(blurSrc, blurDst);
if (item.mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
if (item.is_album) {
try {
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
for (const sub of subItems) {
const subBase = sub.dest.replace(/\.[^.]+$/, '');
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
}
} catch (_) {}
}
break;
} catch (e) { }
}
};
// Permanently remove an item's files and comments and flag it purged (admin action).
const purgeItem = async (item, id) => {
await safeDeleteMediaFile(item.dest, id);
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.t, `${id}_blur.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}_blur.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${id}`;
await db`delete from comments where item_id = ${id}`;
};
const uploaderInfoFor = async (username) => {
try {
const u = await db`select id, "user" as username from "user" where login = ${username} or "user" = ${username} limit 1`;
return u.length ? { uploader_id: u[0].id, uploader_name: u[0].username } : {};
} catch { return {}; }
};
// F-005 Security: Approve action — POST with CSRF protection // F-005 Security: Approve action — POST with CSRF protection
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => { router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
const id = +(req.post?.id || 0); const id = +(req.post?.id || 0);
@@ -120,12 +235,12 @@ export default (router, tpl) => {
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl')
limit 1) as tag_id limit 1) as tag_id
from "items" i from "items" i
where i.id = ${id} and i.active = false where i.id = ${id} and i.active = false and i.is_deleted = false
limit 1 limit 1
`; `;
if (f0ck.length === 0) { if (f0ck.length === 0) {
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` }); const body = JSON.stringify({ success: false, msg: `f0ck ${id}: not in the approval queue` });
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
} }
@@ -230,72 +345,7 @@ export default (router, tpl) => {
} }
} }
// Move files to public location await moveItemFilesToPublic(f0ck[0], id);
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
];
const isYouTube = f0ck[0].mime === 'video/youtube';
for (const p of movePaths) {
try {
if (isYouTube) {
await fs.access(p.t);
} else {
await fs.access(p.b);
}
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
}
};
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
const tDst = path.join(cfg.paths.t, `${id}.webp`);
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
if (!isYouTube) {
await moveSafe(p.b, bDst);
}
await moveSafe(p.t, tDst);
const blurSrc = p.t.replace('.webp', '_blur.webp');
await moveSafe(blurSrc, blurDst);
if (f0ck[0].mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
if (f0ck[0].is_album) {
try {
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
for (const sub of subItems) {
const subBase = sub.dest.replace(/\.[^.]+$/, '');
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
}
} catch (_) {}
}
break;
} catch (e) { }
}
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) { if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" }); const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
@@ -320,26 +370,11 @@ export default (router, tpl) => {
if (item.is_deleted) { if (item.is_deleted) {
// PURGE LOGIC (Strict Admin) // PURGE LOGIC (Strict Admin)
if (!req.session.admin) { if (!req.session.admin) {
return res.reply({ success: false, msg: "Only admins can purge items permanently." }); const body = JSON.stringify({ success: false, msg: "Only admins can purge items permanently." });
return res.writeHead(403, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
} }
// Delete files — respect symlink ownership await purgeItem(item, id);
await safeDeleteMediaFile(item.dest, id);
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
}
// DB Flag instead of delete
await db`update "items" set is_purged = true where id = ${id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${id}`;
// Fetch uploader details for audit log // Fetch uploader details for audit log
let uploaderInfo = {}; let uploaderInfo = {};
@@ -701,26 +736,66 @@ export default (router, tpl) => {
} }
}); });
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
router.post(/^\/mod\/trash\/restore\/?$/, lib.modAuth, async (req, res) => {
const id = +(req.post?.id || 0);
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
const rows = await db`
select i.id, i.dest, i.mime, i.username, i.visibility, i.is_album, i.album_count, i.is_oc,
(select ta2.tag_id from tags_assign ta2 join tags t2 on t2.id = ta2.tag_id
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') limit 1) as tag_id
from "items" i
where i.id = ${id} and i.is_deleted = true and i.is_purged = false
limit 1
`;
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
const item = rows[0];
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`;
if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' });
await moveItemFilesToPublic(item, id);
await audit.log(req.session.id, 'restore_item', 'item', id, { filename: item.dest, ...(await uploaderInfoFor(item.username)) });
// Live grid update so the item reappears for open tabs
if ((item.visibility || 0) === 0) {
db`SELECT pg_notify('new_item', ${JSON.stringify({
id, dest: item.dest, mime: item.mime, username: item.username, tag_id: item.tag_id,
is_oc: !!item.is_oc, is_album: !!item.is_album, album_count: item.album_count || 0
})})`.catch(err => console.error('[MOD RESTORE] new_item notify failed:', err));
}
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item restored' });
});
// Permanently purge one soft-deleted item (admins only)
router.post(/^\/mod\/trash\/purge\/?$/, lib.auth, async (req, res) => {
const id = +(req.post?.id || 0);
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
const reason = (req.post?.reason || '').toString().trim();
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
const rows = await db`select id, dest, mime, username from "items" where id = ${id} and is_deleted = true and is_purged = false limit 1`;
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
const item = rows[0];
await purgeItem(item, id);
await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, reason, ...(await uploaderInfoFor(item.username)) });
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item purged' });
});
// Purge Trash (POST) - Strict Admin // Purge Trash (POST) - Strict Admin
router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => { router.post(/^\/mod\/(?:purge-trash-all|trash\/purge-all)\/?$/, lib.auth, async (req, res) => {
try { try {
// lib.auth already ensures session.admin // lib.auth already ensures session.admin
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`; const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
let count = 0; let count = 0;
for (const item of trash) { for (const item of trash) {
try { try {
await safeDeleteMediaFile(item.dest, item.id); await purgeItem(item, item.id);
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${item.id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${item.id}`;
count++; count++;
} catch (e) { } } catch (e) { }
} }
+4 -1
View File
@@ -1,7 +1,7 @@
import db from "../sql.mjs"; import db from "../sql.mjs";
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import { isAnonymizeSession } from "../settings.mjs"; import { isAnonymizeSession, canAnonDo, getSessionOwnerName } from "../settings.mjs";
import f0cklib from "../routeinc/f0cklib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs";
import fs from "fs/promises"; import fs from "fs/promises";
import path from "path"; import path from "path";
@@ -174,6 +174,9 @@ export default (router, tpl) => {
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_'))))); const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator)); data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase()))); data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && item.username && _ownerName.toLowerCase() === item.username.toLowerCase()));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))); data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites); data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : ''; data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
+32
View File
@@ -47,6 +47,7 @@ export const DEFAULT_ANON_PERMISSIONS = Object.freeze({
rate_item: false, rate_item: false,
filter: true, filter: true,
exclude_tags: true, exclude_tags: true,
chan: false, // 4chan viewer & rehost ("4chan mode")
anonymize_users: false, anonymize_users: false,
allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'], allowed_modes: ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'],
allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf'] allowed_mimes: ['image', 'video', 'audio', 'flash', 'pdf']
@@ -124,6 +125,37 @@ export const isAnonSession = (session) => {
return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_'))); return !!(session.is_anon || session.user === 'anonymous' || (typeof session.user === 'string' && session.user.startsWith('anon_')));
}; };
// Onara viewer is a per-user setting (cookie f0ck_onara, legacy cookie onara, or session value).
// config `onara: false` disables it for everyone; `onara: true` is only the default for users
// without a stored preference. `forceOn` covers explicit requests like ?onara=1.
export const isOnaraEnabledFor = (req, forceOn = false) => {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
if (c === false) return false;
if (forceOn) return true;
const ck = req?.cookies || {};
const raw = ck.f0ck_onara !== undefined ? ck.f0ck_onara : ck.onara;
if (raw !== undefined) return raw === '1' || raw === 'true';
if (req?.session && req.session.onara !== undefined && req.session.onara !== null) return !!req.session.onara;
return c === true;
};
// The name items.username holds for this session's uploads. Anonymous sessions have
// user = 'anonymous' (shared by all anon users); their real account is the shadow login (anon_xxxx).
export const getSessionOwnerName = (session) => {
if (!session || typeof session !== 'object') return null;
if (session.is_anon) return session.anon_login || session.login || null;
return session.user || null;
};
// 4chan viewer & rehost access: admins, members of the '4chan' group, and anonymous
// users when anonymous_permissions.chan is enabled
export const canUseChan = (session) => {
if (!session || typeof session !== 'object') return false;
if (session.admin) return true;
if (Array.isArray(session.groups) && session.groups.includes('4chan')) return true;
return !!(session.is_anon && canAnonDo('chan'));
};
export const checkAnonPermission = (session, action) => { export const checkAnonPermission = (session, action) => {
if (!isAnonSession(session)) return true; if (!isAnonSession(session)) return true;
return canAnonDo(action); return canAnonDo(action);
+40 -14
View File
@@ -21,7 +21,7 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_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, getBrandImageUrl, setBrandImageUrl } 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, canUseChan, isOnaraEnabledFor, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs"; import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs"; import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
@@ -647,6 +647,28 @@ process.on('uncaughtException', err => {
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON album_items_tags_assign(tag_id)`); await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON album_items_tags_assign(tag_id)`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON album_items_tags_assign(album_item_id)`); await runMigration(db`CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON album_items_tags_assign(album_item_id)`);
// Ensure the NSFL rating tag exists under cfg.nsfl_tag_id — rating changes and feed filters
// all reference that id, and tags_assign has a FK on tags.id
if (cfg.enable_nsfl) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10);
if (nsflId > 0) {
try {
const [byId] = await db`SELECT id, normalized FROM tags WHERE id = ${nsflId} LIMIT 1`;
const [byName] = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
if (byId && byId.normalized !== 'nsfl') {
console.warn(`[BOOT] nsfl_tag_id ${nsflId} points to tag "${byId.normalized}", not "nsfl" — check config`);
} else if (!byId && byName) {
console.warn(`[BOOT] NSFL tag exists as id ${byName.id} but nsfl_tag_id is ${nsflId} — set nsfl_tag_id: ${byName.id} in config`);
} else if (!byId) {
await db`INSERT INTO tags (id, tag) VALUES (${nsflId}, 'nsfl') ON CONFLICT DO NOTHING`;
console.log(`[BOOT] Created missing NSFL rating tag (id ${nsflId})`);
}
} catch (e) {
console.error('[BOOT] NSFL tag check failed:', e.message);
}
}
}
// Initial halls cache (only if halls are enabled) // Initial halls cache (only if halls are enabled)
if (cfg.websrv.halls_enabled !== false) { if (cfg.websrv.halls_enabled !== false) {
try { try {
@@ -1026,7 +1048,7 @@ process.on('uncaughtException', err => {
} }
// csrf_token is loaded from user_sessions table via the session query above // csrf_token is loaded from user_sessions table via the session query above
if (req.session) { if (req.session) {
req.session.can_chan = !!(req.session.admin || (Array.isArray(req.session.groups) && req.session.groups.includes('4chan'))); req.session.can_chan = canUseChan(req.session);
} }
// Ban check (Session) // Ban check (Session)
@@ -1955,9 +1977,11 @@ process.on('uncaughtException', err => {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
return !!c; return !!c;
}, },
// onara_cfg locks the per-user setting; only `onara: false` (feature disabled) does that.
// `onara: true` is just the default for users who haven't chosen.
get onara_cfg() { get onara_cfg() {
const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const c = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
return (c !== undefined && c !== null) ? !!c : null; return c === false ? false : null;
}, },
is_onara_item: false, is_onara_item: false,
can_extract_meta: false, can_extract_meta: false,
@@ -2120,7 +2144,7 @@ process.on('uncaughtException', err => {
const activeSession = activeReq?.session || data?.session || null; const activeSession = activeReq?.session || data?.session || null;
const isAnonymized = isAnonymizeSession(activeSession); const isAnonymized = isAnonymizeSession(activeSession);
const anonAnonymize = getAnonAnonymize(); const anonAnonymize = getAnonAnonymize();
const canChan = !!(activeSession && (activeSession.admin || (Array.isArray(activeSession.groups) && activeSession.groups.includes('4chan')))); const canChan = canUseChan(activeSession);
if (activeSession && typeof activeSession === 'object') { if (activeSession && typeof activeSession === 'object') {
activeSession.can_chan = canChan; activeSession.can_chan = canChan;
} }
@@ -2135,6 +2159,13 @@ process.on('uncaughtException', err => {
is_anonymized: isAnonymized, is_anonymized: isAnonymized,
anon_anonymize: anonAnonymize, anon_anonymize: anonAnonymize,
can_chan: canChan, can_chan: canChan,
// Item partials read can_rate_item; routes that only set can_manage_item fall back to it
can_rate_item: !!(data && (data.can_rate_item ?? data.can_manage_item)),
// Moderator-area page (/mod…): the back bar points to the moderator dashboard instead of /admin
// Includes are rendered without req; keep the value the page render already computed
is_mod_area: (data && typeof data.is_mod_area === 'boolean')
? data.is_mod_area
: !!(activeReq?.url?.pathname && /^\/mod(\/|$)/.test(activeReq.url.pathname)),
item_has_dimensions: !!(data && data.item && data.item.width && data.item.height), item_has_dimensions: !!(data && data.item && data.item.width && data.item.height),
user_alternative_infobox: useAltInfobox, user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung, user_alternative_steuerung: useAltSteuerung,
@@ -2169,15 +2200,10 @@ process.on('uncaughtException', err => {
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1)); data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload; data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
// Onara is a per-user setting; config `onara: false` disables it, `onara: true` is only the default
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
const cookieOnara = activeReq.cookies?.f0ck_onara !== undefined data.onara = isOnaraEnabledFor(activeReq);
? (activeReq.cookies.f0ck_onara === '1' || activeReq.cookies.f0ck_onara === 'true') data.onara_cfg = cfgOnara === false ? false : null;
: (activeReq.cookies?.onara !== undefined ? (activeReq.cookies.onara === '1' || activeReq.cookies.onara === 'true') : null);
const effectiveOnara = (cfgOnara !== undefined && cfgOnara !== null)
? !!cfgOnara
: (cookieOnara !== null ? cookieOnara : !!activeReq.session?.onara);
data.onara = effectiveOnara;
data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null;
} else { } else {
data.recaptcha_enabled = perRequestRecaptcha; data.recaptcha_enabled = perRequestRecaptcha;
data.theme = data.theme || cfg.websrv.theme || 'f0ck'; data.theme = data.theme || cfg.websrv.theme || 'f0ck';
@@ -2186,8 +2212,8 @@ process.on('uncaughtException', err => {
globals.csrf_token = data.csrf_token || ''; globals.csrf_token = data.csrf_token || '';
const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara; const cfgOnara = cfg.onara !== undefined ? cfg.onara : cfg.websrv?.onara;
data.onara = !!cfgOnara; data.onara = cfgOnara === true;
data.onara_cfg = (cfgOnara !== undefined && cfgOnara !== null) ? !!cfgOnara : null; data.onara_cfg = cfgOnara === false ? false : null;
} }
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req); return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
+205 -117
View File
@@ -1,136 +1,230 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
<div class="container"> <div class="container adm">
<h1>ADMINBEREICH</h1>
<h5>Hallo, {{ session.user }}</h5> <!-- Header -->
<span>Hier entsteht eine Internetpräsenz!</span><br> <header class="adm-header">
<hr> <div>
<p>f0ck stats: @if(typeof totals !== "undefined")total: {{ totals.total }} | tagged: {{ totals.tagged }} | untagged: {{ totals.untagged }} | sfw: {{ totals.sfw }} | nsfw: {{ totals.nsfw }}@endif</p> <h1 class="adm-title">Admin</h1>
<hr> <div class="adm-subtitle">Signed in as <strong>{{ session.user }}</strong></div>
<div class="admintools"> </div>
<p>Adminwerkzeuge</p> <a href="/mod" class="adm-btn adm-btn-ghost"><i class="fa-solid fa-gavel"></i> Moderator panel</a>
<ul> </header>
<li><a href="/mod/audit">Audit Log</a></li>
<li><a href="/admin/approve">Approval Queue</a></li> <!-- Overview -->
<li><a href="/admin/sessions">Sessions</a></li> <section class="adm-section">
<li><a href="/admin/tokens">Invite Tokens</a></li> <h2 class="adm-section-title"><i class="fa-solid fa-chart-simple"></i> Overview</h2>
<li><a href="/admin/users">User Manager</a></li> @if(typeof totals !== "undefined")
<li><a href="/admin/bans">Ban & Fingerprint Manager</a></li> <div class="adm-stats">
<li><a href="/admin/emojis">Emoji Manager</a></li> <div class="adm-stat"><span class="adm-stat-num">{{ totals.total }}</span><span class="adm-stat-label">Posts</span></div>
<li><a href="/admin/memes">Meme Manager</a></li> <div class="adm-stat"><span class="adm-stat-num">{{ totals.sfw }}</span><span class="adm-stat-label">SFW</span></div>
<li><a href="/admin/koepfe">Köpfe Manager</a></li> <div class="adm-stat"><span class="adm-stat-num">{{ totals.nsfw }}</span><span class="adm-stat-label">NSFW</span></div>
<li><a href="/admin/halls">Hall Manager</a></li> @if(enable_nsfl)
<li><a href="/admin/motd">MOTD Manager</a></li> <div class="adm-stat"><span class="adm-stat-num">{{ totals.nsfl }}</span><span class="adm-stat-label">NSFL</span></div>
<li><a href="/admin/wordfilter">Wordfilter Manager</a></li> @endif
<li><a href="/admin/nsfp">NSFP Tag Manager</a></li> <div class="adm-stat"><span class="adm-stat-num">{{ totals.untagged }}</span><span class="adm-stat-label">Untagged</span></div>
<a href="/admin/approve" class="adm-stat adm-stat-link @if(totals.pending > 0) is-hot @endif"><span class="adm-stat-num">{{ totals.pending }}</span><span class="adm-stat-label">Pending approval</span></a>
<a href="/mod/trash" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.trash }}</span><span class="adm-stat-label">Soft deleted</span></a>
<a href="/mod/reports" class="adm-stat adm-stat-link @if(dash.open_reports > 0) is-hot @endif"><span class="adm-stat-num">{{ dash.open_reports }}</span><span class="adm-stat-label">Open reports</span></a>
<a href="/admin/users" class="adm-stat adm-stat-link"><span class="adm-stat-num">{{ dash.users }}</span><span class="adm-stat-label">Users <small>+{{ dash.anon_users }} anon</small></span></a>
</div>
@endif
</section>
<!-- Tools -->
<section class="adm-section">
<h2 class="adm-section-title"><i class="fa-solid fa-screwdriver-wrench"></i> Tools</h2>
<h3 class="adm-group-title">Moderation</h3>
<div class="adm-tools">
<a href="/admin/approve" class="adm-tool">
<i class="fa-solid fa-clock"></i>
<span class="adm-tool-text"><strong>Approval Queue @if(totals.pending > 0)<span class="adm-count">{{ totals.pending }}</span>@endif</strong><span>Review uploads waiting for approval</span></span>
</a>
<a href="/mod/trash" class="adm-tool">
<i class="fa-solid fa-trash-can"></i>
<span class="adm-tool-text"><strong>Soft Deleted @if(dash.trash > 0)<span class="adm-count">{{ dash.trash }}</span>@endif</strong><span>Restore or permanently purge removed items</span></span>
</a>
<a href="/mod/reports" class="adm-tool">
<i class="fa-solid fa-flag"></i>
<span class="adm-tool-text"><strong>User Reports @if(dash.open_reports > 0)<span class="adm-count">{{ dash.open_reports }}</span>@endif</strong><span>Reported posts, comments and users</span></span>
</a>
<a href="/mod/audit" class="adm-tool">
<i class="fa-solid fa-scroll"></i>
<span class="adm-tool-text"><strong>Audit Log</strong><span>Every moderation and admin action</span></span>
</a>
</div>
<h3 class="adm-group-title">Users &amp; access</h3>
<div class="adm-tools">
<a href="/admin/users" class="adm-tool">
<i class="fa-solid fa-users"></i>
<span class="adm-tool-text"><strong>User Manager</strong><span>Accounts, roles and groups</span></span>
</a>
<a href="/admin/bans" class="adm-tool">
<i class="fa-solid fa-ban"></i>
<span class="adm-tool-text"><strong>Bans &amp; Fingerprints</strong><span>IP, hardware and passkey bans</span></span>
</a>
<a href="/admin/sessions" class="adm-tool">
<i class="fa-solid fa-key"></i>
<span class="adm-tool-text"><strong>Sessions</strong><span>Active logins across all users</span></span>
</a>
<a href="/admin/tokens" class="adm-tool">
<i class="fa-solid fa-ticket"></i>
<span class="adm-tool-text"><strong>Invite Tokens</strong><span>Create and revoke invites</span></span>
</a>
</div>
<h3 class="adm-group-title">Content</h3>
<div class="adm-tools">
<a href="/admin/halls" class="adm-tool">
<i class="fa-solid fa-layer-group"></i>
<span class="adm-tool-text"><strong>Halls</strong><span>Curated collections</span></span>
</a>
<a href="/admin/emojis" class="adm-tool">
<i class="fa-solid fa-face-smile"></i>
<span class="adm-tool-text"><strong>Emojis</strong><span>Custom emojis and sticker packs</span></span>
</a>
<a href="/admin/memes" class="adm-tool">
<i class="fa-regular fa-image"></i>
<span class="adm-tool-text"><strong>Memes</strong><span>Meme creator templates</span></span>
</a>
<a href="/admin/koepfe" class="adm-tool">
<i class="fa-solid fa-masks-theater"></i>
<span class="adm-tool-text"><strong>Köpfe</strong><span>Floating head images</span></span>
</a>
<a href="/admin/nsfp" class="adm-tool">
<i class="fa-solid fa-eye-slash"></i>
<span class="adm-tool-text"><strong>NSFP Tags</strong><span>Tags hidden from the public</span></span>
</a>
<a href="/admin/wordfilter" class="adm-tool">
<i class="fa-solid fa-filter"></i>
<span class="adm-tool-text"><strong>Wordfilter</strong><span>Replace words in comments</span></span>
</a>
@if(enable_cleanup) @if(enable_cleanup)
<li><a href="/admin/cleanup">Cleanup Manager</a></li> <a href="/admin/cleanup" class="adm-tool">
<i class="fa-solid fa-broom"></i>
<span class="adm-tool-text"><strong>Cleanup</strong><span>Remove old unengaged uploads</span></span>
</a>
@endif @endif
<li><a href="/admin/about">About Page</a></li> </div>
<li><a href="/admin/rules">Rules Page</a></li>
<li><a href="/admin/terms">ToS Page</a></li> <h3 class="adm-group-title">Site</h3>
<div class="adm-tools">
<a href="/admin/motd" class="adm-tool">
<i class="fa-solid fa-bullhorn"></i>
<span class="adm-tool-text"><strong>MOTD</strong><span>Message of the day banner</span></span>
</a>
<a href="/admin/about" class="adm-tool">
<i class="fa-solid fa-circle-info"></i>
<span class="adm-tool-text"><strong>About Page</strong><span>Edit /about</span></span>
</a>
<a href="/admin/rules" class="adm-tool">
<i class="fa-solid fa-list-check"></i>
<span class="adm-tool-text"><strong>Rules Page</strong><span>Edit /rules</span></span>
</a>
<a href="/admin/terms" class="adm-tool">
<i class="fa-solid fa-file-contract"></i>
<span class="adm-tool-text"><strong>ToS Page</strong><span>Edit /terms</span></span>
</a>
@if(enable_global_chat) @if(enable_global_chat)
<li><a href="/admin/chat">Global Chat Manager</a></li> <a href="/admin/chat" class="adm-tool">
<i class="fa-solid fa-comments"></i>
<span class="adm-tool-text"><strong>Global Chat</strong><span>Moderate the site chat</span></span>
</a>
@endif @endif
</ul> </div>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);"> </section>
<!-- Navbar Brand Image --> <!-- Settings -->
<div class="settings-item" id="brand-image-section" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; margin-top: 10px;"> <section class="adm-section">
<label style="display: block; font-weight: bold; color: var(--accent); margin-bottom: 8px;">Navbar Brand Image</label> <div class="adm-section-head">
<p style="margin: 0 0 12px 0; font-size: 0.8em; color: #aaa;">Upload a logo to display in the site navbar instead of plain text. Accepted: gif, jpg, png, webp, svg &mdash; max 5&nbsp;MB.</p> <h2 class="adm-section-title"><i class="fa-solid fa-sliders"></i> Settings</h2>
<span id="settings-status" class="adm-status"></span>
<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> </div>
<hr style="margin: 20px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);"> <h3 class="adm-group-title">Uploads</h3>
<div class="adm-panel">
<div class="adm-row">
<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 class="adm-row-text">
<div> <label for="manual_approval_toggle">Manual upload approval</label>
<label style="display: block; font-weight: bold; color: var(--accent);">Manual Upload Approval</label> <p>If enabled, mods must approve every upload.</p>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">If enabled, mods must approve every upload.</p> </div>
<label class="adm-switch">
<input type="checkbox" id="manual_approval_toggle" {{ manual_approval ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="adm-slider"></span>
</label>
</div>
<div class="adm-row">
<div class="adm-row-text">
<label for="min_tags_input">Minimum tags</label>
<p>Minimum number of tags required per upload.</p>
</div>
<input type="number" id="min_tags_input" class="adm-number" value="{{ min_tags }}" min="0" max="20" onchange="saveAdminSettings()">
</div>
<div class="adm-row">
<div class="adm-row-text">
<label for="trusted_uploads_input">Trusted upload threshold</label>
<p>Users with fewer approved uploads than this still need manual approval, even if the toggle above is off. 0 disables it.</p>
</div>
<input type="number" id="trusted_uploads_input" class="adm-number" value="{{ trusted_uploads }}" min="0" max="999" onchange="saveAdminSettings()">
</div> </div>
<label class="switch">
<input type="checkbox" id="manual_approval_toggle" {{ manual_approval ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="slider round"></span>
</label>
</div> </div>
@if(registration_web_toggle_enabled) @if(registration_web_toggle_enabled)
<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; margin-top: 10px;"> <h3 class="adm-group-title">Registration</h3>
<div> <div class="adm-panel">
<label style="display: block; font-weight: bold; color: var(--accent);">Open Registration</label> <div class="adm-row">
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Allow registration without invite tokens. Requires email activation.</p> <div class="adm-row-text">
<label for="registration_open_toggle">Open registration</label>
<p>Allow registration without invite tokens. Requires email activation.</p>
</div>
<label class="adm-switch">
<input type="checkbox" id="registration_open_toggle" {{ registration_open ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="adm-slider"></span>
</label>
</div> </div>
<label class="switch">
<input type="checkbox" id="registration_open_toggle" {{ registration_open ? 'checked' : '' }} onchange="saveAdminSettings()">
<span class="slider round"></span>
</label>
</div> </div>
@endif @endif
<h3 class="adm-group-title">Branding</h3>
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;"> <div class="adm-panel" id="brand-image-section">
<div> <div class="adm-row adm-row-wrap">
<label style="display: block; font-weight: bold; color: var(--accent);">Minimum Tags</label> <div class="adm-row-text">
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Minimum number of tags required per upload.</p> <label>Navbar brand image</label>
<p>Shown in the navbar instead of the site name. gif, jpg, png, webp or svg, max 5&nbsp;MB.</p>
</div>
<div class="adm-brand">
<div id="brand-preview-wrap" class="adm-brand-preview">
@if(current_brand_image)
<img id="brand-preview" src="{{ current_brand_image }}" alt="current brand">
@else
<span id="brand-preview" class="adm-muted">No image set</span>
@endif
</div>
<label for="brand-file-input" class="adm-btn adm-btn-primary">Choose image</label>
<input type="file" id="brand-file-input" accept="image/gif,image/jpeg,image/png,image/webp,image/svg+xml" hidden onchange="uploadBrandImage(this)">
<button type="button" id="brand-remove-btn" class="adm-btn adm-btn-danger" onclick="removeBrandImage()" @if(!current_brand_image) disabled @endif>Remove</button>
</div>
</div> </div>
<input type="number" id="min_tags_input" value="{{ min_tags }}" min="0" max="20" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()"> <span id="brand-status" class="adm-status adm-status-row"></span>
</div> </div>
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;"> <h3 class="adm-group-title">Maintenance</h3>
<div> <div class="adm-panel">
<label style="display: block; font-weight: bold; color: var(--accent);">Trusted Upload Threshold</label> <div class="adm-row">
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">New users with fewer than this many approved uploads must still go through manual approval, even if the global toggle is off. Set to 0 to disable.</p> <div class="adm-row-text">
<label>Tag image cache</label>
<p>Regenerate thumbnails for the /tags page. Runs in the background and may take a while.</p>
</div>
<button type="button" onclick="regenerateTagImages()" id="regen_tag_btn" class="adm-btn adm-btn-ghost">Regenerate all</button>
</div> </div>
<input type="number" id="trusted_uploads_input" value="{{ trusted_uploads }}" min="0" max="999" style="width: 60px; background: #333; border: 1px solid #444; color: #fff; padding: 5px; border-radius: 4px; text-align: center;" onchange="saveAdminSettings()">
</div> </div>
</section>
<div class="settings-item" style="background: rgba(0,0,0,0.2); padding: 15px; border-radius: 4px; display: flex; align-items: center; justify-content: space-between; margin-top: 10px;">
<div>
<label style="display: block; font-weight: bold; color: var(--accent);">Tag Image Cache</label>
<p style="margin: 2px 0 0 0; font-size: 0.8em; color: #aaa;">Regenerate thumbnails for the /tags page. This may take a while.</p>
</div>
<button onclick="regenerateTagImages()" id="regen_tag_btn" style="background: #007bff; border: 0; color: #fff; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 0.8em; font-weight: bold;">Regenerate All</button>
</div>
<span id="settings-status" style="display: block; margin-top: 10px; font-size: 0.8em; font-weight: bold; text-align: right;"></span>
</div>
</div> </div>
<style> @include(snippets/adm-dashboard-style)
.switch { position: relative; display: inline-block; width: 50px; height: 26px; }
.switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #333; transition: .4s; border-radius: 34px; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 4px; bottom: 4px; background-color: white; transition: .4s; border-radius: 50%; }
input:checked + .slider { background-color: var(--accent); }
input:checked + .slider:before { transform: translateX(24px); background-color: #000; }
</style>
<script> <script>
@@ -212,7 +306,7 @@
btn.textContent = 'Started!'; btn.textContent = 'Started!';
setTimeout(() => { setTimeout(() => {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Regenerate All'; btn.textContent = 'Regenerate all';
status.textContent = ''; status.textContent = '';
}, 5000); }, 5000);
} else { } else {
@@ -223,7 +317,7 @@
status.textContent = 'Error: ' + err.message; status.textContent = 'Error: ' + err.message;
status.style.color = '#d9534f'; status.style.color = '#d9534f';
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Regenerate All'; btn.textContent = 'Regenerate all';
} }
} }
@@ -263,7 +357,6 @@
preview = document.createElement('img'); preview = document.createElement('img');
preview.id = 'brand-preview'; preview.id = 'brand-preview';
preview.alt = 'current brand'; preview.alt = 'current brand';
preview.style.cssText = 'max-height:48px;max-width:150px;object-fit:contain;';
wrap.appendChild(preview); wrap.appendChild(preview);
} }
preview.src = data.url; preview.src = data.url;
@@ -271,9 +364,7 @@
// Immediately update navbar brand logo // Immediately update navbar brand logo
if (window.updateBrandImage) window.updateBrandImage(data.url); if (window.updateBrandImage) window.updateBrandImage(data.url);
// Enable remove button
removeBtn.disabled = false; 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); setTimeout(() => { status.textContent = ''; }, 3000);
} catch (err) { } catch (err) {
@@ -311,14 +402,12 @@
// Reset preview // Reset preview
const wrap = document.getElementById('brand-preview-wrap'); 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>'; wrap.innerHTML = '<span id="brand-preview" class="adm-muted">No image set</span>';
// Immediately update navbar brand logo (revert to text) // Immediately update navbar brand logo (revert to text)
if (window.updateBrandImage) window.updateBrandImage(null); if (window.updateBrandImage) window.updateBrandImage(null);
// Disable remove button
removeBtn.disabled = true; 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); setTimeout(() => { status.textContent = ''; }, 3000);
} catch (err) { } catch (err) {
@@ -327,7 +416,6 @@
} }
} }
</script> </script>
</div> </div>
</div> </div>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>About Page Content</h2> <h2>About Page Content</h2>
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/about</strong> page. Supports Markdown. Leave empty to show the default static template.</p> <p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/about</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
+4 -2
View File
@@ -2,6 +2,7 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container" style="max-width: 1200px; margin: 0 auto; padding: 20px 15px;"> <div class="container" style="max-width: 1200px; margin: 0 auto; padding: 20px 15px;">
<div style="margin-bottom: 25px;"> <div style="margin-bottom: 25px;">
<a href="/admin" style="color: var(--accent); text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 12px;"> <a href="/admin" style="color: var(--accent); text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 6px; margin-bottom: 12px;">
@@ -473,9 +474,8 @@
</div> </div>
</div> </div>
</div>
</div>
<!-- Modals, styles and script live inside #main: AJAX navigation only swaps #main -->
<!-- Modal: Ban Fingerprint or IP --> <!-- Modal: Ban Fingerprint or IP -->
<div id="ban-modal" class="modal-overlay" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 10000; align-items: center; justify-content: center;"> <div id="ban-modal" class="modal-overlay" style="display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.75); z-index: 10000; align-items: center; justify-content: center;">
<div style="background: #1e1e1e; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; width: 92%; max-width: 500px; padding: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.8);"> <div style="background: #1e1e1e; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; width: 92%; max-width: 500px; padding: 25px; box-shadow: 0 10px 30px rgba(0,0,0,0.8);">
@@ -784,5 +784,7 @@ async function unbanIp(ip) {
} }
} }
</script> </script>
</div>
</div>
@include(snippets/footer) @include(snippets/footer)
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h1>ADMINBEREICH</h1> <h1>ADMINBEREICH</h1>
<h5>Global Chat Management</h5> <h5>Global Chat Management</h5>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Cleanup Manager</h2> <h2>Cleanup Manager</h2>
<p style="color: #ccc; margin-bottom: 20px;">Delete old posts that have no engagement (no comments, no favorites, and no subscriptions) to regain disk space.</p> <p style="color: #ccc; margin-bottom: 20px;">Delete old posts that have no engagement (no comments, no favorites, and no subscriptions) to regain disk space.</p>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Custom Emojis &amp; Sticker Packs</h2> <h2>Custom Emojis &amp; Sticker Packs</h2>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
@include(snippets/admin-back)
<style> <style>
.hm-btn { .hm-btn {
display: inline-flex; display: inline-flex;
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Köpfe Manager</h2> <h2>Köpfe Manager</h2>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Meme Manager</h2> <h2>Meme Manager</h2>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Message of the Day (MOTD)</h2> <h2>Message of the Day (MOTD)</h2>
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed <strong>inside the navbar</strong> (at the bottom) so it stays visible while scrolling. Supports Markdown and HTML.</p> <p style="color: #ccc; margin-bottom: 20px;">This message is displayed <strong>inside the navbar</strong> (at the bottom) so it stays visible while scrolling. Supports Markdown and HTML.</p>
+1
View File
@@ -2,6 +2,7 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Rules Page Content</h2> <h2>Rules Page Content</h2>
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/rules</strong> page. Supports Markdown. Leave empty to show the default static rules template.</p> <p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/rules</strong> page. Supports Markdown. Leave empty to show the default static rules template.</p>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
@include(snippets/admin-back)
<div class="container session-grid"> <div class="container session-grid">
<h2 class="session-page-title"> <h2 class="session-page-title">
Sessions Sessions
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Terms of Service Page Content</h2> <h2>Terms of Service Page Content</h2>
<p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/terms</strong> page. Supports Markdown. Leave empty to show the default static template.</p> <p style="color: #ccc; margin-bottom: 20px;">This text is displayed on the <strong>/terms</strong> page. Supports Markdown. Leave empty to show the default static template.</p>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<div class="admin-header-flex"> <div class="admin-header-flex">
<h2>Invite Tokens</h2> <h2>Invite Tokens</h2>
+1
View File
@@ -2,6 +2,7 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<div style="margin-bottom: 30px;"> <div style="margin-bottom: 30px;">
<a href="/admin/users" style="color: #888; text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; margin-bottom: 15px;"> <a href="/admin/users" style="color: #888; text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; margin-bottom: 15px;">
+1
View File
@@ -2,6 +2,7 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<style> <style>
.admin-users-table { .admin-users-table {
width: 100%; width: 100%;
+1
View File
@@ -8,6 +8,7 @@
</style> </style>
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main" class="admin-container"> <div id="main" class="admin-container">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;"> <div class="admin-header-flex" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 25px;">
<h2>Wordfilter Manager</h2> <h2>Wordfilter Manager</h2>
+1 -1
View File
@@ -61,7 +61,7 @@
<span class="chan-card-badge chan-badge-closed" title="Closed"><i class="fa-solid fa-lock"></i></span> <span class="chan-card-badge chan-badge-closed" title="Closed"><i class="fa-solid fa-lock"></i></span>
@endif @endif
@if(t.local_id) @if(t.local_id)
<span class="chan-card-badge chan-badge-rehosted" title="OP Rehosted on f0ckm as Post #{{ t.local_id }}" style="background: rgba(16, 185, 129, 0.9); color: #fff; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ t.local_id }}</span> <span class="chan-card-badge chan-badge-rehosted" title="OP rehosted on f0ckm as /{{ t.local_path || t.local_id }}" style="background: rgba(16, 185, 129, 0.9); color: #fff; font-weight: 700;"><i class="fa-solid fa-check"></i> /{{ t.local_path || t.local_id }}</span>
@endif @endif
<span class="chan-card-badge chan-badge-stats"> <span class="chan-card-badge chan-badge-stats">
<span title="Replies"><i class="fa-solid fa-comment"></i> {{ t.replies }}</span> <span title="Replies"><i class="fa-solid fa-comment"></i> {{ t.replies }}</span>
+95 -62
View File
@@ -67,8 +67,8 @@
<time class="chan-post-time timeago" data-iso="{{ p.iso_str }}" tooltip="{{ p.iso_str }}" title="{{ p.date_str }}">{{ p.timeago }}</time> <time class="chan-post-time timeago" data-iso="{{ p.iso_str }}" tooltip="{{ p.iso_str }}" title="{{ p.date_str }}">{{ p.timeago }}</time>
<a href="#p{{ p.no }}" class="chan-post-no">No. {{ p.no }}</a> <a href="#p{{ p.no }}" class="chan-post-no">No. {{ p.no }}</a>
@if(p.local_id) @if(p.local_id)
<a href="/{{ p.local_id }}" target="_blank" class="chan-post-rehosted-pill" title="Already rehosted on f0ckm as Post #{{ p.local_id }}"> <a href="/{{ p.local_path || p.local_id }}" target="_blank" class="chan-post-rehosted-pill" title="Already rehosted on f0ckm as /{{ p.local_path || p.local_id }}">
<i class="fa-solid fa-check"></i> #{{ p.local_id }} <i class="fa-solid fa-check"></i> /{{ p.local_path || p.local_id }}
</a> </a>
@endif @endif
@if(p.sub && p.no !== tid) @if(p.sub && p.no !== tid)
@@ -92,6 +92,43 @@
@endif @endif
</div> </div>
<!-- File info: one row above the thumbnail -->
@if(p.has_media)
<div class="chan-file-info">
<div class="chan-filename-row">
<a href="{{ p.external_media_url }}" target="_blank" rel="noopener noreferrer" class="chan-file-download" title="Open full media in new tab">
<i class="fa-solid fa-arrow-up-right-from-square"></i>
<span class="chan-file-title">{{ p.filename || ('File ' + p.tim + p.ext) }}</span>
</a>
</div>
<div class="chan-file-meta-row">
@if(p.fsize)<span>{{ p.fsize }}</span>@endif
@if(p.w && p.h)<span>{{ p.w }}×{{ p.h }}</span>@endif
</div>
<!-- Rehost & Favorite actions -->
<div class="chan-rehost-container" id="rehost-box-{{ p.no }}">
@if(p.local_id)
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">
<a href="/{{ p.local_path || p.local_id }}" class="chan-rehost-btn rehosted" target="_blank">
<i class="fa-solid fa-check"></i> Rehosted /{{ p.local_path || p.local_id }}
</a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ p.local_id }}" title="Edit rating" style="padding:4px 7px;border-radius:0;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">
<i class="fa-solid fa-pen"></i>
</button>
</span>
@else
<button type="button" class="chan-rehost-btn" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-post="{{ p.no }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}">
<i class="fa-solid fa-cloud-arrow-up"></i> Rehost
</button>
@endif
<button type="button" class="chan-card-fav-btn {{ p.user_has_favorited ? 'favorited' : '' }}" data-post="{{ p.no }}" data-local-id="{{ p.local_id || '' }}" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}" title="{{ p.user_has_favorited ? 'Remove from favorites' : 'Favorite' }}">
<i class="fa-{{ p.user_has_favorited ? 'solid' : 'regular' }} fa-heart"></i>
</button>
</div>
</div>
@endif
<!-- Post Content (Media + Comment) --> <!-- Post Content (Media + Comment) -->
<div class="chan-post-content"> <div class="chan-post-content">
@if(p.has_media) @if(p.has_media)
@@ -107,44 +144,11 @@
<span class="chan-video-indicator"><i class="fa-solid fa-play"></i></span> <span class="chan-video-indicator"><i class="fa-solid fa-play"></i></span>
@endif @endif
@if(p.local_id) @if(p.local_id)
<span class="chan-thumb-rehost-badge" title="Rehosted on f0ckm as Post #{{ p.local_id }}"><i class="fa-solid fa-check"></i> #{{ p.local_id }}</span> <span class="chan-thumb-rehost-badge" title="Rehosted on f0ckm as /{{ p.local_path || p.local_id }}"><i class="fa-solid fa-check"></i> /{{ p.local_path || p.local_id }}</span>
@endif @endif
</a> </a>
</div> </div>
<div class="chan-file-info">
<div class="chan-filename-row">
<a href="{{ p.external_media_url }}" target="_blank" rel="noopener noreferrer" class="chan-file-download" title="Open full media in new tab">
<i class="fa-solid fa-arrow-up-right-from-square"></i>
<span class="chan-file-title">{{ p.filename || ('File ' + p.tim + p.ext) }}</span>
</a>
</div>
<div class="chan-file-meta-row">
@if(p.fsize)<span>{{ p.fsize }}</span>@endif
@if(p.w && p.h)<span>{{ p.w }}×{{ p.h }}</span>@endif
</div>
<!-- Rehost & Favorite actions -->
<div class="chan-rehost-container" id="rehost-box-{{ p.no }}">
@if(p.local_id)
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">
<a href="/{{ p.local_id }}" class="chan-rehost-btn rehosted" target="_blank">
<i class="fa-solid fa-check"></i> Rehosted #{{ p.local_id }}
</a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ p.local_id }}" title="Edit rating" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">
<i class="fa-solid fa-pen"></i>
</button>
</span>
@else
<button type="button" class="chan-rehost-btn" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-post="{{ p.no }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}">
<i class="fa-solid fa-cloud-arrow-up"></i> Rehost
</button>
@endif
<button type="button" class="chan-card-fav-btn {{ p.user_has_favorited ? 'favorited' : '' }}" data-post="{{ p.no }}" data-local-id="{{ p.local_id || '' }}" data-url="{{ p.external_media_url }}" data-board="{{ board }}" data-tid="{{ tid }}" data-filename="{{ p.filename || '' }}" data-width="{{ p.w || '' }}" data-height="{{ p.h || '' }}" title="{{ p.user_has_favorited ? 'Remove from favorites' : 'Favorite' }}">
<i class="fa-{{ p.user_has_favorited ? 'solid' : 'regular' }} fa-heart"></i>
</button>
</div>
</div>
</div> </div>
@endif @endif
@@ -176,7 +180,7 @@
margin-bottom: 25px; margin-bottom: 25px;
background: var(--bg-card, rgba(25, 25, 25, 0.85)); background: var(--bg-card, rgba(25, 25, 25, 0.85));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
border-radius: 12px; border-radius: 0;
padding: 16px 20px; padding: 16px 20px;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
@@ -192,7 +196,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 6px 14px; padding: 6px 14px;
border-radius: 8px; border-radius: 0;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
color: var(--text-main, #fff); color: var(--text-main, #fff);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -216,7 +220,7 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 12px; padding: 6px 12px;
border-radius: 8px; border-radius: 0;
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
@@ -272,7 +276,7 @@
display: inline-flex; display: inline-flex;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px; border-radius: 0;
padding: 3px; padding: 3px;
gap: 3px; gap: 3px;
} }
@@ -280,7 +284,7 @@
background: none; background: none;
border: none; border: none;
padding: 5px 12px; padding: 5px 12px;
border-radius: 6px; border-radius: 0;
font-size: 0.82rem; font-size: 0.82rem;
font-weight: 600; font-weight: 600;
color: #aaa; color: #aaa;
@@ -301,7 +305,7 @@
.chan-post-card { .chan-post-card {
background: var(--bg-card, rgba(25, 25, 25, 0.85)); background: var(--bg-card, rgba(25, 25, 25, 0.85));
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08)); border: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
border-radius: 10px; border-radius: 0;
padding: 14px 18px; padding: 14px 18px;
transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, background-color 0.2s, box-shadow 0.2s;
scroll-margin-top: 80px; scroll-margin-top: 80px;
@@ -388,7 +392,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 2px 7px; padding: 2px 7px;
border-radius: 4px; border-radius: 0;
background: rgba(74, 222, 128, 0.2); background: rgba(74, 222, 128, 0.2);
color: #4ade80; color: #4ade80;
border: 1px solid rgba(74, 222, 128, 0.4); border: 1px solid rgba(74, 222, 128, 0.4);
@@ -411,7 +415,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 0;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -506,7 +510,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
width: 100%; width: 100%;
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
background: #0d0d0d; background: #0d0d0d;
border-radius: 8px; border-radius: 0;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
} }
@@ -549,7 +553,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
right: 6px; right: 6px;
width: 24px; width: 24px;
height: 24px; height: 24px;
border-radius: 50%; border-radius: 0;
background: rgba(0, 0, 0, 0.75); background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
color: #fff; color: #fff;
@@ -566,9 +570,29 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
/* File Info (filename, dimensions, size) */ /* File Info (filename, dimensions, size) */
.chan-file-info { .chan-file-info {
display: flex; display: flex;
flex-direction: column; flex-direction: row;
gap: 4px; align-items: center;
gap: 10px;
width: 100%; width: 100%;
min-width: 0;
margin-bottom: 8px;
}
.chan-file-info .chan-filename-row {
flex: 0 1 auto;
min-width: 0;
}
.chan-file-info .chan-file-meta-row {
flex-shrink: 0;
white-space: nowrap;
}
.chan-file-info .chan-rehost-container {
flex-shrink: 0;
margin-left: auto;
width: auto;
}
.chan-file-info .chan-rehost-btn {
width: auto;
flex: 0 0 auto;
} }
.chan-filename-row { .chan-filename-row {
display: flex; display: flex;
@@ -617,7 +641,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 0;
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 600;
background: rgba(74, 222, 128, 0.15); background: rgba(74, 222, 128, 0.15);
@@ -671,7 +695,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
width: 28px; width: 28px;
min-width: 28px; min-width: 28px;
padding: 0; padding: 0;
border-radius: 6px; border-radius: 0;
font-size: 0.85rem; font-size: 0.85rem;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.15);
@@ -715,7 +739,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
gap: 4px 6px; gap: 4px 6px;
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07); border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 6px; border-radius: 0;
padding: 2px 7px; padding: 2px 7px;
font-size: 0.78rem; font-size: 0.78rem;
max-width: 100%; max-width: 100%;
@@ -743,7 +767,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
font-size: 0.78rem; font-size: 0.78rem;
cursor: pointer; cursor: pointer;
padding: 1px 5px; padding: 1px 5px;
border-radius: 4px; border-radius: 0;
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2); border: 1px solid rgba(239, 68, 68, 0.2);
transition: all 0.15s ease; transition: all 0.15s ease;
@@ -773,7 +797,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
background: #333; background: #333;
color: #333; color: #333;
padding: 0 3px; padding: 0 3px;
border-radius: 2px; border-radius: 0;
text-decoration: none; text-decoration: none;
cursor: pointer; cursor: pointer;
transition: color 0.15s; transition: color 0.15s;
@@ -793,7 +817,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
overflow-y: auto; overflow-y: auto;
background: rgba(22, 22, 24, 0.98); background: rgba(22, 22, 24, 0.98);
border: 1px solid #4ade80; border: 1px solid #4ade80;
border-radius: 10px; border-radius: 0;
padding: 12px 16px; padding: 12px 16px;
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.8), 0 0 20px rgba(74, 222, 128, 0.2); box-shadow: 0 12px 35px rgba(0, 0, 0, 0.8), 0 0 20px rgba(74, 222, 128, 0.2);
pointer-events: none; pointer-events: none;
@@ -823,6 +847,14 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
.chan-quote-preview-popup .chan-post-backlinks { .chan-quote-preview-popup .chan-post-backlinks {
display: none !important; display: none !important;
} }
/* Style guide: no rounded corners anywhere in the thread (covers site CSS, inline and JS-inserted elements) */
.chan-thread-page,
.chan-thread-page *,
.chan-thread-page *::before,
.chan-thread-page *::after {
border-radius: 0 !important;
}
</style> </style>
<script> <script>
@@ -899,9 +931,9 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
btn.classList.remove('loading'); btn.classList.remove('loading');
btn.classList.add('rehosted'); btn.classList.add('rehosted');
btn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">' + btn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">' +
'<a href="/' + data.item_id + '" class="chan-rehost-btn rehosted" target="_blank">' + '<a href="/' + (data.item_path || data.item_id) + '" class="chan-rehost-btn rehosted" target="_blank">' +
'<i class="fa-solid fa-check"></i> Rehosted #' + data.item_id + '</a>' + '<i class="fa-solid fa-check"></i> Rehosted /' + (data.item_path || data.item_id) + '</a>' +
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + data.item_id + '" title="Edit rating" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' + '<button type="button" class="chan-rehost-edit-btn" data-item-id="' + data.item_id + '" title="Edit rating" style="padding:4px 7px;border-radius:0;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
'<i class="fa-solid fa-pen"></i></button></span>'; '<i class="fa-solid fa-pen"></i></button></span>';
const postCard = document.getElementById('p' + postNo) || btn.closest('.chan-post-card'); const postCard = document.getElementById('p' + postNo) || btn.closest('.chan-post-card');
@@ -1003,6 +1035,7 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
const rehostData = await rehostResp.json(); const rehostData = await rehostResp.json();
if (rehostData.success && rehostData.item_id) { if (rehostData.success && rehostData.item_id) {
localId = rehostData.item_id; localId = rehostData.item_id;
const localPath = rehostData.item_path || localId;
favBtn.dataset.localId = localId; favBtn.dataset.localId = localId;
// Update rehost box // Update rehost box
@@ -1011,9 +1044,9 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
const rehostBtn = rehostBox.querySelector('.chan-rehost-btn'); const rehostBtn = rehostBox.querySelector('.chan-rehost-btn');
if (rehostBtn) { if (rehostBtn) {
rehostBtn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">' + rehostBtn.outerHTML = '<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;flex:1;">' +
'<a href="/' + localId + '" class="chan-rehost-btn rehosted" target="_blank">' + '<a href="/' + localPath + '" class="chan-rehost-btn rehosted" target="_blank">' +
'<i class="fa-solid fa-check"></i> Rehosted #' + localId + '</a>' + '<i class="fa-solid fa-check"></i> Rehosted /' + localPath + '</a>' +
'<button type="button" class="chan-rehost-edit-btn" data-item-id="' + localId + '" title="Edit rating" style="padding:4px 7px;border-radius:4px;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' + '<button type="button" class="chan-rehost-edit-btn" data-item-id="' + localId + '" title="Edit rating" style="padding:4px 7px;border-radius:0;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;font-size:0.75rem;">' +
'<i class="fa-solid fa-pen"></i></button></span>'; '<i class="fa-solid fa-pen"></i></button></span>';
} }
} }
@@ -1024,8 +1057,8 @@ body.onara-modal-closing .chan-thumb-link.onara-active .chan-thumb-img {
if (thumbLink && !thumbLink.querySelector('.chan-thumb-rehost-badge')) { if (thumbLink && !thumbLink.querySelector('.chan-thumb-rehost-badge')) {
const badge = document.createElement('span'); const badge = document.createElement('span');
badge.className = 'chan-thumb-rehost-badge'; badge.className = 'chan-thumb-rehost-badge';
badge.title = 'Rehosted on f0ckm as Post #' + localId; badge.title = 'Rehosted on f0ckm as /' + localPath;
badge.innerHTML = '<i class="fa-solid fa-check"></i> #' + localId; badge.innerHTML = '<i class="fa-solid fa-check"></i> /' + localPath;
thumbLink.appendChild(badge); thumbLink.appendChild(badge);
} }
const timeEl = postCard.querySelector('.chan-post-time, .timeago'); const timeEl = postCard.querySelector('.chan-post-time, .timeago');
+46 -54
View File
@@ -6,7 +6,7 @@
<div class="item-main-content"> <div class="item-main-content">
<div class="_204863"> <div class="_204863">
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_id }}" class="location-link is-rehosted" title="View rehosted post #{{ item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div> <div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_path || item.local_id }}" class="location-link is-rehosted" title="View rehosted post /{{ item.local_path || item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
<div class="gapLeft"></div> <div class="gapLeft"></div>
</div> </div>
@if(enable_item_title) @if(enable_item_title)
@@ -129,7 +129,7 @@
@if(item.is_chan) @if(item.is_chan)
@if(item.local_id) @if(item.local_id)
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;"> <span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a> <a href="/{{ item.local_path || item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (/{{ item.local_path || item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button> <button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
</span> </span>
@else @else
@@ -190,7 +190,7 @@
@if(!user_alternative_infobox && item.is_chan) — <span class="badge badge-dark chan-post-badge" style="background: rgba(74, 222, 128, 0.15); border: 1px solid rgba(74, 222, 128, 0.3); display: inline-flex; align-items: center; gap: 6px;"> @if(!user_alternative_infobox && item.is_chan) — <span class="badge badge-dark chan-post-badge" style="background: rgba(74, 222, 128, 0.15); border: 1px solid rgba(74, 222, 128, 0.3); display: inline-flex; align-items: center; gap: 6px;">
<a href="/4/{{ item.external_board }}/{{ item.external_tid }}" style="color: #4ade80; text-decoration: none; font-weight: 600;" title="View thread overview"><i class="fa-solid fa-clover"></i> /{{ item.external_board }}/{{ item.external_tid }}</a> <a href="/4/{{ item.external_board }}/{{ item.external_tid }}" style="color: #4ade80; text-decoration: none; font-weight: 600;" title="View thread overview"><i class="fa-solid fa-clover"></i> /{{ item.external_board }}/{{ item.external_tid }}</a>
@if(item.local_id) @if(item.local_id)
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 10px;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a> <a href="/{{ item.local_path || item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (/{{ item.local_path || item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 10px;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>
@else @else
<button type="button" class="chan-rehost-action-btn" id="chan-item-rehost-btn" data-url="{{ item.external_media_url }}" data-board="{{ item.external_board }}" data-tid="{{ item.external_tid }}" data-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" title="Rehost to f0ckm" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 12px;min-width:88px;box-sizing:border-box;white-space:nowrap;flex-shrink:0;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.3);cursor:pointer;font-weight:600;"><i class="fa-solid fa-cloud-arrow-up"></i> Rehost</button> <button type="button" class="chan-rehost-action-btn" id="chan-item-rehost-btn" data-url="{{ item.external_media_url }}" data-board="{{ item.external_board }}" data-tid="{{ item.external_tid }}" data-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" title="Rehost to f0ckm" style="display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:3px 12px;min-width:88px;box-sizing:border-box;white-space:nowrap;flex-shrink:0;border-radius:4px;font-size:0.75rem;background:rgba(74,222,128,0.15);color:#4ade80;border:1px solid rgba(74,222,128,0.3);cursor:pointer;font-weight:600;"><i class="fa-solid fa-cloud-arrow-up"></i> Rehost</button>
@endif @endif
@@ -201,17 +201,56 @@
</span>@endif</span> </span>@endif</span>
<span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}"> {{-- Action buttons when the alternative user infobox is off (the infobox renders its own set) --}}
@if(!user_alternative_infobox)
<div class="gapRight blahlol-actions" style="display:flex;width:100%;justify-content:center;margin:4px 0;">
@if(session)
@if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif title="Favorite"></i>
@else
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.local_id || item.id }}" @if(item.is_chan) data-is-chan="true" data-local-id="{{ item.local_id || '' }}" data-chan-url="{{ item.external_media_url }}" data-chan-board="{{ item.external_board }}" data-chan-tid="{{ item.external_tid }}" data-chan-post="{{ item.external_id }}" data-filename="{{ item.original_filename || '' }}" data-width="{{ item.width || '' }}" data-height="{{ item.height || '' }}" @endif 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(typeof can_extract_meta !== 'undefined' && 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>
@endif
<span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}" data-can-rate="{{ can_rate_item ? 'true' : 'false' }}">
<span class="tags-inner"> <span class="tags-inner">
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl) @if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
<span class="badge badge-untagged rating-tag is-untagged @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged"> <span class="badge badge-untagged rating-tag is-untagged @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged">
<span class="rating-label">?</span> <span class="rating-label">?</span>
</span> </span>
@endif @endif
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl') @if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}"> <span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}">
<span class="rating-label">{!! tag.tag !!}</span> <span class="rating-label">{!! tag.tag !!}</span>
</span> </span>
@else @else
@@ -285,51 +324,4 @@
@include(snippets/info-modal) @include(snippets/info-modal)
<div id="visibility-modal" class="modal-overlay" style="display: none;"> @include(snippets/visibility-modal)
<div class="modal-content" style="max-width: 420px;">
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
</div>
<div class="modal-body" style="padding: 10px 0; text-align: left;">
<form id="visibility-form" onsubmit="return false;">
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
</div>
</label>
@if(is_mod_or_admin)
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="3" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-ban" style="color: var(--danger, #ff4444);"></i> Unavailable (451)</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to administrators. Serves HTTP 451 on direct media for all other users.</span>
</div>
</label>
@endif
</div>
</form>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
</div>
</div>
</div>
+6 -53
View File
@@ -40,16 +40,16 @@
@endif @endif
<div class="sidebar-tags-container"> <div class="sidebar-tags-container">
<span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;"> <span class="badge badge-dark" id="tags" data-item-id="{{ item.local_id || item.id }}" data-can-manage="{{ can_manage_item ? 'true' : 'false' }}" data-can-rate="{{ can_rate_item ? 'true' : 'false' }}" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl) @if(!item.is_sfw && !item.is_nsfw && !item.is_nsfl)
<span class="badge badge-untagged rating-tag is-untagged @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged"> <span class="badge badge-untagged rating-tag is-untagged @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="untagged">
<span class="rating-label">?</span> <span class="rating-label">?</span>
</span> </span>
@endif @endif
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)
@if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl') @if(tag.normalized === 'sfw' || tag.normalized === 'nsfw' || tag.normalized === 'nsfl')
<span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_manage_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}"> <span class="badge {{ tag.badge }} rating-tag is-{{ tag.normalized }} @if(can_rate_item) can-cycle @endif" data-item-id="{{ item.local_id || item.id }}" data-rating="{{ tag.normalized }}">
<span class="rating-label">{!! tag.tag !!}</span> <span class="rating-label">{!! tag.tag !!}</span>
</span> </span>
@else @else
@@ -70,7 +70,7 @@
<div class="item-main-content"> <div class="item-main-content">
<div class="_204863"> <div class="_204863">
<div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_id }}" class="location-link is-rehosted" title="View rehosted post #{{ item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div> <div class="location">@if(item.is_chan)<a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/4</a><a href="/4/{{ item.external_board }}/catalogue" class="location-link" title="Board /{{ item.external_board }}/ catalogue">/{{ item.external_board }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}" class="location-link" title="Thread #{{ item.external_tid }} overview">/{{ item.external_tid }}</a><a href="/4/{{ item.external_board }}/{{ item.external_tid }}/{{ item.external_id }}" class="location-link" data-inherit-context="false" title="Post #{{ item.external_id }}">/{{ item.external_id }}</a>@if(item.local_id)<span class="location-sep" style="opacity: 0.6; margin: 0 4px;">→</span><a href="/{{ item.local_path || item.local_id }}" class="location-link is-rehosted" title="View rehosted post /{{ item.local_path || item.local_id }} on f0ckm" style="color: #4ade80; font-weight: 700;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>@endif@elseif(tmp && tmp.tag)<a href="/tags" class="location-link" title="All tags">/tag</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}{{ link.suffix || '' }}" class="location-link" title="Tag: {{ tmp.tag }}">/{{ tmp.tag }}</a><a href="/tag/{{ encodeURIComponent(tmp.tag) }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix || '' }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}" class="location-link" title="Post #{{ item.id }}">/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}</a>@else{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}{{ item.requested_subf0ck_slug ? ('#' + item.requested_subf0ck_slug) : '' }}@endif</div>
<div class="gapLeft"></div> <div class="gapLeft"></div>
</div> </div>
@if(enable_item_title) @if(enable_item_title)
@@ -161,7 +161,7 @@
@if(item.is_chan) @if(item.is_chan)
@if(item.local_id) @if(item.local_id)
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;"> <span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
<a href="/{{ item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #{{ item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> #{{ item.local_id }}</a> <a href="/{{ item.local_path || item.local_id }}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (/{{ item.local_path || item.local_id }})" style="display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:4px 12px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;white-space:nowrap;flex-shrink:0;"><i class="fa-solid fa-check"></i> /{{ item.local_path || item.local_id }}</a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button> <button type="button" class="chan-rehost-edit-btn" data-item-id="{{ item.local_id }}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
</span> </span>
@else @else
@@ -233,51 +233,4 @@
@include(snippets/info-modal) @include(snippets/info-modal)
<div id="visibility-modal" class="modal-overlay" style="display: none;"> @include(snippets/visibility-modal)
<div class="modal-content" style="max-width: 420px;">
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
</div>
<div class="modal-body" style="padding: 10px 0; text-align: left;">
<form id="visibility-form" onsubmit="return false;">
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
</div>
</label>
@if(is_mod_or_admin)
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="3" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-ban" style="color: var(--danger, #ff4444);"></i> Unavailable (451)</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to administrators. Serves HTTP 451 on direct media for all other users.</span>
</div>
</label>
@endif
</div>
</form>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
</div>
</div>
</div>
+64 -21
View File
@@ -1,29 +1,72 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
<div class="container"> <div class="container adm">
<h1>MODERATOR DASHBOARD</h1>
<h5>Hello, {!! session.user !!}</h5> <!-- Header -->
<hr> <header class="adm-header">
<div>
<h1 class="adm-title">Moderator</h1>
<div class="adm-subtitle">Signed in as <strong>{!! session.user !!}</strong></div>
</div>
@if(session.admin)
<a href="/admin" class="adm-btn adm-btn-ghost"><i class="fa-solid fa-shield-halved"></i> Admin dashboard</a>
@endif
</header>
<!-- Overview -->
<section class="adm-section">
<h2 class="adm-section-title"><i class="fa-solid fa-chart-simple"></i> Overview</h2>
<div class="adm-stats">
@if(manualApproval) @if(manualApproval)
<p>Stats: Pending Uploads: {!! pendingCount !!}</p> <a href="/mod/approve" class="adm-stat adm-stat-link @if(pendingCount > 0) is-hot @endif"><span class="adm-stat-num">{!! pendingCount !!}</span><span class="adm-stat-label">Pending approval</span></a>
<hr>
@endif @endif
<div class="admintools"> <a href="/mod/trash" class="adm-stat adm-stat-link"><span class="adm-stat-num">{!! trashCount !!}</span><span class="adm-stat-label">Soft deleted</span></a>
<p>Tools</p> <a href="/mod/reports" class="adm-stat adm-stat-link @if(reportsCount > 0) is-hot @endif"><span class="adm-stat-num">{!! reportsCount !!}</span><span class="adm-stat-label">Open reports</span></a>
<ul> </div>
@if(manualApproval) </section>
<li><a href="/mod/approve">Approval Queue @if(pendingCount > 0) <span class="badge badge-danger">{!! pendingCount !!}</span> @endif</a></li>
@endif <!-- Tools -->
@if(session.admin || session.is_moderator) <section class="adm-section">
<li><a href="/mod/audit">Audit Log</a></li> <h2 class="adm-section-title"><i class="fa-solid fa-screwdriver-wrench"></i> Tools</h2>
<li><a href="/mod/reports">User Reports</a></li>
<li><a href="/mod/motd">MOTD</a></li> <h3 class="adm-group-title">Moderation</h3>
<li><a href="/mod/halls">Hall Manager</a></li> <div class="adm-tools">
@endif @if(manualApproval)
</ul> <a href="/mod/approve" class="adm-tool">
</div> <i class="fa-solid fa-clock"></i>
</div> <span class="adm-tool-text"><strong>Approval Queue @if(pendingCount > 0)<span class="adm-count">{!! pendingCount !!}</span>@endif</strong><span>Review uploads waiting for approval</span></span>
</a>
@endif
<a href="/mod/trash" class="adm-tool">
<i class="fa-solid fa-trash-can"></i>
<span class="adm-tool-text"><strong>Soft Deleted @if(trashCount > 0)<span class="adm-count">{!! trashCount !!}</span>@endif</strong><span>Restore removed items@if(session.admin) or purge them for good@endif</span></span>
</a>
<a href="/mod/reports" class="adm-tool">
<i class="fa-solid fa-flag"></i>
<span class="adm-tool-text"><strong>User Reports @if(reportsCount > 0)<span class="adm-count">{!! reportsCount !!}</span>@endif</strong><span>Reported posts, comments and users</span></span>
</a>
<a href="/mod/audit" class="adm-tool">
<i class="fa-solid fa-scroll"></i>
<span class="adm-tool-text"><strong>Audit Log</strong><span>Every moderation and admin action</span></span>
</a>
</div>
<h3 class="adm-group-title">Site</h3>
<div class="adm-tools">
<a href="/mod/motd" class="adm-tool">
<i class="fa-solid fa-bullhorn"></i>
<span class="adm-tool-text"><strong>MOTD</strong><span>Message of the day banner</span></span>
</a>
<a href="/mod/halls" class="adm-tool">
<i class="fa-solid fa-layer-group"></i>
<span class="adm-tool-text"><strong>Halls</strong><span>Curated collections</span></span>
</a>
</div>
</section>
</div>
@include(snippets/adm-dashboard-style)
</div> </div>
</div> </div>
@include(snippets/footer) @include(snippets/footer)
+69 -307
View File
@@ -2,349 +2,111 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
@include(snippets/admin-back)
<div class="container mq">
<header class="mq-header">
<div>
<h1 class="mq-title">Approval Queue</h1>
<div class="mq-subtitle">{{ total }} upload@if(total !== 1)s@endif waiting for approval.</div>
</div>
</header>
<div class="container">
<h1>APPROVAL QUEUE</h1>
<p>Items here are pending approval.</p>
@if(pending.length > 0) @if(pending.length > 0)
<h2>Pending Uploads</h2> <div class="mq-grid">
<div class="approval-grid">
@each(pending as post) @each(pending as post)
<div class="approval-card"> <div class="mq-card">
<div class="approval-card-media"> <div class="mq-media">
@if(post.mime === 'video/youtube') @if(post.mime === 'video/youtube')
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;"> <div class="embed-responsive embed-responsive-16by9">
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe> <iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
</div> </div>
@elseif(post.mime.startsWith('video')) @elseif(post.mime.startsWith('video'))
<video controls loop muted preload="none" poster="/mod/pending/t/{!! post.id !!}.webp"> <video controls loop muted preload="none" poster="/mod/pending/t/{!! post.id !!}.webp">
<source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}"> <source src="/mod/pending/b/{!! post.dest !!}" type="{!! post.mime !!}">
Your browser does not support the video tag.
</video> </video>
@elseif(post.mime === 'application/pdf') @elseif(post.mime === 'application/pdf')
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;"> <div class="embed-responsive embed-responsive-16by9">
<iframe class="embed-responsive-item" src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe> <iframe class="embed-responsive-item" src="/mod/pending/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
</div> </div>
@else @else
<img src="/mod/pending/t/{!! post.id !!}.webp" alt="Preview"> <img src="/mod/pending/t/{!! post.id !!}.webp" alt="Preview" loading="lazy">
@endif @endif
</div> </div>
<div class="approval-card-body"> <div class="mq-body">
<div class="approval-card-info"> <div class="mq-meta">
<div><strong>ID:</strong> {!! post.id !!}</div> <span><strong>#{!! post.id !!}</strong></span>
<div><strong>User:</strong> {!! post.username !!}</div> <span>by <strong>{!! post.username !!}</strong></span>
<div><strong>Type:</strong> {!! post.mime !!}</div> <span>{!! post.mime !!}</span>
</div> </div>
<div class="approval-card-tags"> <div class="mq-tags">
@each(post.tags as tag) @each(post.tags as tag)
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span> <span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
@endeach @endeach
</div> </div>
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;"> <div class="mq-actions">
<button data-id="{!! post.id !!}" class="badge badge-success btn-approve-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Approve</button> <button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-primary btn-approve-async">Approve</button>
<button data-id="{!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Deny / Delete</button> <button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-danger btn-deny-async">Deny</button>
<a href="/api/v2/tags/{!! post.id !!}/toggle" class="badge btn-rating-toggle-async" style="grid-column: span 2; background: #444; color: #ccc; margin: 0; text-align: center;">Rating</a> <button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-ghost mq-btn-rating btn-rating-toggle-async"><i class="fa-solid fa-shield-halved"></i> Toggle rating</button>
</div>
</div>
</div>
@endeach
</div>
@endif
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 40px; margin-bottom: 20px;">
<h2 style="color: #ff6b6b; margin: 0;">Soft Deleted</h2>
@if(trash.length > 0 && session.admin)
<button id="btn-purge-trash" class="badge badge-danger" style="border: none; padding: 10px 15px; cursor: pointer; font-size: 14px;">Purge All Soft-Deleted</button>
@endif
</div>
<p class="text-muted">These items are in the deleted folder but not purged from DB. Approving them will restore them.</p>
@if(trash.length > 0)
<div class="approval-grid" style="opacity: 0.8;">
@each(trash as post)
<div class="approval-card">
<div class="approval-card-media">
@if(post.mime === 'video/youtube')
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
</div>
@elseif(post.mime.startsWith('video'))
<video controls loop muted preload="none" poster="/mod/deleted/t/{!! post.id !!}.webp">
<source src="/mod/deleted/b/{!! post.dest !!}" type="{!! post.mime !!}">
Your browser does not support the video tag.
</video>
@elseif(post.mime === 'application/pdf')
<div class="embed-responsive embed-responsive-16by9" style="width: 100%;">
<iframe class="embed-responsive-item" src="/mod/deleted/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
</div>
@else
<img src="/mod/deleted/t/{!! post.id !!}.webp" style="filter: grayscale(50%);" alt="Preview">
@endif
</div>
<div class="approval-card-body">
<div class="approval-card-info">
<div><strong>ID:</strong> {!! post.id !!}</div>
<div><strong>User:</strong> {!! post.username !!}</div>
<div><strong>Type:</strong> {!! post.mime !!}</div>
@if(post.delete_reason)
<div style="color: #ffb8b8;"><strong>Reason:</strong> {!! post.delete_reason !!}</div>
@endif
</div>
<div class="approval-card-tags">
@each(post.tags as tag)
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
@endeach
</div>
<div class="approval-card-actions" style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<button data-id="{!! post.id !!}" class="badge badge-warning btn-approve-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Restore</button>
@if(session.admin)
<button data-id="{!! post.id !!}" class="badge badge-danger btn-deny-async" style="margin: 0; text-align: center; border: none; cursor: pointer;">Purge</button>
@else
<span></span>
@endif
<a href="/api/v2/tags/{!! post.id !!}/toggle" class="badge btn-rating-toggle-async" style="grid-column: span 2; background: #444; color: #ccc; margin: 0; text-align: center;">Rating</a>
</div> </div>
</div> </div>
</div> </div>
@endeach @endeach
</div> </div>
@else @else
<p style="padding: 20px; border: 1px dashed #444; color: #888;">Trash is empty.</p> <div class="mq-empty"><strong>No pending uploads.</strong>Go touch grass?</div>
@endif @endif
@if(pending.length === 0 && trash.length === 0) @if(pages > 1)
<div style="text-align: center; padding: 50px;"> <div class="mq-pager">
<h3>No pending items.</h3>
<p>Go touch grass?</p>
</div>
@endif
<br>
@if(typeof pages !== 'undefined' && pages > 1)
<div class="pagination" style="display: flex; gap: 10px; align-items: center; justify-content: center;">
@if(page > 1) @if(page > 1)
<a href="/mod/approve?page={!! page - 1 !!}" class="badge badge-secondary">&laquo; Prev</a> <a href="/mod/approve?page={!! page - 1 !!}" class="mq-btn mq-btn-ghost">&laquo; Prev</a>
@endif @endif
<span>Page {!! page !!} of {!! pages !!}</span> <span>Page {!! page !!} of {!! pages !!}</span>
@if(page < pages) <a href="/mod/approve?page={!! page + 1 !!}" class="badge badge-secondary">Next &raquo;</a>@endif @if(page < pages)
<a href="/mod/approve?page={!! page + 1 !!}" class="mq-btn mq-btn-ghost">Next &raquo;</a>
@endif
</div> </div>
<br>
@endif @endif
<!-- Custom Modal -->
<div id="custom-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); justify-content: center; align-items: center; z-index: 1000;">
<div style="background: #222; color: #fff; padding: 20px; border-radius: 8px; max-width: 400px; text-align: center; border: 1px solid #444;">
<h3 id="modal-title" style="margin-top: 0;">Confirm Action</h3>
<p id="modal-text">Are you sure?</p>
<div id="modal-reason-container" style="display: none; margin-bottom: 15px; text-align: left;">
<label style="display: block; margin-bottom: 5px; font-size: 0.9em; color: #aaa;">Reason (required):</label>
<textarea id="modal-reason" style="width: 100%; background: #333; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 8px; resize: none;" rows="3" placeholder="Enter reason for denial..."></textarea>
</div>
<div style="display: flex; justify-content: space-around; margin-top: 20px;">
<button id="modal-cancel" class="badge badge-secondary" style="border: none; padding: 10px 20px; cursor: pointer;">Cancel</button>
<button id="modal-confirm" class="badge badge-danger" style="border: none; padding: 10px 20px; cursor: pointer;">Confirm</button>
</div>
</div>
</div>
<script>
(() => {
const modal = document.getElementById('custom-modal');
const modalTitle = document.getElementById('modal-title');
const modalText = document.getElementById('modal-text');
const modalReasonContainer = document.getElementById('modal-reason-container');
const modalReasonInput = document.getElementById('modal-reason');
const btnConfirm = document.getElementById('modal-confirm');
const btnCancel = document.getElementById('modal-cancel');
let pendingAction = null;
const showModal = (title, text, action, showReason = false) => {
modalTitle.innerText = title;
modalText.innerText = text;
pendingAction = action;
if (showReason) {
modalReasonContainer.style.display = 'block';
modalReasonInput.value = '';
modalReasonInput.focus();
} else {
modalReasonContainer.style.display = 'none';
}
modal.style.display = 'flex';
btnConfirm.onclick = async () => {
if (!pendingAction) return;
btnConfirm.disabled = true;
btnConfirm.innerText = 'Processing...';
try {
let reason = null;
if (showReason) {
reason = modalReasonInput.value.trim();
if (!reason) {
modalReasonInput.style.borderColor = '#ff4444';
alert('Please provide a reason for denial');
return;
}
modalReasonInput.style.borderColor = '#444';
}
await pendingAction(reason);
closeModal();
} catch (e) {
alert('Error: ' + e.message);
} finally {
btnConfirm.disabled = false;
btnConfirm.innerText = 'Confirm';
}
};
};
const closeModal = () => {
modal.style.display = 'none';
pendingAction = null;
};
if (btnCancel) btnCancel.onclick = closeModal;
// Single Deny
document.querySelectorAll('.btn-deny-async').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
const itemId = btn.getAttribute('data-id');
const card = btn.closest('.approval-card');
showModal('{!! t('mod.confirm_action') !!}', '{!! t('mod.confirm_action') !!}?', async (reason) => {
const res = await fetch('/mod/deny', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
},
body: JSON.stringify({ id: itemId, reason })
});
const data = await res.json();
if (res.ok && data.success) {
if (window.flashMessage) window.flashMessage('{!! t('toast.item_deleted_success') !!}', 2000, 'success');
card.style.opacity = '0';
setTimeout(() => card.remove(), 300);
} else {
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.report_error') !!}', 3000, 'error');
throw new Error(data.msg || 'Request failed');
}
}, true);
});
});
// Single Approve / Restore
document.querySelectorAll('.btn-approve-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
const itemId = btn.getAttribute('data-id');
const card = btn.closest('.approval-card');
try {
const res = await fetch('/mod/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json',
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
},
body: JSON.stringify({ id: itemId })
});
const data = await res.json();
if (data.success) {
if (window.flashMessage) window.flashMessage('{!! t('toast.approve_success') !!}', 2000, 'success');
card.style.opacity = '0';
setTimeout(() => card.remove(), 300);
} else {
if (window.flashMessage) window.flashMessage(data.msg || '{!! t('toast.approve_error') !!}', 3000, 'error');
}
} catch (err) {
console.error(err);
if (window.flashMessage) window.flashMessage('{!! t('toast.network_error') !!}', 3000, 'error');
}
});
});
// Rating Toggle
document.querySelectorAll('.btn-rating-toggle-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
const url = btn.getAttribute('href');
const card = btn.closest('.approval-card');
const tagsContainer = card.querySelector('.approval-card-tags');
btn.style.opacity = '0.5';
btn.style.pointerEvents = 'none';
try {
const res = await fetch(url, {
method: 'PUT',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json'
}
});
const data = await res.json();
if (data.success && data.tags) {
// Update tags UI
tagsContainer.innerHTML = '';
data.tags.forEach(tag => {
const span = document.createElement('span');
let badgeClass = 'badge-light';
if (tag.normalized === 'sfw') badgeClass = 'badge-success';
else if (tag.normalized === 'nsfw') badgeClass = 'badge-danger';
else if (tag.tag.startsWith('>')) badgeClass = 'badge-greentext badge-light';
else if (tag.normalized === 'ukraine') badgeClass = 'badge-ukraine badge-light';
else if (/[а-яё]/.test(tag.normalized) || tag.normalized === 'russia') badgeClass = 'badge-russia badge-light';
else if (tag.normalized === 'german') badgeClass = 'badge-german badge-light';
else if (tag.normalized === 'dutch') badgeClass = 'badge-dutch badge-light';
span.className = 'badge ' + badgeClass;
span.innerText = tag.tag;
tagsContainer.appendChild(span);
});
} else {
alert('Error: ' + (data.msg || 'Unknown error'));
}
} catch (err) {
console.error(err);
alert('Network error');
} finally {
btn.style.opacity = '1';
btn.style.pointerEvents = 'auto';
}
});
});
// Purge All Trash
const btnPurgeTrash = document.getElementById('btn-purge-trash');
if (btnPurgeTrash) {
btnPurgeTrash.addEventListener('click', () => {
showModal('Purge Trash', 'Permanently delete ALL items in the trash? This cannot be undone.', async () => {
const res = await fetch('/mod/purge-trash-all', {
method: 'POST',
headers: {
'X-CSRF-Token': window.f0ckSession?.csrf_token || ''
}
});
const data = await res.json();
if (data.success) {
location.reload();
} else {
throw new Error(data.msg || 'Purge failed');
}
});
});
}
})();
</script>
</div> </div>
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}}
@include(snippets/mod-cards)
<script>
(() => {
// Approve → public
document.querySelectorAll('.btn-approve-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
btn.disabled = true;
try {
await mq.post('/mod/approve', { id: btn.dataset.id });
mq.flash('{!! t('toast.approve_success') !!}', 'success');
mq.removeCard(btn.closest('.mq-card'));
} catch (err) {
mq.flash(err.message || '{!! t('toast.approve_error') !!}', 'error');
btn.disabled = false;
}
});
});
// Deny → soft deleted (uploader is notified with the reason)
document.querySelectorAll('.btn-deny-async').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
mq.confirm({
heading: 'Deny upload',
body: 'Deny this upload? It moves to Soft Deleted and the uploader is notified with your reason.',
confirmLabel: 'Deny',
withReason: true,
action: async (reason) => {
await mq.post('/mod/deny', { id: btn.dataset.id, reason });
mq.flash('Upload denied', 'success');
mq.removeCard(btn.closest('.mq-card'));
}
});
});
});
})();
</script>
</div> </div>
</div> </div>
@include(snippets/footer) @include(snippets/footer)
+1
View File
@@ -1,5 +1,6 @@
@include(snippets/header) @include(snippets/header)
<div id="main"> <div id="main">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h1>AUDIT LOG</h1> <h1>AUDIT LOG</h1>
<p>Actions performed by moderators and admins.</p> <p>Actions performed by moderators and admins.</p>
+1
View File
@@ -1,6 +1,7 @@
@include(snippets/header) @include(snippets/header)
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
@include(snippets/admin-back)
<div class="container"> <div class="container">
<h2>Moderator Message of the Day (MOTD)</h2> <h2>Moderator Message of the Day (MOTD)</h2>
<p style="color: #ccc; margin-bottom: 20px;">This message is displayed site-wide. It will automatically be tagged with your name (<code>t. {!! session.display_name || session.user !!}</code>).</p> <p style="color: #ccc; margin-bottom: 20px;">This message is displayed site-wide. It will automatically be tagged with your name (<code>t. {!! session.display_name || session.user !!}</code>).</p>
+138
View File
@@ -0,0 +1,138 @@
@include(snippets/header)
<div class="pagewrapper">
<div id="main">
@include(snippets/admin-back)
<div class="container mq mq-trash">
<header class="mq-header">
<div>
<h1 class="mq-title">Soft Deleted</h1>
<div class="mq-subtitle">{{ total }} removed item@if(total !== 1)s@endif still on disk. Restore makes an item public again@if(session.admin); purge deletes it and its files for good@endif.</div>
</div>
@if(trash.length > 0 && session.admin)
<button type="button" id="btn-purge-trash" class="mq-btn mq-btn-danger"><i class="fa-solid fa-trash"></i> Purge all</button>
@endif
</header>
@if(trash.length > 0)
<div class="mq-grid">
@each(trash as post)
<div class="mq-card">
<div class="mq-media">
@if(post.mime === 'video/youtube')
<div class="embed-responsive embed-responsive-16by9">
<iframe class="embed-responsive-item" src="https://www.youtube.com/embed/{!! post.dest.replace('yt:', '') !!}" frameborder="0" allowfullscreen></iframe>
</div>
@elseif(post.mime.startsWith('video'))
<video controls loop muted preload="none" poster="/mod/deleted/t/{!! post.id !!}.webp">
<source src="/mod/deleted/b/{!! post.dest !!}" type="{!! post.mime !!}">
</video>
@elseif(post.mime === 'application/pdf')
<div class="embed-responsive embed-responsive-16by9">
<iframe class="embed-responsive-item" src="/mod/deleted/b/{!! post.dest !!}#toolbar=0" loading="lazy" style="border: none;"></iframe>
</div>
@else
<img src="/mod/deleted/t/{!! post.id !!}.webp" alt="Preview" loading="lazy">
@endif
</div>
<div class="mq-body">
<div class="mq-meta">
<span><strong>#{!! post.id !!}</strong></span>
<span>by <strong>{!! post.username !!}</strong></span>
<span>{!! post.mime !!}</span>
</div>
@if(post.delete_reason)
<div class="mq-reason"><strong>Reason:</strong> {!! post.delete_reason !!}</div>
@endif
<div class="mq-tags">
@each(post.tags as tag)
<span class="badge {!! tag.badge !!}">{!! tag.tag !!}</span>
@endeach
</div>
<div class="mq-actions">
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-primary btn-approve-async">Restore</button>
@if(session.admin)
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-danger btn-deny-async">Purge</button>
@else
<span></span>
@endif
<button type="button" data-id="{!! post.id !!}" class="mq-btn mq-btn-ghost mq-btn-rating btn-rating-toggle-async"><i class="fa-solid fa-shield-halved"></i> Toggle rating</button>
</div>
</div>
</div>
@endeach
</div>
@else
<div class="mq-empty"><strong>Nothing soft deleted.</strong>Removed items show up here until they are restored or purged.</div>
@endif
@if(pages > 1)
<div class="mq-pager">
@if(page > 1)
<a href="/mod/trash?page={!! page - 1 !!}" class="mq-btn mq-btn-ghost">&laquo; Prev</a>
@endif
<span>Page {!! page !!} of {!! pages !!}</span>
@if(page < pages)
<a href="/mod/trash?page={!! page + 1 !!}" class="mq-btn mq-btn-ghost">Next &raquo;</a>
@endif
</div>
@endif
</div>
{{-- Inside #main so AJAX navigation (which only swaps #main) brings the styles, modal and scripts along --}}
@include(snippets/mod-cards)
<script>
(() => {
// Restore → public again (no "approved" notification)
document.querySelectorAll('.btn-approve-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
btn.disabled = true;
try {
await mq.post('/mod/trash/restore', { id: btn.dataset.id });
mq.flash('Item restored', 'success');
mq.removeCard(btn.closest('.mq-card'));
} catch (err) {
mq.flash(err.message, 'error');
btn.disabled = false;
}
});
});
// Purge one item permanently (admins)
document.querySelectorAll('.btn-deny-async').forEach(btn => {
btn.addEventListener('click', e => {
e.preventDefault();
mq.confirm({
heading: 'Purge item',
body: 'Permanently delete this item, its files and its comments? This cannot be undone.',
confirmLabel: 'Purge',
withReason: true,
action: async (reason) => {
await mq.post('/mod/trash/purge', { id: btn.dataset.id, reason });
mq.flash('{!! t('toast.item_deleted_success') !!}', 'success');
mq.removeCard(btn.closest('.mq-card'));
}
});
});
});
// Purge everything (admins)
const purgeAll = document.getElementById('btn-purge-trash');
if (purgeAll) {
purgeAll.addEventListener('click', () => {
mq.confirm({
heading: 'Purge all',
body: 'Permanently delete ALL soft-deleted items, their files and comments? This cannot be undone.',
confirmLabel: 'Purge all',
action: async () => {
const data = await mq.post('/mod/trash/purge-all');
mq.flash('Purged ' + (data.count || 0) + ' item(s)', 'success');
location.reload();
}
});
});
}
})();
</script>
</div>
</div>
@include(snippets/footer)
+1
View File
@@ -2,6 +2,7 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
@include(snippets/admin-back)
<style> <style>
.rp-page { max-width: 900px; margin: 0 auto; padding: 30px 15px 60px; } .rp-page { max-width: 900px; margin: 0 auto; padding: 30px 15px 60px; }
.rp-header { margin-bottom: 28px; } .rp-header { margin-bottom: 28px; }
+2 -4
View File
@@ -10,9 +10,7 @@
<a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a> <a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
@endif @endif
<a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a> <a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
@if(session.is_anon)
<a href="#anon-ssh"><i class="fa-solid fa-key"></i> SSH Identity</a>
@endif
@if(!session.is_anon && enable_data_export) @if(!session.is_anon && enable_data_export)
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a> <a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
@endif @endif
@@ -472,7 +470,7 @@
</div> </div>
</div> </div>
@if(allow_user_upload_visibility !== false) @if(allow_user_upload_visibility !== false && !session.is_anon)
<div class="setting-item" style="margin-top: 18px;"> <div class="setting-item" style="margin-top: 18px;">
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;"> <label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
<span>{{ t('settings.default_upload_visibility') }}</span> <span>{{ t('settings.default_upload_visibility') }}</span>
+124
View File
@@ -0,0 +1,124 @@
{{-- Shared look of the admin (/admin) and moderator (/mod) dashboards: .adm-* classes --}}
<style>
/* Admin & moderator dashboards — square, flat, theme colours (same rules as the modals) */
.adm {
--adm-accent: var(--accent, #0096ff);
--adm-text: var(--text-color, var(--white, #fff));
--adm-muted: var(--text-muted, #8a8f98);
--adm-surface: rgba(255, 255, 255, 0.04);
--adm-border: rgba(255, 255, 255, 0.1);
max-width: 1100px;
margin: 0 auto;
padding: 24px 16px 60px;
text-align: left;
color: var(--adm-text);
line-height: 1.5;
}
.adm *, .adm *::before, .adm *::after { border-radius: 0 !important; }
.adm-header {
display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap;
padding-bottom: 18px; margin-bottom: 26px; border-bottom: 1px solid var(--adm-border);
}
.adm-title { margin: 0; font-size: 1.8em; font-weight: 800; letter-spacing: -0.01em; line-height: 1.1; }
.adm-subtitle { margin-top: 6px; font-size: 0.85em; color: var(--adm-muted); }
.adm-subtitle strong { color: var(--adm-text); }
.adm-section { margin-bottom: 34px; }
.adm-section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.adm-section-title {
display: flex; align-items: center; gap: 10px; margin: 0 0 14px;
font-size: 0.78em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em; color: var(--adm-muted);
}
.adm-section-title i { color: var(--adm-accent); }
.adm-section-title::after { content: ''; flex: 1; height: 1px; background: var(--adm-border); }
.adm-section-head .adm-section-title { flex: 1; }
.adm-group-title { margin: 18px 0 8px; font-size: 0.95em; font-weight: 700; color: var(--adm-text); }
/* Overview tiles */
.adm-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; }
.adm-stat {
display: flex; flex-direction: column; gap: 2px; padding: 14px 16px;
background: var(--adm-surface); border: 1px solid var(--adm-border);
color: var(--adm-text); text-decoration: none;
}
.adm-stat-num { font-size: 1.6em; font-weight: 800; line-height: 1.1; font-variant-numeric: tabular-nums; }
.adm-stat-label { font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--adm-muted); }
.adm-stat-label small { text-transform: none; letter-spacing: 0; font-weight: 500; }
.adm-stat-link { transition: border-color 0.15s; }
.adm-stat-link:hover { border-color: var(--adm-accent); text-decoration: none; color: var(--adm-text); }
.adm-stat.is-hot { border-color: var(--adm-accent); border-left: 3px solid var(--adm-accent); }
.adm-stat.is-hot .adm-stat-num { color: var(--adm-accent); }
/* Tool cards */
.adm-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; }
.adm-tool {
display: flex; align-items: flex-start; gap: 12px; padding: 12px 14px;
background: var(--adm-surface); border: 1px solid var(--adm-border);
color: var(--adm-text); text-decoration: none; transition: border-color 0.15s, background 0.15s;
}
.adm-tool:hover { border-color: var(--adm-accent); background: rgba(255, 255, 255, 0.06); text-decoration: none; color: var(--adm-text); }
.adm-tool > i { width: 20px; margin-top: 3px; text-align: center; color: var(--adm-accent); flex-shrink: 0; }
.adm-tool-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.adm-tool-text strong { display: flex; align-items: center; gap: 8px; font-size: 0.92em; }
.adm-tool-text > span { font-size: 0.78em; color: var(--adm-muted); }
.adm-count { padding: 0 6px; font-size: 0.8em; line-height: 1.6; background: var(--adm-accent); color: #000; font-weight: 800; }
/* Settings panels */
.adm-panel { background: var(--adm-surface); border: 1px solid var(--adm-border); }
.adm-row { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 14px 16px; }
.adm-row + .adm-row { border-top: 1px solid var(--adm-border); }
.adm-row-wrap { flex-wrap: wrap; }
.adm-row-text { min-width: 0; flex: 1 1 260px; }
.adm-row-text label { display: block; margin: 0; font-weight: 700; font-size: 0.92em; color: var(--adm-text); }
.adm-row-text p { margin: 3px 0 0; font-size: 0.8em; color: var(--adm-muted); }
.adm-muted { font-size: 0.78em; font-style: italic; color: var(--adm-muted); }
.adm-number {
width: 76px; height: 36px; padding: 0 8px; text-align: center;
background: rgba(0, 0, 0, 0.3); border: 1px solid var(--adm-border); color: var(--adm-text);
font: inherit; outline: none; flex-shrink: 0;
}
.adm-number:focus { border-color: var(--adm-accent); }
.adm-switch { position: relative; display: inline-block; width: 46px; height: 24px; flex-shrink: 0; cursor: pointer; }
.adm-switch input { opacity: 0; width: 0; height: 0; position: absolute; }
.adm-slider { position: absolute; inset: 0; background: rgba(255, 255, 255, 0.12); border: 1px solid var(--adm-border); transition: background 0.2s, border-color 0.2s; }
.adm-slider::before { content: ''; position: absolute; width: 16px; height: 16px; left: 3px; top: 3px; background: var(--adm-muted); transition: transform 0.2s, background 0.2s; }
.adm-switch input:checked + .adm-slider { background: var(--adm-accent); border-color: var(--adm-accent); }
.adm-switch input:checked + .adm-slider::before { transform: translateX(22px); background: #000; }
.adm-switch input:focus-visible + .adm-slider { outline: 2px solid var(--adm-accent); outline-offset: 2px; }
.adm-brand { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.adm-brand-preview {
width: 160px; height: 46px; display: flex; align-items: center; justify-content: center; overflow: hidden;
background: rgba(0, 0, 0, 0.3); border: 1px solid var(--adm-border);
}
.adm-brand-preview img { max-height: 40px; max-width: 150px; object-fit: contain; }
/* Buttons */
.adm-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; height: 36px; margin: 0; padding: 0 16px;
font: inherit; font-size: 0.82em; font-weight: 700; cursor: pointer; text-decoration: none; white-space: nowrap;
border: 1px solid var(--adm-border); background: transparent; color: var(--adm-text);
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.adm-btn:hover { text-decoration: none; }
.adm-btn-primary { background: var(--adm-accent); border-color: var(--adm-accent); color: #000; text-transform: uppercase; letter-spacing: 0.06em; }
.adm-btn-primary:hover { background: transparent; color: var(--adm-accent); }
.adm-btn-ghost { color: var(--adm-muted); }
.adm-btn-ghost:hover { color: var(--adm-text); border-color: var(--adm-accent); }
.adm-btn-danger { border-color: rgba(220, 53, 69, 0.5); color: #ff6b6b; }
.adm-btn-danger:hover { background: rgba(220, 53, 69, 0.15); }
.adm-btn:disabled { opacity: 0.4; cursor: not-allowed; background: transparent; }
.adm-btn:focus-visible { outline: 2px solid var(--adm-accent); outline-offset: 2px; }
.adm-status { font-size: 0.8em; font-weight: 700; min-height: 1em; }
.adm-status-row { display: block; padding: 0 16px 12px; }
.adm-status-row:empty { display: none; }
@media (max-width: 600px) {
.adm-row { flex-wrap: wrap; }
.adm-tools { grid-template-columns: 1fr; }
}
</style>
+81
View File
@@ -0,0 +1,81 @@
{{-- Back-to-dashboard bar for admin/mod subpages: /mod… pages (and non-admins) go to /mod, /admin… pages to /admin. Include right after #main opens (AJAX nav only swaps #main). --}}
<nav class="adm-back" aria-label="Breadcrumb">
@if(session && session.admin && !is_mod_area)
<a href="/admin" class="adm-back-link">
<span class="adm-back-arrow"><i class="fa-solid fa-arrow-left"></i></span>
<span class="adm-back-text"><small>Back to</small>Admin dashboard</span>
</a>
@else
<a href="/mod" class="adm-back-link">
<span class="adm-back-arrow"><i class="fa-solid fa-arrow-left"></i></span>
<span class="adm-back-text"><small>Back to</small>Moderator dashboard</span>
</a>
@endif
<span class="adm-back-crumb" aria-current="page"></span>
</nav>
@include(snippets/admin-skin)
<style>
.adm-back {
--ab-accent: var(--accent, #0096ff);
display: flex; align-items: center; justify-content: space-between; gap: 12px;
max-width: 1200px; margin: 0 auto; padding: 18px 16px 0; box-sizing: border-box;
text-align: left;
}
.adm-back, .adm-back * { border-radius: 0 !important; }
.adm-back-link {
position: relative; display: inline-flex; align-items: stretch; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(255, 255, 255, 0.03);
color: var(--text-color, var(--white, #fff)); text-decoration: none;
transition: border-color 0.2s, background 0.2s;
}
.adm-back-link::after {
content: ''; position: absolute; left: 0; bottom: 0; height: 2px; width: 100%;
background: var(--ab-accent); transform: scaleX(0); transform-origin: left; transition: transform 0.25s ease;
}
.adm-back-link:hover, .adm-back-link:focus-visible {
border-color: var(--ab-accent); background: color-mix(in srgb, var(--ab-accent) 8%, transparent);
color: var(--text-color, var(--white, #fff)); text-decoration: none; outline: none;
}
.adm-back-link:hover::after, .adm-back-link:focus-visible::after { transform: scaleX(1); }
.adm-back-arrow {
display: flex; align-items: center; justify-content: center; width: 38px;
background: var(--ab-accent); color: #000; font-size: 0.9em;
}
.adm-back-arrow i { transition: transform 0.2s ease; }
.adm-back-link:hover .adm-back-arrow i { transform: translateX(-3px); }
.adm-back-text {
display: flex; flex-direction: column; justify-content: center; padding: 6px 14px 7px 12px;
font-size: 0.86em; font-weight: 700; line-height: 1.2; white-space: nowrap;
}
.adm-back-text small {
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--text-muted, #8a8f98);
}
.adm-back-crumb {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.14em;
color: var(--text-muted, #8a8f98);
}
.adm-back-crumb:not(:empty)::before { content: '/ '; color: var(--ab-accent); }
@media (prefers-reduced-motion: reduce) {
.adm-back-link::after, .adm-back-arrow i { transition: none; }
}
</style>
<script>
(() => {
// Breadcrumb = the page's own heading (first h1/h2 in #main). The rest of the page isn't in the
// DOM yet when this runs (parser / AJAX insert), so wait until it is.
const fill = () => {
const crumb = document.querySelector('#main .adm-back-crumb');
const main = document.getElementById('main');
if (!crumb || !main) return;
const heading = Array.from(main.querySelectorAll('h1, h2')).find(h => !h.closest('.adm-back') && h.textContent.trim());
if (heading) crumb.textContent = heading.innerText.trim().slice(0, 60); // innerText already collapses whitespace (no regex: the template engine strips backslashes)
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fill, { once: true });
else setTimeout(fill, 0);
})();
</script>
+87
View File
@@ -0,0 +1,87 @@
{{-- Admin/mod subpage skin (style guide: square, flat, theme colours). Loaded by snippets/admin-back, scoped to pages that show the back bar, except the .mq card pages which have their own design. --}}
<style>
/* Scope root: #main of an admin subpage. Most rules sit in :where() (zero specificity) so a page's own
classes still win; only corners and form-control surfaces are forced because pages hard-code them inline. */
#main:has(> .adm-back):not(:has(.mq)) {
--as-accent: var(--accent, #0096ff);
--as-text: var(--text-color, var(--white, #fff));
--as-muted: var(--text-muted, #8a8f98);
--as-surface: rgba(255, 255, 255, 0.04);
--as-border: rgba(255, 255, 255, 0.1);
text-align: left;
}
/* ── Corners: none, anywhere ─────────────────────────────────────────── */
#main:has(> .adm-back):not(:has(.mq)) *,
#main:has(> .adm-back):not(:has(.mq)) *::before,
#main:has(> .adm-back):not(:has(.mq)) *::after { border-radius: 0 !important; }
/* ── Layout ──────────────────────────────────────────────────────────── */
:where(#main:has(> .adm-back):not(:has(.mq))) > .container {
max-width: 1200px; margin: 0 auto; padding: 20px 16px 60px; box-sizing: border-box; text-align: left;
}
/* ── Headings ────────────────────────────────────────────────────────── */
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.container > h1, .container > h2, .admin-header-flex h2, .session-page-title) {
margin: 0 0 6px; font-size: 1.8em; font-weight: 800; letter-spacing: -0.01em; line-height: 1.15; color: var(--as-text);
text-transform: none;
}
:where(#main:has(> .adm-back):not(:has(.mq))) :is(h3, h4) { font-weight: 700; color: var(--as-text); }
:where(#main:has(> .adm-back):not(:has(.mq))) .admin-header-flex {
display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap;
padding-bottom: 16px; margin-bottom: 20px; border-bottom: 1px solid var(--as-border);
}
:where(#main:has(> .adm-back):not(:has(.mq))) hr { border: 0; border-top: 1px solid var(--as-border); margin: 20px 0; }
:where(#main:has(> .adm-back):not(:has(.mq))) label { color: var(--as-text); }
/* ── Cards / panels ──────────────────────────────────────────────────── */
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.upload-form, .admin-form-container, .admin-motd-form, .settings-section, .admin-card) {
background: var(--as-surface); border: 1px solid var(--as-border); padding: 18px; box-shadow: none;
}
/* ── Tables ──────────────────────────────────────────────────────────── */
:where(#main:has(> .adm-back):not(:has(.mq))) table { width: 100%; border-collapse: collapse; background: transparent; color: var(--as-text); }
:where(#main:has(> .adm-back):not(:has(.mq))) :is(th, thead td) {
padding: 10px 12px; text-align: left; font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--as-muted); background: var(--as-surface); border-bottom: 1px solid var(--as-border);
}
:where(#main:has(> .adm-back):not(:has(.mq))) tbody td { padding: 10px 12px; border-bottom: 1px solid var(--as-border); vertical-align: middle; }
:where(#main:has(> .adm-back):not(:has(.mq))) tbody tr { background: transparent; transition: background 0.12s; }
:where(#main:has(> .adm-back):not(:has(.mq))) tbody tr:hover { background: var(--as-surface); }
/* ── Form controls (forced: pages hard-code #333 backgrounds inline) ── */
#main:has(> .adm-back):not(:has(.mq)) :is(input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=range]):not([type=color]):not([type=hidden]), select, textarea) {
background: var(--as-surface) !important;
border: 1px solid var(--as-border) !important;
color: var(--as-text) !important;
font-family: inherit; box-shadow: none !important; outline: none;
transition: border-color 0.15s;
}
:where(#main:has(> .adm-back):not(:has(.mq))) :is(input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=range]):not([type=color]):not([type=hidden]), select) {
min-height: 34px; padding: 6px 10px; box-sizing: border-box;
}
:where(#main:has(> .adm-back):not(:has(.mq))) textarea { padding: 10px 12px; box-sizing: border-box; }
#main:has(> .adm-back):not(:has(.mq)) :is(input, select, textarea):focus { border-color: var(--as-accent) !important; }
#main:has(> .adm-back):not(:has(.mq)) select option { background: var(--bg, #111); color: var(--as-text); }
:where(#main:has(> .adm-back):not(:has(.mq))) :is(input[type=checkbox], input[type=radio]) { accent-color: var(--as-accent); }
/* ── Buttons ─────────────────────────────────────────────────────────── */
:where(#main:has(> .adm-back):not(:has(.mq))) :is(button, .btn, input[type=submit]) { font-family: inherit; cursor: pointer; }
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.btn-upload, .btn-primary, button[type=submit], input[type=submit]) {
min-height: 36px; padding: 0 18px; border: 1px solid var(--as-accent); background: var(--as-accent); color: #000;
font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.82em;
transition: background 0.15s, color 0.15s;
}
:where(#main:has(> .adm-back):not(:has(.mq))) :is(.btn-upload, .btn-primary, button[type=submit], input[type=submit]):hover:not(:disabled) {
background: transparent; color: var(--as-accent);
}
:where(#main:has(> .adm-back):not(:has(.mq))) :is(button, .btn):disabled { opacity: 0.5; cursor: not-allowed; }
#main:has(> .adm-back):not(:has(.mq)) :is(button, a, input, select, textarea):focus-visible { outline: 2px solid var(--as-accent); outline-offset: 2px; }
/* ── Modals opened from admin pages ──────────────────────────────────── */
#main:has(> .adm-back):not(:has(.mq)) :is(.modal-content, .modal-overlay > div:first-child) {
background: var(--bg, #111) !important;
border: 1px solid var(--as-accent) !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
}
</style>
+126 -22
View File
@@ -68,36 +68,45 @@
@endif @endif
@if(!private_society || session) @if(!private_society || session)
<div id="report-modal" class="modal-overlay" style="display:none;"> <div id="report-modal" class="modal-overlay" style="display:none;">
<div class="modal-content" style="max-height: 90vh; overflow-y: auto;"> <div class="modal-content report-modal-content">
<h3>{{ t('report.title') }}</h3> <div class="rm-header">
<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> <h3>{{ t('report.title') }}</h3>
<p>Select one or more reasons and optionally add details below.</p>
</div>
<div class="rm-body">
<input type="hidden" id="report-item-id"> <input type="hidden" id="report-item-id">
<input type="hidden" id="report-comment-id"> <input type="hidden" id="report-comment-id">
<input type="hidden" id="report-user-id"> <input type="hidden" id="report-user-id">
<div id="report-categories" style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px;"> <div id="report-categories">
<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;"> <label class="report-category-option">
<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;"> <input type="checkbox" name="report_category" value="wrong_rating" class="report-cat-check">
<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> <span class="rm-check"><i class="fa-solid fa-check"></i></span>
<span class="rm-text"><strong>Wrong Rating</strong><span>Content is marked SFW but should be NSFW, or vice versa.</span></span>
</label> </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;"> <label class="report-category-option">
<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;"> <input type="checkbox" name="report_category" value="spam" class="report-cat-check">
<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> <span class="rm-check"><i class="fa-solid fa-check"></i></span>
<span class="rm-text"><strong>Spam</strong><span>Repeated, unwanted, or promotional content.</span></span>
</label> </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;"> <label class="report-category-option">
<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;"> <input type="checkbox" name="report_category" value="duplicate" class="report-cat-check">
<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> <span class="rm-check"><i class="fa-solid fa-check"></i></span>
<span class="rm-text"><strong>Duplicate</strong><span>This content has already been posted before.</span></span>
</label> </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;"> <label class="report-category-option">
<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;"> <input type="checkbox" name="report_category" value="copyright" class="report-cat-check">
<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> <span class="rm-check"><i class="fa-solid fa-check"></i></span>
<span class="rm-text"><strong>Copyright</strong><span>Content infringes on intellectual property rights.</span></span>
</label> </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;"> <label class="report-category-option rm-danger">
<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;"> <input type="checkbox" name="report_category" value="illegal" class="report-cat-check">
<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> <span class="rm-check"><i class="fa-solid fa-check"></i></span>
<span class="rm-text"><strong>Illegal Content</strong><span>Content that may violate laws or platform rules.</span></span>
</label> </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;"> <label class="report-category-option">
<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;"> <input type="checkbox" name="report_category" value="other" class="report-cat-check">
<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> <span class="rm-check"><i class="fa-solid fa-check"></i></span>
<span class="rm-text"><strong>Other</strong><span>Something else not listed above.</span></span>
</label> </label>
</div> </div>
<textarea id="report-reason" class="mod-reason" placeholder="{{ t('report.placeholder') }}"></textarea> <textarea id="report-reason" class="mod-reason" placeholder="{{ t('report.placeholder') }}"></textarea>
@@ -105,6 +114,7 @@
<div id="modal-report-recaptcha" data-sitekey="{{ recaptcha_site_key }}" style="margin: 10px 0; display: flex; justify-content: center;"></div> <div id="modal-report-recaptcha" data-sitekey="{{ recaptcha_site_key }}" style="margin: 10px 0; display: flex; justify-content: center;"></div>
@endif @endif
<div id="report-error" class="error-msg"></div> <div id="report-error" class="error-msg"></div>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button id="report-cancel" class="btn-secondary">{{ t('common.cancel') }}</button> <button id="report-cancel" class="btn-secondary">{{ t('common.cancel') }}</button>
<button id="report-submit" class="">{{ t('report.submit') }}</button> <button id="report-submit" class="">{{ t('report.submit') }}</button>
@@ -112,6 +122,100 @@
</div> </div>
</div> </div>
<style>
/* Report modal — same style rules as the login/info modals: square, flat, accent primary button */
#report-modal .report-modal-content {
--rm-accent: var(--accent, #0096ff);
--rm-text: var(--text-color, #fff);
--rm-muted: var(--text-muted, #8a8f98);
--rm-surface: rgba(255, 255, 255, 0.04);
--rm-border: rgba(255, 255, 255, 0.1);
width: min(460px, 92vw);
max-height: min(90vh, 90dvh);
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
text-align: left;
border-radius: 0 !important;
border: 1px solid var(--accent) !important;
background: var(--bg) !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
color: var(--rm-text);
}
#report-modal .report-modal-content * { border-radius: 0 !important; }
#report-modal .rm-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--rm-border); }
#report-modal .rm-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 24px; }
#report-modal .rm-body > :last-child { margin-bottom: 0; }
#report-modal .rm-header h3 { margin: 0; font-size: 1.25em; font-weight: 700; color: var(--rm-text); }
#report-modal .rm-header p { margin: 6px 0 0; font-size: 0.85em; line-height: 1.5; color: var(--rm-muted); }
#report-modal #report-categories { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
#report-modal .report-category-option {
display: flex; align-items: flex-start; gap: 11px; padding: 10px 12px;
background: var(--rm-surface); border: 1px solid var(--rm-border);
cursor: pointer; user-select: none; transition: border-color 0.15s, background 0.15s;
}
#report-modal .report-category-option:hover { border-color: rgba(255, 255, 255, 0.22); }
#report-modal .report-category-option:has(.report-cat-check:checked) {
border-color: var(--rm-accent);
background: color-mix(in srgb, var(--rm-accent) 8%, transparent);
}
#report-modal .report-cat-check { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
#report-modal .rm-check {
width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.25); background: var(--rm-surface);
font-size: 0.7em; color: transparent; transition: background 0.15s, border-color 0.15s, color 0.15s;
}
#report-modal .report-cat-check:checked + .rm-check { background: var(--rm-accent); border-color: var(--rm-accent); color: #000; }
#report-modal .report-cat-check:focus-visible + .rm-check { outline: 2px solid var(--rm-accent); outline-offset: 2px; }
#report-modal .rm-text { display: flex; flex-direction: column; gap: 2px; font-size: 0.88em; line-height: 1.4; }
#report-modal .rm-text strong { color: var(--rm-text); font-weight: 700; }
#report-modal .rm-text span { color: var(--rm-muted); }
#report-modal .rm-danger .rm-text strong { color: var(--danger, #ff5c5c); }
#report-modal .rm-danger:has(.report-cat-check:checked) { border-color: var(--danger, #ff5c5c); background: rgba(255, 92, 92, 0.08); }
#report-modal .rm-danger .report-cat-check:checked + .rm-check { background: var(--danger, #ff5c5c); border-color: var(--danger, #ff5c5c); }
#report-modal textarea.mod-reason {
width: 100%; min-height: 90px; box-sizing: border-box; margin: 0; padding: 10px 12px;
background: var(--rm-surface); border: 1px solid var(--rm-border); color: var(--rm-text);
font: inherit; font-size: 0.9em; resize: vertical; outline: none; box-shadow: none;
transition: border-color 0.15s;
}
#report-modal textarea.mod-reason:focus { border-color: var(--rm-accent); }
#report-modal textarea.mod-reason::placeholder { color: var(--rm-muted); }
#report-modal .modal-actions {
flex-shrink: 0; display: flex; justify-content: flex-end; gap: 8px; margin: 0;
padding: 14px 24px; border-top: 1px solid var(--rm-border); background: rgba(0, 0, 0, 0.2);
}
@media (max-width: 600px) {
#report-modal { padding: 0 !important; align-items: stretch !important; }
#report-modal .report-modal-content {
width: 100vw; max-width: none;
height: 100vh; height: 100dvh; max-height: none;
border: 0 !important; box-shadow: none !important;
}
#report-modal .modal-actions { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
#report-modal .modal-actions button { flex: 1; }
}
#report-modal .modal-actions button {
height: 40px; margin: 0; padding: 0 18px; font: inherit; font-size: 0.85em; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
transition: background 0.18s, color 0.18s, border-color 0.18s;
}
#report-modal #report-cancel { background: transparent; border: 1px solid var(--rm-border); color: var(--rm-muted); font-weight: 600; }
#report-modal #report-cancel:hover { color: var(--rm-text); border-color: rgba(255, 255, 255, 0.25); background: var(--rm-surface); }
#report-modal #report-submit {
background: var(--rm-accent); border: 1px solid var(--rm-accent); color: #000;
font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
}
#report-modal #report-submit:hover:not(:disabled) { background: transparent; color: var(--rm-accent); }
#report-modal #report-submit:disabled { opacity: 0.6; cursor: progress; }
#report-modal button:focus-visible { outline: 2px solid var(--rm-accent); outline-offset: 2px; }
</style>
<div id="warning-modal" class="modal-overlay" style="display:none; z-index: 100100;"> <div id="warning-modal" class="modal-overlay" style="display:none; z-index: 100100;">
<div class="modal-content content-warning-content"> <div class="modal-content content-warning-content">
<h3 style="color: var(--danger);">{{ t('account_warning.title') }}</h3> <h3 style="color: var(--danger);">{{ t('account_warning.title') }}</h3>
+198
View File
@@ -107,9 +107,15 @@
<div class="infobox-card" id="info-card-directlink"> <div class="infobox-card" id="info-card-directlink">
<label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label> <label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label>
<div class="infobox-card-value"> <div class="infobox-card-value">
@if(item.external_thread_url)
<a href="{{ item.external_thread_url }}" target="_blank" rel="noopener noreferrer" class="infobox-link-pill" id="info-file-direct-link" title="{{ item.external_thread_url }}">
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
</a>
@else
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill" id="info-file-direct-link"> <a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill" id="info-file-direct-link">
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }} <i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
</a> </a>
@endif
</div> </div>
</div> </div>
@@ -167,3 +173,195 @@
</div> </div>
</div> </div>
</div> </div>
<style>
/* Info modal — same style rules as the login/register/anon modals: square, flat, no glow */
#info-modal .infobox-modal-content {
--im-accent: var(--accent, #0096ff);
--im-text: var(--text-color, #fff);
--im-muted: var(--text-muted, #8a8f98);
--im-surface: rgba(255, 255, 255, 0.04);
--im-border: rgba(255, 255, 255, 0.1);
padding: 0;
/* Same box as .login-modal-content (f0ckm.css); !important beats the older infobox rules */
border-radius: 0 !important;
border: 1px solid var(--accent) !important;
background: var(--bg) !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
color: var(--im-text);
overflow: hidden;
}
#info-modal .infobox-modal-content * { border-radius: 0 !important; }
/* Header */
#info-modal .infobox-modal-content .infobox-modal-header {
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
margin: 0; padding: 22px 24px 18px;
background: none;
border-bottom: 1px solid var(--im-border);
}
#info-modal .infobox-modal-content .infobox-modal-header h3 {
margin: 0; font-size: 1.25em; font-weight: 700; letter-spacing: -0.01em; color: var(--im-text);
}
#info-modal .infobox-modal-content .infobox-sub-heading {
display: block; margin-top: 5px; font-size: 0.8em; color: var(--im-muted);
}
#info-modal .infobox-modal-content .infobox-sub-heading code {
padding: 1px 6px; background: var(--im-surface); border: 1px solid var(--im-border);
color: var(--im-accent); font-size: 1em;
}
#info-modal .infobox-modal-content .infobox-close-icon-btn {
flex-shrink: 0; width: 34px; height: 34px; margin: -4px -8px 0 0; padding: 0;
display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid transparent;
color: var(--im-muted); font-size: 1.05em; cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
#info-modal .infobox-modal-content .infobox-close-icon-btn:hover {
color: var(--im-text); background: var(--im-surface); border-color: var(--im-border);
}
/* Body + sections */
#info-modal .infobox-modal-content .infobox-modal-body {
padding: 20px 24px; display: flex; flex-direction: column; gap: 22px;
}
#info-modal .infobox-modal-content .infobox-section { margin: 0; padding: 0; background: none; border: 0; }
#info-modal .infobox-modal-content .infobox-section-header {
display: flex; align-items: center; gap: 8px; margin: 0 0 10px; padding: 0;
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em;
color: var(--im-muted); background: none; border: 0;
}
#info-modal .infobox-modal-content .infobox-section-header i { color: var(--im-accent); }
#info-modal .infobox-modal-content .infobox-section-header::after {
content: ''; flex: 1; height: 1px; background: var(--im-border);
}
/* Cards */
#info-modal .infobox-modal-content .infobox-cards-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px;
}
#info-modal .infobox-modal-content .infobox-card {
margin: 0; padding: 12px 14px;
background: var(--im-surface); border: 1px solid var(--im-border); box-shadow: none;
display: flex; flex-direction: column; gap: 8px; min-width: 0;
transition: border-color 0.15s;
}
#info-modal .infobox-modal-content .infobox-card:hover { border-color: rgba(255, 255, 255, 0.18); }
#info-modal .infobox-modal-content .infobox-card.full-width { grid-column: 1 / -1; }
#info-modal .infobox-modal-content .infobox-card-label {
display: flex; align-items: center; gap: 6px; margin: 0;
font-size: 0.7em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--im-muted);
}
#info-modal .infobox-modal-content .infobox-card-label i { color: var(--im-accent); opacity: 0.85; width: 1em; text-align: center; }
#info-modal .infobox-modal-content .infobox-card-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
#info-modal .infobox-modal-content .infobox-card-value { font-size: 0.95em; color: var(--im-text); overflow-wrap: anywhere; }
#info-modal .infobox-modal-content .infobox-card-value-row,
#info-modal .infobox-modal-content .infobox-expiry-content {
display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; font-size: 0.92em;
}
#info-modal .infobox-modal-content .info-expiry-edit-controls { display: flex; gap: 6px; width: 100%; }
/* Small controls inside cards */
#info-modal .infobox-modal-content .infobox-action-btn,
#info-modal .infobox-modal-content .copy-hash-btn {
height: 28px; margin: 0; padding: 0 10px;
display: inline-flex; align-items: center; gap: 6px;
background: transparent; border: 1px solid var(--im-border);
color: var(--im-muted); font: inherit; font-size: 0.78em; font-weight: 600; cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
#info-modal .infobox-modal-content .infobox-action-btn:hover,
#info-modal .infobox-modal-content .copy-hash-btn:hover {
color: var(--im-accent); border-color: var(--im-accent); background: transparent;
}
#info-modal .infobox-modal-content .infobox-select {
flex: 1; min-width: 0; height: 28px; margin: 0; padding: 0 8px;
background: rgba(0, 0, 0, 0.35); border: 1px solid var(--im-border);
color: var(--im-text); font: inherit; font-size: 0.82em; outline: none;
}
#info-modal .infobox-modal-content .infobox-select:focus { border-color: var(--im-accent); }
#info-modal .infobox-modal-content .mime-pill {
display: inline-block; padding: 2px 8px;
background: rgba(0, 0, 0, 0.35); border: 1px solid var(--im-border);
color: var(--im-text); font-size: 0.85em;
}
#info-modal .infobox-modal-content .infobox-link-pill,
#info-modal .infobox-modal-content .infobox-source-link {
display: inline-flex; align-items: center; gap: 7px; max-width: 100%;
color: var(--im-accent); text-decoration: none; font-weight: 600; font-size: 0.92em;
background: none; border: 0; padding: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#info-modal .infobox-modal-content .infobox-link-pill:hover,
#info-modal .infobox-modal-content .infobox-source-link:hover { text-decoration: underline; text-underline-offset: 3px; }
#info-modal .infobox-modal-content .info-hash-codeblock {
margin: 0; padding: 10px 12px;
background: rgba(0, 0, 0, 0.4); border: 1px solid var(--im-border); border-left: 2px solid var(--im-accent);
font-size: 0.8em; line-height: 1.5; color: var(--im-text); word-break: break-all;
}
#info-modal .infobox-modal-content .info-hash-codeblock code { background: none; border: 0; padding: 0; color: inherit; }
/* Reposts */
#info-modal .infobox-modal-content .infobox-reposts-list { display: flex; flex-wrap: wrap; gap: 6px; }
#info-modal .infobox-modal-content .repost-pill {
display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
background: var(--im-surface); border: 1px solid var(--im-border);
color: var(--im-text); font-size: 0.82em; text-decoration: none;
transition: border-color 0.15s, color 0.15s;
}
#info-modal .infobox-modal-content .repost-pill:hover { border-color: var(--im-accent); color: var(--im-accent); }
#info-modal .infobox-modal-content .repost-pill.exact i { color: var(--im-accent); }
/* Footer */
#info-modal .infobox-modal-content .infobox-modal-footer {
display: flex; justify-content: flex-end; align-items: center; gap: 8px; flex-wrap: wrap;
margin: 0; padding: 16px 24px;
background: rgba(0, 0, 0, 0.25); border-top: 1px solid var(--im-border);
}
#info-modal .infobox-modal-content .btn-infobox-action,
#info-modal .infobox-modal-content .btn-infobox-close {
height: 40px; margin: 0; padding: 0 18px;
display: inline-flex; align-items: center; justify-content: center; gap: 8px;
font: inherit; font-size: 0.85em; cursor: pointer; box-shadow: none;
transition: background 0.18s, color 0.18s, border-color 0.18s;
}
#info-modal .infobox-modal-content .btn-infobox-action {
background: transparent; border: 1px solid var(--im-border); color: var(--im-muted); font-weight: 600;
}
#info-modal .infobox-modal-content .btn-infobox-action:hover { color: var(--im-text); border-color: rgba(255, 255, 255, 0.25); background: var(--im-surface); }
#info-modal .infobox-modal-content .btn-infobox-close {
background: var(--im-accent); border: 1px solid var(--im-accent); color: #000;
font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
}
#info-modal .infobox-modal-content .btn-infobox-close:hover { background: transparent; color: var(--im-accent); }
#info-modal .infobox-modal-content :is(button, a, select):focus-visible { outline: 2px solid var(--im-accent); outline-offset: 2px; }
/* Fixed header + footer, only the body scrolls; full screen on phones */
#info-modal .infobox-modal-content {
display: flex !important;
flex-direction: column !important;
max-height: min(90vh, 90dvh) !important;
}
#info-modal .infobox-modal-content .infobox-modal-header,
#info-modal .infobox-modal-content .infobox-modal-footer { flex-shrink: 0; }
#info-modal .infobox-modal-content .infobox-modal-body {
flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; overscroll-behavior: contain;
}
@media (max-width: 600px) {
#info-modal { padding: 0 !important; align-items: stretch !important; }
#info-modal .infobox-modal-content {
width: 100vw !important; max-width: none !important;
height: 100vh !important; height: 100dvh !important; max-height: none !important;
border: 0 !important; box-shadow: none !important;
}
#info-modal .infobox-modal-content .infobox-modal-footer { padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 520px) {
#info-modal .infobox-modal-content .infobox-modal-header,
#info-modal .infobox-modal-content .infobox-modal-body,
#info-modal .infobox-modal-content .infobox-modal-footer { padding-left: 16px; padding-right: 16px; }
#info-modal .infobox-modal-content .infobox-modal-footer > * { flex: 1; }
}
</style>
+205
View File
@@ -0,0 +1,205 @@
{{-- Shared card UI for mod/approve.html and mod/trash.html: styles, confirmation modal, rating toggle and helpers on window.mq (page actions live in each page) --}}
<div id="mq-modal" class="mq-modal" style="display:none;">
<div class="mq-modal-box">
<h3 id="mq-modal-title">Confirm action</h3>
<p id="mq-modal-text">Are you sure?</p>
<div id="mq-modal-reason-wrap" style="display:none;">
<label for="mq-modal-reason">Reason (required)</label>
<textarea id="mq-modal-reason" rows="3" placeholder="Enter a reason…"></textarea>
</div>
<div class="mq-modal-actions">
<button type="button" id="mq-modal-cancel" class="mq-btn mq-btn-ghost">Cancel</button>
<button type="button" id="mq-modal-confirm" class="mq-btn mq-btn-danger">Confirm</button>
</div>
</div>
</div>
<style>
/* Moderation queues — square, flat, theme colours */
.mq {
--mq-accent: var(--accent, #0096ff);
--mq-text: var(--text-color, var(--white, #fff));
--mq-muted: var(--text-muted, #8a8f98);
--mq-surface: rgba(255, 255, 255, 0.04);
--mq-border: rgba(255, 255, 255, 0.1);
--mq-danger: #ff5c5c;
max-width: 1200px; margin: 0 auto; padding: 24px 16px 60px;
text-align: left; color: var(--mq-text); line-height: 1.5;
}
.mq *, .mq *::before, .mq *::after, .mq-modal *, .mq-modal *::before, .mq-modal *::after { border-radius: 0 !important; }
.mq-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 18px; }
.mq-title { margin: 0; font-size: 1.8em; font-weight: 800; line-height: 1.1; }
.mq-subtitle { margin-top: 6px; font-size: 0.85em; color: var(--mq-muted); }
.mq-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 10px; }
.mq-card { display: flex; flex-direction: column; background: var(--mq-surface); border: 1px solid var(--mq-border); transition: opacity 0.3s, border-color 0.15s; }
.mq-card:hover { border-color: rgba(255, 255, 255, 0.2); }
.mq-media { position: relative; aspect-ratio: 16 / 10; background: #000; display: flex; align-items: center; justify-content: center; overflow: hidden; }
.mq-media img, .mq-media video { width: 100%; height: 100%; object-fit: contain; display: block; }
.mq-media .embed-responsive { width: 100%; }
.mq-trash .mq-media img, .mq-trash .mq-media video { filter: grayscale(50%); }
.mq-body { display: flex; flex-direction: column; gap: 8px; padding: 12px; flex: 1; }
.mq-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 0.8em; color: var(--mq-muted); }
.mq-meta strong { color: var(--mq-text); font-weight: 700; }
.mq-reason { padding: 6px 8px; font-size: 0.8em; color: #ffb8b8; background: rgba(255, 92, 92, 0.08); border-left: 2px solid var(--mq-danger); }
.mq-tags { display: flex; flex-wrap: wrap; gap: 4px; min-height: 22px; }
.mq-tags .badge { margin: 0; }
.mq-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: auto; }
.mq-actions .mq-btn-rating { grid-column: span 2; }
.mq-btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 34px; margin: 0; padding: 0 12px;
font: inherit; font-size: 0.8em; font-weight: 700; cursor: pointer; text-decoration: none; white-space: nowrap;
border: 1px solid var(--mq-border); background: transparent; color: var(--mq-text);
transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s;
}
.mq-btn:hover { text-decoration: none; }
.mq-btn:disabled { opacity: 0.5; cursor: progress; }
.mq-btn-primary { background: var(--mq-accent); border-color: var(--mq-accent); color: #000; text-transform: uppercase; letter-spacing: 0.06em; }
.mq-btn-primary:hover:not(:disabled) { background: transparent; color: var(--mq-accent); }
.mq-btn-danger { border-color: rgba(255, 92, 92, 0.5); color: var(--mq-danger); }
.mq-btn-danger:hover:not(:disabled) { background: rgba(255, 92, 92, 0.15); }
.mq-btn-ghost { color: var(--mq-muted); }
.mq-btn-ghost:hover:not(:disabled) { color: var(--mq-text); border-color: rgba(255, 255, 255, 0.25); background: var(--mq-surface); }
.mq-btn:focus-visible { outline: 2px solid var(--mq-accent); outline-offset: 2px; }
.mq-note { margin: 0 0 16px; font-size: 0.85em; color: var(--mq-muted); }
.mq-empty { padding: 40px 20px; text-align: center; color: var(--mq-muted); border: 1px dashed var(--mq-border); }
.mq-empty strong { display: block; margin-bottom: 4px; color: var(--mq-text); font-size: 1.05em; }
.mq-pager { display: flex; align-items: center; justify-content: center; gap: 10px; margin-top: 24px; font-size: 0.85em; color: var(--mq-muted); }
.mq-modal { position: fixed; inset: 0; z-index: 100100; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.8); backdrop-filter: blur(4px); padding: 16px; }
.mq-modal-box {
width: min(420px, 100%); padding: 22px; text-align: left; color: var(--text-color, #fff);
background: var(--bg); border: 1px solid var(--accent); box-shadow: 0 0 20px rgba(0, 0, 0, 0.5);
}
.mq-modal-box h3 { margin: 0 0 6px; font-size: 1.15em; font-weight: 700; }
.mq-modal-box p { margin: 0 0 14px; font-size: 0.88em; color: var(--text-muted, #8a8f98); }
.mq-modal-box label { display: block; margin-bottom: 6px; font-size: 0.78em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted, #8a8f98); }
.mq-modal-box textarea {
width: 100%; box-sizing: border-box; padding: 10px 12px; margin-bottom: 14px; resize: vertical;
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.1); color: inherit; font: inherit; font-size: 0.9em; outline: none;
}
.mq-modal-box textarea:focus { border-color: var(--accent); }
.mq-modal-box textarea.is-invalid { border-color: #ff5c5c; }
.mq-modal-actions { display: flex; justify-content: flex-end; gap: 8px; }
.mq-modal .mq-btn { height: 38px; padding: 0 16px; }
</style>
<script>
// Shared helpers for the moderation card pages. Re-assigned on every (AJAX) page load.
window.mq = (() => {
const modal = document.getElementById('mq-modal');
const title = document.getElementById('mq-modal-title');
const text = document.getElementById('mq-modal-text');
const reasonWrap = document.getElementById('mq-modal-reason-wrap');
const reasonInput = document.getElementById('mq-modal-reason');
const btnConfirm = document.getElementById('mq-modal-confirm');
const btnCancel = document.getElementById('mq-modal-cancel');
const csrf = () => (window.f0ckSession && window.f0ckSession.csrf_token) || '';
const flash = (msg, type) => { if (window.flashMessage) window.flashMessage(msg, type === 'error' ? 3000 : 2000, type); };
let pendingAction = null;
let needsReason = false;
const close = () => { modal.style.display = 'none'; pendingAction = null; };
const confirm = ({ heading, body, confirmLabel = 'Confirm', withReason = false, action }) => {
title.innerText = heading;
text.innerText = body;
pendingAction = action;
needsReason = withReason;
btnConfirm.innerText = confirmLabel;
btnConfirm.dataset.label = confirmLabel;
reasonWrap.style.display = withReason ? 'block' : 'none';
reasonInput.value = '';
reasonInput.classList.remove('is-invalid');
modal.style.display = 'flex';
if (withReason) reasonInput.focus();
};
btnConfirm.addEventListener('click', async () => {
if (!pendingAction) return;
let reason = null;
if (needsReason) {
reason = reasonInput.value.trim();
if (!reason) { reasonInput.classList.add('is-invalid'); reasonInput.focus(); return; }
}
btnConfirm.disabled = true;
btnConfirm.innerText = 'Processing…';
try {
await pendingAction(reason);
close();
} catch (e) {
flash('Error: ' + e.message, 'error');
} finally {
btnConfirm.disabled = false;
btnConfirm.innerText = btnConfirm.dataset.label || 'Confirm';
}
});
btnCancel.addEventListener('click', close);
modal.addEventListener('click', e => { if (e.target === modal) close(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && modal.style.display !== 'none') close(); });
const post = async (url, body) => {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-Token': csrf() },
body: JSON.stringify(body || {})
});
let data = {};
try { data = await res.json(); } catch {}
if (!res.ok || !data.success) throw new Error(data.msg || ('Request failed (' + res.status + ')'));
return data;
};
const removeCard = (card) => {
if (!card) return;
card.style.opacity = '0';
setTimeout(() => {
card.remove();
const grid = document.querySelector('.mq-grid');
if (grid && !grid.querySelector('.mq-card')) location.reload();
}, 300);
};
// Rating toggle (same on both pages)
document.querySelectorAll('.btn-rating-toggle-async').forEach(btn => {
btn.addEventListener('click', async e => {
e.preventDefault();
const tagsContainer = btn.closest('.mq-card')?.querySelector('.mq-tags');
btn.disabled = true;
try {
const res = await fetch('/api/v2/tags/' + btn.dataset.id + '/toggle', {
method: 'PUT',
headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json', 'X-CSRF-Token': csrf() }
});
const data = await res.json();
if (!data.success || !data.tags) throw new Error(data.msg || 'Unknown error');
if (tagsContainer) {
tagsContainer.innerHTML = '';
data.tags.forEach(tag => {
let cls = 'badge-light';
if (tag.normalized === 'sfw') cls = 'badge-success';
else if (tag.normalized === 'nsfw') cls = 'badge-danger';
else if (tag.tag.startsWith('>')) cls = 'badge-greentext badge-light';
const span = document.createElement('span');
span.className = 'badge ' + cls;
span.innerText = tag.tag;
tagsContainer.appendChild(span);
});
}
} catch (err) {
flash('Error: ' + err.message, 'error');
} finally {
btn.disabled = false;
}
});
});
return { confirm, post, flash, removeCard };
})();
</script>
+392 -128
View File
@@ -69,7 +69,7 @@
@endif @endif
@endif @endif
@if(enable_anonymous_access && session.is_anon) @if(enable_anonymous_access && session.is_anon)
<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> <a href="#" id="nav-user-anon-identity-btn" style="white-space: nowrap;" onclick="event.preventDefault(); if(window.f0ckAnonPasskey) { window.f0ckAnonPasskey.openModal(); } else { const m = document.getElementById('anon-passkey-modal'); if (m) m.style.display='flex'; }">Passkey Identity</a>
@endif @endif
<a href="/user/{{ (session.is_anon && session.login ? session.login : session.user).toLowerCase() }}/favs" class="mobile-only">{{ t('nav.favs') }}</a> <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> <a href="/settings" class="mobile-only">{{ t('nav.settings') }}</a>
@@ -385,7 +385,7 @@ async function loginWithPasskey() {
errEl.textContent = (e && e.message) || 'Passkey login failed.'; errEl.textContent = (e && e.message) || 'Passkey login failed.';
} }
} }
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Login with passkey'; } if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fa-solid fa-fingerprint"></i> Use my passkey'; }
} }
} }
</script> </script>
@@ -396,76 +396,319 @@ async function loginWithPasskey() {
<!-- Login View --> <!-- Login View -->
<div id="modal-login-view"> <div id="modal-login-view">
<h2 style="text-align: center; margin-bottom: 18px;">{{ t('auth.login_title') }}</h2> <div class="lm-header">
<h2 class="lm-title">{{ t('auth.login_title') }}</h2>
<div class="lm-subtitle">{{ domain }}</div>
</div>
<!-- Tab bar --> <!-- Tab bar -->
<div id="login-tabs" style="display: flex; border-bottom: 1px solid rgba(255,255,255,0.1); margin-bottom: 20px;"> <div id="login-tabs" class="lm-tabs" role="tablist">
<button type="button" id="login-tab-creds" onclick="switchLoginTab('creds')" <button type="button" id="login-tab-creds" class="lm-tab is-active" role="tab" aria-selected="true" 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"></i>Credentials
<i class="fa-solid fa-key" style="margin-right:5px;"></i>Credentials
</button> </button>
<button type="button" id="login-tab-passkey" onclick="switchLoginTab('passkey')" <button type="button" id="login-tab-passkey" class="lm-tab" role="tab" aria-selected="false" 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"></i>Passkey
<i class="fa-solid fa-fingerprint" style="margin-right:5px;"></i>Passkey
</button> </button>
</div> </div>
<!-- Tab: Credentials --> <!-- Tab: Credentials -->
<div id="login-panel-creds"> <div id="login-panel-creds" class="lm-panel">
<form class="login-form" method="post" action="/login" novalidate style="padding: 0; background: none; box-shadow: none; border: none;"> <form class="login-form lm-form" method="post" action="/login" novalidate>
<input type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" autocomplete="off" required /> <label class="lm-field">
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder_min') }}" autocomplete="off" required /> <i class="fa-solid fa-user lm-field-icon"></i>
<p style="text-align: left; font-size: 0.9em; margin: 0;"><input type="checkbox" id="kmsi-modal" name="kmsi" /> <input class="lm-input" type="text" name="username" placeholder="{{ t('auth.username_or_email') }}" aria-label="{{ t('auth.username_or_email') }}" autocomplete="username" autocapitalize="off" spellcheck="false" required />
<label for="kmsi-modal">{{ t('auth.stay_signed_in') }}</label> </label>
</p> <label class="lm-field">
<button type="submit">{{ t('auth.login_title') }}</button> <i class="fa-solid fa-lock lm-field-icon"></i>
@if(smtp_enabled) <input class="lm-input" type="password" name="password" id="modal-login-password" placeholder="{{ t('auth.password_placeholder_min') }}" aria-label="{{ t('auth.password_placeholder_min') }}" autocomplete="current-password" required />
<div style="text-align: center; margin-top: 10px;"> <button type="button" class="lm-reveal" onclick="toggleLoginPassword(this)" aria-label="Show password" title="Show password">
<a href="#" id="modal-forgot-btn" style="font-size: 0.85em; color: var(--accent); text-decoration: underline;">{{ t('auth.forgot_password') }}</a> <i class="fa-solid fa-eye"></i>
</button>
</label>
<div class="lm-row">
<label class="lm-check" for="kmsi-modal">
<input type="checkbox" id="kmsi-modal" name="kmsi" />
<span class="lm-check-box"><i class="fa-solid fa-check"></i></span>
<span>{{ t('auth.stay_signed_in') }}</span>
</label>
@if(smtp_enabled)
<a href="#" id="modal-forgot-btn" class="lm-link lm-link-muted">{{ t('auth.forgot_password') }}</a>
@endif
</div> </div>
@endif
@if(registration_open || private_society) <button type="submit" class="lm-btn lm-btn-primary">
<p style="text-align: center; font-size: 0.9em; margin-top: 15px; color: #888;"> {{ t('auth.login_title') }} <i class="fa-solid fa-arrow-right"></i>
{{ t('auth.no_account') }} <a href="#" id="login-to-register" style="color: var(--accent); text-decoration: underline;">{{ t('auth.register_now') }}</a> </button>
</p>
@endif
</form> </form>
</div> </div>
<!-- Tab: Passkey --> <!-- Tab: Passkey -->
<div id="login-panel-passkey" style="display: none; text-align: center; padding: 8px 0 4px;"> <div id="login-panel-passkey" class="lm-panel lm-panel-passkey" style="display: none;">
<div style="font-size: 2.6em; margin-bottom: 14px;">🔑</div> <div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
<p style="font-size: 0.88em; color: var(--text-muted, #aaa); margin: 0 0 20px; line-height: 1.55;"> <p class="lm-passkey-text">
Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password. Use a saved passkey from Bitwarden, iCloud Keychain, or your OS to sign in without a password.
</p> </p>
<button type="button" id="modal-login-passkey-btn" <button type="button" id="modal-login-passkey-btn" class="lm-btn lm-btn-primary" onclick="loginWithPasskey()">
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 <i class="fa-solid fa-fingerprint"></i> Use my passkey
</button> </button>
<div id="modal-passkey-error" style="display:none; color:#e06c75; font-size:0.82em; margin-top:6px;"></div> <div id="modal-passkey-error" class="lm-error" style="display:none;"></div>
</div> </div>
@if(enable_anonymous_access) @if(enable_anonymous_access)
<div style="margin-top: 16px; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 12px; text-align: center;"> <div class="lm-anon">
<button type="button" id="modal-login-as-anon-btn" <div class="lm-divider"><span>or</span></div>
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;" <button type="button" id="modal-login-as-anon-btn" class="lm-btn lm-btn-ghost"
onclick="event.preventDefault(); if(window.f0ckAnonPasskey) window.f0ckAnonPasskey.openSetupModal(); else { const m=document.getElementById('anon-setup-modal'); if(m) m.style.display='flex'; }"> 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 <i class="fa-solid fa-user-secret"></i> Login as anonymous
</button> </button>
</div> </div>
@endif @endif
@if(registration_open || private_society)
<p class="lm-footer">
{{ t('auth.no_account') }} <a href="#" id="login-to-register" class="lm-link">{{ t('auth.register_now') }}</a>
</p>
@endif
</div> </div>
<style>
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) {
--lm-accent: var(--accent, #0096ff);
--lm-text: var(--text-color, #fff);
--lm-muted: var(--text-muted, #8a8f98);
--lm-surface: rgba(255, 255, 255, 0.04);
--lm-border: rgba(255, 255, 255, 0.1);
--lm-radius: 0;
padding: 28px 28px 24px;
font-family: inherit;
color: var(--lm-text);
}
#login-modal .login-modal-content,
#register-modal .login-modal-content { border-radius: 0; }
/* Header */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-header { text-align: center; margin-bottom: 22px; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-title {
margin: 0; font-size: 1.5em; font-weight: 700; letter-spacing: -0.01em; color: var(--lm-text);
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-subtitle {
margin-top: 4px; font-size: 0.82em; color: var(--lm-muted); letter-spacing: 0.02em;
}
/* Segmented tabs */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tabs {
display: flex; gap: 4px; padding: 4px; margin-bottom: 20px;
background: rgba(0, 0, 0, 0.35);
border: 1px solid var(--lm-border);
border-radius: var(--lm-radius);
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab {
flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 8px 0; margin: 0;
background: transparent; border: 0; border-radius: var(--lm-radius);
color: var(--lm-muted); font: inherit; font-size: 0.88em; font-weight: 600;
cursor: pointer; transition: background 0.18s, color 0.18s, box-shadow 0.18s;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab:hover { color: var(--lm-text); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab.is-active {
color: var(--lm-text);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06) inset, 0 2px 8px rgba(0, 0, 0, 0.35);
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-tab.is-active i { color: var(--lm-accent); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-panel { animation: lm-fade 0.2s ease; }
@keyframes lm-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
/* Form */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) form.lm-form {
display: flex; flex-direction: column; gap: 12px;
padding: 0; margin: 0; background: none; border: 0; box-shadow: none; width: 100%;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-field { position: relative; display: block; margin: 0; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-field-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
color: var(--lm-muted); font-size: 0.9em; pointer-events: none; transition: color 0.15s;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input {
width: 100%; box-sizing: border-box; height: 46px; margin: 0;
padding: 0 42px 0 40px;
background: var(--lm-surface);
border: 1px solid var(--lm-border);
border-radius: var(--lm-radius);
color: var(--lm-text); font: inherit; font-size: 0.95em;
outline: none; box-shadow: none;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input::placeholder { color: var(--lm-muted); opacity: 0.8; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input:hover { border-color: rgba(255, 255, 255, 0.18); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) input.lm-input:focus {
border-color: var(--lm-accent);
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--lm-accent) 22%, transparent);
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-field:focus-within .lm-field-icon { color: var(--lm-accent); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-reveal {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; margin: 0; padding: 0;
display: flex; align-items: center; justify-content: center;
background: transparent; border: 0; border-radius: var(--lm-radius);
color: var(--lm-muted); cursor: pointer; transition: color 0.15s, background 0.15s;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-reveal:hover { color: var(--lm-text); background: rgba(255, 255, 255, 0.06); }
/* Remember me + forgot */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
font-size: 0.85em; margin: 2px 0 4px;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check {
display: inline-flex; align-items: center; gap: 9px; cursor: pointer; color: var(--lm-muted); user-select: none;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check-box {
width: 18px; height: 18px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.25); border-radius: var(--lm-radius);
background: var(--lm-surface); font-size: 0.7em; color: transparent;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check:hover .lm-check-box { border-color: var(--lm-accent); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input:checked + .lm-check-box { background: var(--lm-accent); border-color: var(--lm-accent); color: #000; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input:focus-visible + .lm-check-box { box-shadow: 0 0 0 3px color-mix(in srgb, var(--lm-accent) 30%, transparent); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-check input:checked ~ span:last-child { color: var(--lm-text); }
/* Links */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link {
color: var(--lm-accent); text-decoration: none; font-weight: 600; transition: opacity 0.15s;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link:hover { text-decoration: underline; text-underline-offset: 3px; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link-muted { color: var(--lm-muted); font-weight: 500; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-link-muted:hover { color: var(--lm-text); }
/* Buttons */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn {
width: 100%; height: 46px; margin: 0; padding: 0 16px;
display: inline-flex; align-items: center; justify-content: center; gap: 9px;
border-radius: var(--lm-radius); font: inherit; font-size: 0.95em; font-weight: 700;
cursor: pointer; transition: transform 0.12s, box-shadow 0.18s, background 0.18s, border-color 0.18s, filter 0.18s;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary {
margin-top: 6px;
border: 1px solid var(--lm-accent); color: #000;
background: var(--lm-accent);
box-shadow: none;
text-transform: uppercase; letter-spacing: 0.08em; font-weight: 800;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary:hover { background: transparent; color: var(--lm-accent); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary:active { transform: translateY(1px); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary .fa-arrow-right { font-size: 0.85em; transition: transform 0.18s; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-primary:hover .fa-arrow-right { transform: translateX(3px); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn:disabled { opacity: 0.6; cursor: progress; filter: none; transform: none; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn:focus-visible { outline: 2px solid var(--lm-accent); outline-offset: 2px; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-ghost {
background: var(--lm-surface); border: 1px solid var(--lm-border); color: var(--lm-muted); font-weight: 600;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-btn-ghost:hover { color: var(--lm-text); border-color: rgba(255, 255, 255, 0.22); background: rgba(255, 255, 255, 0.07); }
/* Passkey panel */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-panel-passkey { text-align: center; padding-top: 4px; }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-passkey-icon {
width: 76px; height: 76px; margin: 0 auto 16px;
display: flex; align-items: center; justify-content: center;
border-radius: var(--lm-radius); font-size: 2.2em; color: var(--lm-accent);
background: radial-gradient(circle, color-mix(in srgb, var(--lm-accent) 20%, transparent) 0%, transparent 70%);
border: 1px solid color-mix(in srgb, var(--lm-accent) 30%, transparent);
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-passkey-text {
margin: 0 auto 20px; max-width: 320px; font-size: 0.88em; line-height: 1.6; color: var(--lm-muted);
}
/* Errors (also covers the .flash-error the login JS injects) */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-error,
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .flash-error {
margin: 10px 0 0; padding: 10px 12px; text-align: left;
font-size: 0.84em; line-height: 1.45; color: #ff8a8a;
background: rgba(224, 108, 117, 0.1);
border: 1px solid rgba(224, 108, 117, 0.3);
border-radius: var(--lm-radius);
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) form .flash-error { margin: 0; }
#register-modal #modal-register-view #register-status:empty { display: none; }
#register-modal #modal-register-view #register-status.flash-success {
padding: 10px 12px; font-size: 0.84em; line-height: 1.45; color: #7ee2a0;
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.3);
border-radius: var(--lm-radius);
}
/* Anon setup */
#anon-setup-modal #anon-setup-view .lm-note {
margin: 0 0 20px; padding: 9px 12px; text-align: left;
font-size: 0.8em; line-height: 1.5; color: var(--lm-muted);
background: var(--lm-surface); border: 1px solid var(--lm-border); border-left: 2px solid var(--lm-accent);
}
#anon-setup-modal #anon-setup-view .lm-note i { color: var(--lm-accent); margin-right: 4px; }
#anon-setup-modal #anon-setup-view .lm-textbtn {
margin: 0; padding: 0; background: none; border: 0;
font: inherit; font-weight: 600; color: var(--lm-accent); cursor: pointer;
}
#anon-setup-modal #anon-setup-view .lm-textbtn:hover { text-decoration: underline; text-underline-offset: 3px; }
/* Anon passkey identity */
#anon-passkey-modal #anon-passkey-view .lm-stat {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
margin: 0 0 16px; padding: 12px 14px;
background: var(--lm-surface); border: 1px solid var(--lm-border);
}
#anon-passkey-modal #anon-passkey-view .lm-stat-label {
font-size: 0.72em; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--lm-muted);
}
#anon-passkey-modal #anon-passkey-view .lm-stat code { color: var(--lm-accent); font-size: 0.95em; }
/* Register-only */
#register-modal #modal-register-view .lm-check-multiline { align-items: flex-start; line-height: 1.5; }
#register-modal #modal-register-view .lm-check-multiline .lm-check-box { margin-top: 1px; }
#register-modal #modal-register-view .lm-recaptcha { display: flex; justify-content: center; }
/* Divider + footer */
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-divider {
display: flex; align-items: center; gap: 12px; margin: 20px 0 14px;
color: var(--lm-muted); font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.12em;
}
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-divider::before,
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-divider::after { content: ''; flex: 1; height: 1px; background: var(--lm-border); }
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) .lm-footer {
margin: 20px 0 0; padding-top: 16px; border-top: 1px solid var(--lm-border);
text-align: center; font-size: 0.88em; color: var(--lm-muted);
}
@media (prefers-reduced-motion: reduce) {
:is(#login-modal #modal-login-view, #register-modal #modal-register-view, #anon-setup-modal #anon-setup-view, #anon-passkey-modal #anon-passkey-view) * { animation: none !important; transition: none !important; }
}
</style>
<script> <script>
function switchLoginTab(tab) { function switchLoginTab(tab) {
var isCreds = tab === 'creds'; var isCreds = tab === 'creds';
document.getElementById('login-panel-creds').style.display = isCreds ? '' : 'none'; document.getElementById('login-panel-creds').style.display = isCreds ? '' : 'none';
document.getElementById('login-panel-passkey').style.display = isCreds ? 'none' : ''; document.getElementById('login-panel-passkey').style.display = isCreds ? 'none' : '';
var tCreds = document.getElementById('login-tab-creds'); [['login-tab-creds', isCreds], ['login-tab-passkey', !isCreds]].forEach(function (t) {
var tPk = document.getElementById('login-tab-passkey'); var el = document.getElementById(t[0]);
if (tCreds) { tCreds.style.borderBottomColor = isCreds ? 'var(--accent, #0096ff)' : 'transparent'; tCreds.style.color = isCreds ? 'var(--text-color, #fff)' : 'var(--text-muted, #888)'; } if (!el) return;
if (tPk) { tPk.style.borderBottomColor = isCreds ? 'transparent' : 'var(--accent, #0096ff)'; tPk.style.color = isCreds ? 'var(--text-muted, #888)' : 'var(--text-color, #fff)'; } el.classList.toggle('is-active', t[1]);
el.setAttribute('aria-selected', t[1] ? 'true' : 'false');
});
}
function toggleLoginPassword(btn) {
var input = btn.parentNode.querySelector('input');
if (!input) return;
var show = input.type === 'password';
input.type = show ? 'text' : 'password';
btn.innerHTML = show ? '<i class="fa-solid fa-eye-slash"></i>' : '<i class="fa-solid fa-eye"></i>';
btn.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
btn.title = show ? 'Hide password' : 'Show password';
input.focus();
} }
</script> </script>
@@ -508,40 +751,25 @@ async function loginWithPasskey() {
@if(enable_anonymous_access) @if(enable_anonymous_access)
<!-- Anonymous Passkey Identity Modal --> <!-- 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 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;"> <div style="max-width: 380px; width: 92vw; max-height: 90vh; overflow-y: auto; padding: 0; border: 1px solid var(--accent); border-radius: 0; background: var(--bg); box-shadow: 0 0 20px rgba(0,0,0,0.5); position: relative;">
<button id="anon-passkey-modal-close" style="position: absolute; top: 15px; right: 15px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.4em; cursor: pointer; line-height: 1;">&times;</button> <button id="anon-passkey-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">&times;</button>
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 8px;"> <div id="anon-passkey-view">
<i class="fa-solid fa-fingerprint" style="color: var(--accent, #0096ff); font-size: 1.3em;"></i> <div class="lm-header">
<h3 style="margin: 0; font-size: 1.25em; color: var(--text-color, #fff);">Anonymous Passkey Identity</h3> <h2 class="lm-title">Passkeys</h2>
</div> <div class="lm-subtitle"><span id="anon-pk-count">–</span> / <span id="anon-pk-max">4</span> in use</div>
<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>
<!-- Identity info -->
<div style="margin-bottom: 14px; padding: 12px; background: rgba(0,0,0,0.35); border-radius: 6px; border: 1px solid rgba(255,255,255,0.07);">
<div style="margin-bottom: 8px;">
<label style="display: block; font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted, #888); margin-bottom: 3px;">Short Fingerprint</label>
<code id="anon-pk-fp-display" style="font-family: monospace; font-size: 1em; color: var(--accent, #00d2ff);">—</code>
</div> </div>
<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>
<!-- Actions --> <div class="lm-stat">
<div style="display: flex; flex-wrap: wrap; gap: 8px;"> <span class="lm-stat-label">Fingerprint</span>
<button type="button" id="anon-pk-add-btn" class="btn btn-sm btn-primary" style="font-size: 0.85em; padding: 6px 14px;"> <code id="anon-pk-fp-display">—</code>
<i class="fa-solid fa-plus"></i> Add another passkey </div>
<button type="button" id="anon-pk-add-btn" class="lm-btn lm-btn-primary">
<i class="fa-solid fa-plus"></i> Add passkey
</button> </button>
<div id="anon-pk-error" class="lm-error" style="display: none;"></div>
</div> </div>
<p style="margin: 14px 0 0; font-size: 0.8em; color: var(--text-muted, #888); line-height: 1.4;">
To use this identity on another device, simply sign in with your passkey manager (Bitwarden, iCloud Keychain, etc.) — it syncs automatically.
</p>
</div> </div>
</div> </div>
@@ -578,43 +806,45 @@ async function loginWithPasskey() {
@if(enable_anonymous_access) @if(enable_anonymous_access)
<!-- Anonymous Passkey Setup Modal — shown when user clicks "Login as anonymous" --> <!-- 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 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;"> <div style="max-width: 420px; width: 92vw; padding: 0; border: 1px solid var(--accent); border-radius: 0; background: var(--bg); box-shadow: 0 0 20px rgba(0,0,0,0.5); position: relative;">
<button id="anon-setup-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">&times;</button> <button id="anon-setup-modal-close" style="position: absolute; top: 14px; right: 14px; background: none; border: none; color: var(--text-muted, #aaa); font-size: 1.3em; cursor: pointer; line-height: 1;">&times;</button>
<!-- New user view (no passkey yet) --> <div id="anon-setup-view">
<div id="anon-setup-new"> <!-- New user view (no passkey yet) -->
<div style="font-size: 2.4em; margin-bottom: 12px;">🔑</div> <div id="anon-setup-new" class="lm-panel lm-panel-passkey">
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Create an anonymous passkey</h3> <div class="lm-passkey-icon"><i class="fa-solid fa-fingerprint"></i></div>
<p style="margin: 0 0 18px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;"> <h2 class="lm-title">Create an anonymous passkey</h2>
Your browser will ask you to save a <strong>passkey</strong> — a secure credential stored in Bitwarden, iCloud Keychain, or your OS. <p class="lm-passkey-text" style="margin-top: 12px;">
No account, no email, no password. Your comments and favorites are tied to this passkey. Your browser will ask you to save a <strong>passkey</strong> — a secure credential stored in Bitwarden, iCloud Keychain, or your OS.
</p> No account, no email, no password. Your comments and favorites are tied to this passkey.
<p style="margin: 0 0 20px; font-size: 0.82em; color: var(--text-muted, #888); line-height: 1.4;"> </p>
You can use it across devices if your passkey manager syncs (e.g. Bitwarden). <p class="lm-note">
</p> <i class="fa-solid fa-rotate"></i> You can use it across devices if your passkey manager syncs (e.g. Bitwarden).
<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;"> </p>
<i class="fa-solid fa-fingerprint"></i> Create my passkey <button type="button" id="anon-setup-create-btn" class="lm-btn lm-btn-primary">
</button> <i class="fa-solid fa-fingerprint"></i> Create my passkey
<div id="anon-setup-new-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div> </button>
<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;"> <div id="anon-setup-new-error" class="lm-error" style="display: none;"></div>
Already have one? Use existing passkey <p class="lm-footer">
</button> <button type="button" id="anon-setup-switch-existing" class="lm-textbtn">Already have one? Use existing passkey</button>
</div> </p>
</div>
<!-- Returning user view (has passkey) --> <!-- Returning user view (has passkey) -->
<div id="anon-setup-returning" style="display: none;"> <div id="anon-setup-returning" class="lm-panel lm-panel-passkey" style="display: none;">
<div style="font-size: 2.4em; margin-bottom: 12px;">👤</div> <div class="lm-passkey-icon"><i class="fa-solid fa-user-secret"></i></div>
<h3 style="margin: 0 0 10px; font-size: 1.15em; color: var(--text-color, #fff);">Use your anonymous passkey</h3> <h2 class="lm-title">Use your anonymous passkey</h2>
<p style="margin: 0 0 20px; font-size: 0.88em; color: var(--text-muted, #aaa); line-height: 1.55;"> <p class="lm-passkey-text" style="margin-top: 12px;">
Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user. Pick your saved passkey from Bitwarden, iCloud Keychain, or your OS to continue as the same anonymous user.
</p> </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;"> <button type="button" id="anon-setup-auth-btn" class="lm-btn lm-btn-primary">
<i class="fa-solid fa-fingerprint"></i> Use my passkey <i class="fa-solid fa-fingerprint"></i> Use my passkey
</button> </button>
<div id="anon-setup-ret-error" style="display: none; color: #e06c75; font-size: 0.83em; margin-top: 6px;"></div> <div id="anon-setup-ret-error" class="lm-error" style="display: none;"></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;"> <p class="lm-footer">
Create a new passkey instead <button type="button" id="anon-setup-switch-new" class="lm-textbtn">Create a new passkey instead</button>
</button> </p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -733,31 +963,65 @@ async function loginWithPasskey() {
<div id="register-modal" style="display: none;"> <div id="register-modal" style="display: none;">
<div class="login-modal-content"> <div class="login-modal-content">
<button id="register-modal-close">&times;</button> <button id="register-modal-close">&times;</button>
<form class="login-form" id="modal-register-form" method="post" action="/register" novalidate> <div id="modal-register-view">
<h2 style="text-align: center; margin-bottom: 20px;">{{ t('auth.register_title') }}</h2> <div class="lm-header">
<div id="register-status" style="margin-bottom: 15px; text-align: center; font-size: 0.9em;"></div> <h2 class="lm-title">{{ t('auth.register_title') }}</h2>
<input type="text" name="username" placeholder="{{ t('auth.username_placeholder') }}" autocomplete="off" required /> <div class="lm-subtitle">{{ domain }}</div>
<input type="password" name="password" placeholder="{{ t('auth.password_placeholder') }}" autocomplete="off" required minlength="20"
title="Must be at least 20 characters long." />
<input type="password" name="password_confirm" placeholder="{{ t('auth.confirm_password') }}" autocomplete="off" required minlength="20" />
@if(!registration_open)
<input type="text" name="token" placeholder="{{ t('auth.invite_token') }}" autocomplete="off" required />
@elseif(registration_require_mail_andor_token)
<input type="email" name="email" placeholder="{{ t('auth.email_placeholder') }}" autocomplete="off" required />
@endif
<input type="text" name="email_confirm_field" style="display: none !important;" tabindex="-1" autocomplete="off" />
<p style="text-align: left; font-size: 0.9em; margin: 0; color: #fff;">
<input type="checkbox" id="tos-modal" name="tos" required />
<label for="tos-modal">@if(private_society){{ t('auth.tos_private') }}@else{{ t('auth.tos_public') }} <a href="/terms" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_terms') }}</a>, <a href="/rules" target="_blank" style="color: var(--accent); text-decoration: underline;">{{ t('auth.tos_rules') }}</a> {{ t('auth.tos_age') }}@endif</label>
</p>
@if(recaptcha_enabled)
<div id="modal-register-recaptcha" style="margin: 10px 0;"></div>
@endif
<button type="submit">{{ t('auth.create_account') }}</button>
<div style="text-align: center; margin-top: 20px;">
<a href="#" id="register-to-login" style="font-size: 0.9em; color: var(--accent); text-decoration: underline;">{{ t('auth.back_to_login') }}</a>
</div> </div>
</form>
<form class="login-form lm-form" id="modal-register-form" method="post" action="/register" novalidate>
<div id="register-status"></div>
<label class="lm-field">
<i class="fa-solid fa-user lm-field-icon"></i>
<input class="lm-input" type="text" name="username" placeholder="{{ t('auth.username_placeholder') }}" aria-label="{{ t('auth.username_placeholder') }}" autocomplete="username" autocapitalize="off" spellcheck="false" required />
</label>
<label class="lm-field">
<i class="fa-solid fa-lock lm-field-icon"></i>
<input class="lm-input" type="password" name="password" placeholder="{{ t('auth.password_placeholder') }}" aria-label="{{ t('auth.password_placeholder') }}" autocomplete="new-password" required minlength="20"
title="Must be at least 20 characters long." />
<button type="button" class="lm-reveal" onclick="toggleLoginPassword(this)" aria-label="Show password" title="Show password">
<i class="fa-solid fa-eye"></i>
</button>
</label>
<label class="lm-field">
<i class="fa-solid fa-lock lm-field-icon"></i>
<input class="lm-input" type="password" name="password_confirm" placeholder="{{ t('auth.confirm_password') }}" aria-label="{{ t('auth.confirm_password') }}" autocomplete="new-password" required minlength="20" />
<button type="button" class="lm-reveal" onclick="toggleLoginPassword(this)" aria-label="Show password" title="Show password">
<i class="fa-solid fa-eye"></i>
</button>
</label>
@if(!registration_open)
<label class="lm-field">
<i class="fa-solid fa-ticket lm-field-icon"></i>
<input class="lm-input" type="text" name="token" placeholder="{{ t('auth.invite_token') }}" aria-label="{{ t('auth.invite_token') }}" autocomplete="off" spellcheck="false" required />
</label>
@elseif(registration_require_mail_andor_token)
<label class="lm-field">
<i class="fa-solid fa-envelope lm-field-icon"></i>
<input class="lm-input" type="email" name="email" placeholder="{{ t('auth.email_placeholder') }}" aria-label="{{ t('auth.email_placeholder') }}" autocomplete="email" required />
</label>
@endif
<input type="text" name="email_confirm_field" style="display: none !important;" tabindex="-1" autocomplete="off" />
<div class="lm-row">
<label class="lm-check lm-check-multiline" for="tos-modal">
<input type="checkbox" id="tos-modal" name="tos" required />
<span class="lm-check-box"><i class="fa-solid fa-check"></i></span>
<span>@if(private_society){{ t('auth.tos_private') }}@else{{ t('auth.tos_public') }} <a href="/terms" target="_blank" class="lm-link">{{ t('auth.tos_terms') }}</a>, <a href="/rules" target="_blank" class="lm-link">{{ t('auth.tos_rules') }}</a> {{ t('auth.tos_age') }}@endif</span>
</label>
</div>
@if(recaptcha_enabled)
<div id="modal-register-recaptcha" class="lm-recaptcha"></div>
@endif
<button type="submit" class="lm-btn lm-btn-primary">{{ t('auth.create_account') }}</button>
</form>
<p class="lm-footer">
<a href="#" id="register-to-login" class="lm-link">{{ t('auth.back_to_login') }}</a>
</p>
</div>
</div> </div>
</div> </div>
+143
View File
@@ -0,0 +1,143 @@
{{-- Item visibility modal (shared by item-partial-modern / item-partial-legacy). JS hooks: #visibility-modal, #visibility-item-id, input[name=visibility], #visibility-modal-cancel/-save --}}
<div id="visibility-modal" class="modal-overlay" style="display: none;">
<div class="modal-content vis-modal-content">
<div class="vis-header">
<h3><i class="fa-solid fa-eye"></i> Change visibility</h3>
<p>Choose who can see this post.</p>
</div>
<div class="vis-body">
<form id="visibility-form" onsubmit="return false;">
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
<div class="visibility-options">
<label class="visibility-option">
<input type="radio" name="visibility" value="0" />
<span class="vis-radio"></span>
<span class="vis-text">
<strong><i class="fa-solid fa-globe"></i> Public</strong>
<span>Visible to everyone in main feeds, search, and rankings.</span>
</span>
</label>
<label class="visibility-option">
<input type="radio" name="visibility" value="1" />
<span class="vis-radio"></span>
<span class="vis-text">
<strong><i class="fa-solid fa-link"></i> Unlisted</strong>
<span>Hidden from main feeds; accessible only via direct link or slug.</span>
</span>
</label>
<label class="visibility-option">
<input type="radio" name="visibility" value="2" />
<span class="vis-radio"></span>
<span class="vis-text">
<strong><i class="fa-solid fa-lock"></i> Private</strong>
<span>Only visible to you and site moderators.</span>
</span>
</label>
@if(is_mod_or_admin)
<label class="visibility-option vis-danger">
<input type="radio" name="visibility" value="3" />
<span class="vis-radio"></span>
<span class="vis-text">
<strong><i class="fa-solid fa-ban"></i> Unavailable (451)</strong>
<span>Only visible to administrators. Serves HTTP 451 on direct media for all other users.</span>
</span>
</label>
@endif
</div>
</form>
</div>
<div class="modal-actions vis-footer">
<button type="button" class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
<button type="button" class="btn-primary" id="visibility-modal-save">Save visibility</button>
</div>
</div>
</div>
<style>
/* Visibility modal — same rules as the report/info modals: square, flat, fixed header/footer, full screen on phones */
#visibility-modal .vis-modal-content {
--vm-accent: var(--accent, #0096ff);
--vm-text: var(--text-color, #fff);
--vm-muted: var(--text-muted, #8a8f98);
--vm-surface: rgba(255, 255, 255, 0.04);
--vm-border: rgba(255, 255, 255, 0.1);
--vm-danger: #ff5c5c;
width: min(440px, 92vw);
max-height: min(90vh, 90dvh);
display: flex; flex-direction: column; overflow: hidden;
padding: 0; text-align: left;
border-radius: 0 !important;
border: 1px solid var(--accent) !important;
background: var(--bg) !important;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.5) !important;
color: var(--vm-text);
}
#visibility-modal .vis-modal-content * { border-radius: 0 !important; }
#visibility-modal .vis-header { flex-shrink: 0; padding: 20px 24px 14px; border-bottom: 1px solid var(--vm-border); }
#visibility-modal .vis-header h3 { display: flex; align-items: center; gap: 10px; margin: 0; font-size: 1.2em; font-weight: 700; color: var(--vm-text); }
#visibility-modal .vis-header h3 i { color: var(--vm-accent); font-size: 0.9em; }
#visibility-modal .vis-header p { margin: 6px 0 0; font-size: 0.85em; color: var(--vm-muted); }
#visibility-modal .vis-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 16px 24px; }
#visibility-modal .visibility-options { display: flex; flex-direction: column; gap: 6px; }
#visibility-modal .visibility-option {
display: flex; align-items: flex-start; gap: 12px; margin: 0; padding: 12px 14px;
background: var(--vm-surface); border: 1px solid var(--vm-border);
cursor: pointer; user-select: none; transition: border-color 0.15s, background 0.15s;
}
#visibility-modal .visibility-option:hover { border-color: rgba(255, 255, 255, 0.22); }
#visibility-modal .visibility-option:has(input:checked) { border-color: var(--vm-accent); background: color-mix(in srgb, var(--vm-accent) 8%, transparent); }
#visibility-modal .visibility-option input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
#visibility-modal .vis-radio {
position: relative; width: 18px; height: 18px; flex-shrink: 0; margin-top: 1px;
border: 1px solid rgba(255, 255, 255, 0.28); background: var(--vm-surface); transition: border-color 0.15s;
}
#visibility-modal .vis-radio::after {
content: ''; position: absolute; inset: 4px; background: var(--vm-accent); transform: scale(0); transition: transform 0.15s;
}
#visibility-modal .visibility-option input:checked + .vis-radio { border-color: var(--vm-accent); }
#visibility-modal .visibility-option input:checked + .vis-radio::after { transform: scale(1); }
#visibility-modal .visibility-option input:focus-visible + .vis-radio { outline: 2px solid var(--vm-accent); outline-offset: 2px; }
#visibility-modal .vis-text { display: flex; flex-direction: column; gap: 3px; font-size: 0.88em; line-height: 1.4; }
#visibility-modal .vis-text strong { display: flex; align-items: center; gap: 7px; color: var(--vm-text); font-weight: 700; }
#visibility-modal .vis-text strong i { width: 14px; text-align: center; color: var(--vm-muted); }
#visibility-modal .visibility-option:has(input:checked) .vis-text strong i { color: var(--vm-accent); }
#visibility-modal .vis-text > span { color: var(--vm-muted); }
#visibility-modal .vis-danger .vis-text strong,
#visibility-modal .vis-danger .vis-text strong i { color: var(--vm-danger); }
#visibility-modal .vis-danger:has(input:checked) { border-color: var(--vm-danger); background: rgba(255, 92, 92, 0.08); }
#visibility-modal .vis-danger input:checked + .vis-radio { border-color: var(--vm-danger); }
#visibility-modal .vis-danger .vis-radio::after { background: var(--vm-danger); }
#visibility-modal .vis-footer {
flex-shrink: 0; display: flex; justify-content: flex-end; gap: 8px; margin: 0;
padding: 14px 24px; border-top: 1px solid var(--vm-border); background: rgba(0, 0, 0, 0.2);
}
#visibility-modal .vis-footer button {
height: 40px; margin: 0; padding: 0 18px; font: inherit; font-size: 0.85em; cursor: pointer;
display: inline-flex; align-items: center; justify-content: center;
transition: background 0.18s, color 0.18s, border-color 0.18s;
}
#visibility-modal #visibility-modal-cancel { background: transparent; border: 1px solid var(--vm-border); color: var(--vm-muted); font-weight: 600; }
#visibility-modal #visibility-modal-cancel:hover { color: var(--vm-text); border-color: rgba(255, 255, 255, 0.25); background: var(--vm-surface); }
#visibility-modal #visibility-modal-save {
background: var(--vm-accent); border: 1px solid var(--vm-accent); color: #000;
font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
}
#visibility-modal #visibility-modal-save:hover:not(:disabled) { background: transparent; color: var(--vm-accent); }
#visibility-modal #visibility-modal-save:disabled { opacity: 0.6; cursor: progress; }
#visibility-modal .vis-footer button:focus-visible { outline: 2px solid var(--vm-accent); outline-offset: 2px; }
@media (max-width: 600px) {
#visibility-modal { padding: 0 !important; align-items: stretch !important; }
#visibility-modal .vis-modal-content {
width: 100vw; max-width: none;
height: 100vh; height: 100dvh; max-height: none;
border: 0 !important; box-shadow: none !important;
}
#visibility-modal .vis-footer { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }
#visibility-modal .vis-footer button { flex: 1; }
}
</style>