From feb408338a5f932475c8b76a36f50b3254d4fa9b Mon Sep 17 00:00:00 2001 From: Kibi Kelburton Date: Fri, 7 Aug 2026 21:42:12 +0200 Subject: [PATCH] 69 --- config_example.json | 2 + migrations/add_private_unlisted_uploads.sql | 38 ++++ migrations/f0ckm_schema.sql | 22 +- migrations/fix_null_visibility.sql | 2 + public/s/css/upload.css | 47 ++++ public/s/js/admin.js | 16 +- public/s/js/comments.js | 58 ++++- public/s/js/f0ckm.js | 235 ++++++++++++++------ public/s/js/settings.js | 29 +++ public/s/js/sidebar-activity.js | 9 +- public/s/js/upload.js | 61 +++-- public/s/js/user.js | 16 +- public/s/js/user_comments.js | 5 +- src/inc/lib.mjs | 10 + src/inc/locales/de.json | 10 +- src/inc/locales/en.json | 10 +- src/inc/routeinc/f0cklib.mjs | 125 +++++++---- src/inc/routes/ajax.mjs | 9 +- src/inc/routes/apiv2/index.mjs | 67 +++++- src/inc/routes/apiv2/settings.mjs | 21 ++ src/inc/routes/apiv2/upload.mjs | 28 ++- src/inc/routes/comments.mjs | 5 +- src/inc/routes/index.mjs | 10 +- src/inc/routes/notifications.mjs | 9 +- src/inc/routes/ranking.mjs | 6 +- src/inc/routes/subscriptions.mjs | 6 +- src/inc/routes/user_halls.mjs | 4 +- src/index.mjs | 3 +- src/upload_handler.mjs | 38 +++- views/index-partial.html | 2 +- views/item-partial-legacy.html | 86 ++++++- views/item-partial-modern.html | 80 ++++++- views/ranking.html | 4 +- views/settings.html | 11 + views/snippets/header.html | 12 +- views/snippets/items-grid.html | 2 +- views/snippets/notifications-list.html | 30 +-- views/snippets/subscriptions-grid.html | 4 +- views/snippets/upload-form.html | 20 ++ views/user-partial.html | 4 +- 40 files changed, 927 insertions(+), 229 deletions(-) create mode 100644 migrations/add_private_unlisted_uploads.sql create mode 100644 migrations/fix_null_visibility.sql diff --git a/config_example.json b/config_example.json index 25acd84..8552c01 100644 --- a/config_example.json +++ b/config_example.json @@ -29,6 +29,8 @@ ], "enable_pdf": false, "enable_nsfl": false, + "enable_private_uploads": true, + "enable_item_slugs": true, "nsfl_tag_id": 4, "allowedMimes": [ "audio", diff --git a/migrations/add_private_unlisted_uploads.sql b/migrations/add_private_unlisted_uploads.sql new file mode 100644 index 0000000..b2bef00 --- /dev/null +++ b/migrations/add_private_unlisted_uploads.sql @@ -0,0 +1,38 @@ +-- Add visibility and slug columns to items table +ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0; +ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE; +ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug); + +COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private'; +COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access'; +COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)'; + +-- Populate missing slugs for existing items +DO $$ +DECLARE + r RECORD; + chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; + new_slug text; + i integer; +BEGIN + FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP + LOOP + new_slug := ''; + FOR i IN 1..11 LOOP + new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1); + END LOOP; + BEGIN + UPDATE items SET slug = new_slug WHERE id = r.id; + EXIT; + EXCEPTION WHEN unique_violation THEN + END; + END LOOP; + END LOOP; +END $$; + +-- Fix any items with NULL visibility (should default to 0 = public) +UPDATE items SET visibility = 0 WHERE visibility IS NULL; +ALTER TABLE items ALTER COLUMN visibility SET NOT NULL; +ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0; diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index f33256b..1d15f6f 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -114,6 +114,7 @@ DROP INDEX IF EXISTS public.idx_items_username; DROP INDEX IF EXISTS public.idx_items_pinned_id; DROP INDEX IF EXISTS public.idx_items_is_purged; DROP INDEX IF EXISTS public.idx_items_is_deleted; +DROP INDEX IF EXISTS public.idx_items_slug; DROP INDEX IF EXISTS public.idx_items_active_id; DROP INDEX IF EXISTS public.idx_global_chat_created_at; DROP INDEX IF EXISTS public.idx_discord_queue_sent; @@ -910,12 +911,18 @@ CREATE TABLE public.items ( original_filename text, title text, width integer, - height integer + height integer, + visibility smallint DEFAULT 0 NOT NULL, + slug character varying(16) ); ALTER TABLE public.items OWNER TO f0ckm; +COMMENT ON COLUMN public.items.visibility IS '0=public, 1=unlisted, 2=private'; +COMMENT ON COLUMN public.items.slug IS 'Unique unguessable 11-character URL slug for direct access'; + + -- -- Name: COLUMN items.src; Type: COMMENT; Schema: public; Owner: f0ckm -- @@ -1500,12 +1507,16 @@ CREATE TABLE public.user_options ( comment_display_mode integer DEFAULT 1, force_comment_display_mode integer DEFAULT 0, favorites_private boolean DEFAULT false, - hide_fav_badge boolean DEFAULT false + hide_fav_badge boolean DEFAULT false, + default_upload_visibility smallint DEFAULT 0 ); ALTER TABLE public.user_options OWNER TO f0ckm; +COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)'; + + -- -- Name: COLUMN user_options.avatar_file; Type: COMMENT; Schema: public; Owner: f0ckm -- @@ -2164,6 +2175,13 @@ CREATE INDEX idx_global_chat_created_at ON public.global_chat USING btree (creat CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true); +-- +-- Name: idx_items_slug; Type: INDEX; Schema: public; Owner: f0ckm +-- + +CREATE UNIQUE INDEX idx_items_slug ON public.items USING btree (slug); + + -- -- Name: idx_items_is_deleted; Type: INDEX; Schema: public; Owner: f0ckm -- diff --git a/migrations/fix_null_visibility.sql b/migrations/fix_null_visibility.sql new file mode 100644 index 0000000..85b3807 --- /dev/null +++ b/migrations/fix_null_visibility.sql @@ -0,0 +1,2 @@ +-- Fix items with NULL visibility (should default to 0 = public) +UPDATE items SET visibility = 0 WHERE visibility IS NULL; diff --git a/public/s/css/upload.css b/public/s/css/upload.css index 9d783bf..9580d27 100644 --- a/public/s/css/upload.css +++ b/public/s/css/upload.css @@ -362,6 +362,7 @@ } .upload-form.shitpost-mode-active .global-rating-section, +.upload-form.shitpost-mode-active .global-visibility-section, .upload-form.shitpost-mode-active .global-comment-section, .upload-form.shitpost-mode-active .global-tag-section { display: none !important; @@ -419,6 +420,26 @@ border-color: var(--badge-nsfl); } +/* Visibility Container - Only checked option is colored, unchecked options are gray */ +.item-visibility-container .item-rating-option input:checked + .item-rating-label.sfw { + background: rgba(81, 207, 102, 0.2); + color: #51cf66; + border-color: rgba(81, 207, 102, 0.5); +} + +.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfw { + background: rgba(255, 187, 51, 0.2); + color: #ffbb33; + border-color: rgba(255, 187, 51, 0.5); +} + +.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfl { + background: rgba(255, 68, 68, 0.2); + color: #ff4444; + border-color: rgba(255, 68, 68, 0.5); +} + + .item-rating-label:hover { @@ -662,6 +683,32 @@ opacity: 1; } +/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */ +.global-visibility-section .rating-option input:checked + .rating-label.sfw { + background: rgba(81, 207, 102, 0.2); + border-color: #51cf66; + color: #51cf66; + box-shadow: none; + opacity: 1; +} + +.global-visibility-section .rating-option input:checked + .rating-label.nsfw { + background: rgba(255, 187, 51, 0.2); + border-color: #ffbb33; + color: #ffbb33; + box-shadow: none; + opacity: 1; +} + +.global-visibility-section .rating-option input:checked + .rating-label.nsfl { + background: rgba(255, 68, 68, 0.2); + border-color: #ff4444; + color: #ff4444; + box-shadow: none; + opacity: 1; +} + + /* Tags */ .tag-input-container { background: rgba(255, 255, 255, 0.05); diff --git a/public/s/js/admin.js b/public/s/js/admin.js index 21c5a5e..566bf8d 100644 --- a/public/s/js/admin.js +++ b/public/s/js/admin.js @@ -1,18 +1,24 @@ (async () => { // Helper to get dynamic context const getContext = () => { - const idLink = document.querySelector("a.id-link"); - if (!idLink) return null; + const commentsEl = document.querySelector("#comments-container"); + const favoEl = document.querySelector("#a_favo"); + const infoEl = document.querySelector("#a_info"); + const idLinkEl = document.querySelector("a.id-link"); + + const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText; + if (!rawId) return null; + const tagsContainer = document.querySelector("#tags"); - const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer; + const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null; const usernameEl = document.querySelector("a#a_username"); return { - postid: +idLink.innerText, + postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(), // data-username holds the raw DB username; data-author-id holds the user's numeric ID. // Never fall back to innerText — it may be a display name or the literal string 'unknown'. poster: (usernameEl?.dataset?.username || '').trim() || null, authorId: (usernameEl?.dataset?.authorId || '').trim() || null, - tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) + tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : [] }; }; diff --git a/public/s/js/comments.js b/public/s/js/comments.js index b66cbcb..21386c9 100644 --- a/public/s/js/comments.js +++ b/public/s/js/comments.js @@ -2405,10 +2405,50 @@ class CommentSystem { } }, { passive: true }); - // Global click listener to close popups (useful for mobile dismissal) + // Global click listener for comment interaction (popups & expanding truncated comments in previews) document.addEventListener('click', (e) => { - const isLink = e.target.closest('.comment-context-link'); - const isPopup = e.target.closest('.comment-preview-popup'); + const target = e.target; + + // Load full comment (expand truncated) + const loadFullBtn = target.closest('.load-full-comment-btn'); + if (loadFullBtn) { + const contentEl = loadFullBtn.closest('.comment-content'); + if (contentEl) { + if (contentEl.querySelector('.collapse-comment-btn')) return; + const commentEl = contentEl.closest('.comment'); + const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null; + const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null); + if (fullContent) { + contentEl.innerHTML = this.renderCommentContent(fullContent, null, true); + const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less'; + contentEl.insertAdjacentHTML('beforeend', + `` + ); + CommentSystem.playEmojiVideos(contentEl); + } + } + return; + } + + // Collapse full comment back to truncated view + const collapseBtn = target.closest('.collapse-comment-btn'); + if (collapseBtn) { + const contentEl = collapseBtn.closest('.comment-content'); + if (contentEl) { + if (contentEl.querySelector('.load-full-comment-btn')) return; + const commentEl = contentEl.closest('.comment'); + const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null; + const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null); + if (fullContent) { + contentEl.innerHTML = this.renderCommentContent(fullContent, null, false); + CommentSystem.playEmojiVideos(contentEl); + } + } + return; + } + + const isLink = target.closest('.comment-context-link'); + const isPopup = target.closest('.comment-preview-popup'); if (!isLink && !isPopup) { this.closePreviewsAboveLevel(-1); @@ -2823,7 +2863,10 @@ class CommentSystem { if (loadFullBtn) { const contentEl = loadFullBtn.closest('.comment-content'); if (contentEl) { - const fullContent = contentEl.dataset.raw; + if (contentEl.querySelector('.collapse-comment-btn')) return; + const commentEl = contentEl.closest('.comment'); + const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null; + const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null); if (fullContent) { contentEl.innerHTML = this.renderCommentContent(fullContent, null, true); // Append "see less" button after full content @@ -2831,6 +2874,7 @@ class CommentSystem { contentEl.insertAdjacentHTML('beforeend', `` ); + CommentSystem.playEmojiVideos(contentEl); } } return; @@ -2841,9 +2885,13 @@ class CommentSystem { if (collapseBtn) { const contentEl = collapseBtn.closest('.comment-content'); if (contentEl) { - const fullContent = contentEl.dataset.raw; + if (contentEl.querySelector('.load-full-comment-btn')) return; + const commentEl = contentEl.closest('.comment'); + const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null; + const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null); if (fullContent) { contentEl.innerHTML = this.renderCommentContent(fullContent, null, false); + CommentSystem.playEmojiVideos(contentEl); } } return; diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index eadca1f..7068a59 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -965,7 +965,7 @@ window.cancelAnimFrame = (function () { 'login-modal', 'register-modal', 'forgot-modal', 'reset-modal', 'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal', 'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay', - 'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal' + 'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal', 'visibility-modal' ]; modalIds.forEach(id => { // Don't close the filter modal during a background mime-filter reload @@ -2442,8 +2442,8 @@ window.cancelAnimFrame = (function () { const isUpload = pathname.match(/\/upload\/?(?:$|\?)/); const parts = pathname.split('/').filter(Boolean); const isItem = !pathname.match(/\/p\//) && ( - pathname.match(/^\/\d+/) || - (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1])) + pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || + (parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ); const isMessages = !!pathname.match(/^\/messages(\/|$)/); const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss'; @@ -3356,19 +3356,19 @@ window.cancelAnimFrame = (function () { // Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...) // Split path, filter numeric, pop last. const pathSegments = new URL(url, window.location.origin).pathname.split('/'); - const numericSegments = pathSegments.filter(s => /^\d+$/.test(s)); + const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s)); // Clear and Hide navbar pagination for Item View (Never show grid pagination on item view) // document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = ''); // Don't destroy content (cached grid needs it) document.querySelectorAll('.pagination-container-fluid').forEach(el => el.style.display = 'none'); - if (numericSegments.length === 0) { - console.warn("loadItemAjax: No ID match found in URL", url); + if (keySegments.length === 0) { + console.warn("loadItemAjax: No ID or slug match found in URL", url); // fallback for weird/external links window.location.href = url; return; } - const itemid = numericSegments.pop(); + const itemid = keySegments.pop(); // Extract context from Target URL first let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null; @@ -3551,18 +3551,19 @@ window.cancelAnimFrame = (function () { } const _hash = new URL(url, window.location.origin).hash; - let _pushUrl = `/${itemid}`; - if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`; - else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; } - else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`; - else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`; + const itemKey = _cachedItem.slug || itemid; + let _pushUrl = `/${itemKey}`; + if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`; + else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; } + else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemKey}`; + else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`; if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`; - if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`); + if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`); if (_hash) _pushUrl += _hash; if (!options.keepMedia && !options.skipPush) history.pushState({}, '', _pushUrl); - document.title = `${window.f0ckDomain} - ${itemid}`; + document.title = `${window.f0ckDomain} - ${itemKey}`; if (navbar) navbar.classList.remove('pbwork'); if (!options.keepMedia) { @@ -3587,14 +3588,14 @@ window.cancelAnimFrame = (function () { .then(r => r.ok ? r.text() : null) .then(freshText => { if (!freshText) return; - try { JSON.parse(freshText); } catch(_) {} - // Re-parse to get html string let freshHtml = freshText; + let freshSlug = null; try { const d = JSON.parse(freshText); if (d && typeof d.html === 'string') freshHtml = d.html; + if (d && (d.slug || d.item?.slug)) freshSlug = d.slug || d.item?.slug; } catch(_) {} - itemCacheMap.set(_itemCacheKey, { html: freshHtml, ts: Date.now() }); + itemCacheMap.set(_itemCacheKey, { html: freshHtml, slug: freshSlug, ts: Date.now() }); window.f0ckDebug('[itemCache] Background revalidation complete for', _itemCacheKey); }) .catch(() => {}); @@ -3619,7 +3620,7 @@ window.cancelAnimFrame = (function () { - Total Network: ${(tBody - tStart).toFixed(2)}ms - Content Size: ${(rawText.length / 1024).toFixed(2)} KB`); - let html, paginationHtml; + let html, paginationHtml, responseSlug = null; try { // Optimistically try to parse as JSON first @@ -3632,6 +3633,7 @@ window.cancelAnimFrame = (function () { if (data && typeof data.html === 'string') { html = data.html; paginationHtml = data.pagination; + responseSlug = data.slug || data.item?.slug || null; } else { html = rawText; } @@ -3642,7 +3644,7 @@ window.cancelAnimFrame = (function () { // ── Store in item cache (stale-while-revalidate) ─────────────────────── if (html && !options.skipCache) { - itemCacheMap.set(_itemCacheKey, { html, ts: Date.now() }); + itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, ts: Date.now() }); if (itemCacheMap.size > ITEM_CACHE_MAX) { // Evict oldest entry itemCacheMap.delete(itemCacheMap.keys().next().value); @@ -3749,26 +3751,25 @@ window.cancelAnimFrame = (function () { // Construct proper History URL (Context Aware) // If we inherited context, we should reflect it in the URL const hash = new URL(url, window.location.origin).hash; - let pushUrl = `/${itemid}`; + const itemKey = responseSlug || itemid; + let pushUrl = `/${itemKey}`; // Logic from ajax.mjs context reconstruction: if (userHall && userHallOwner) { - pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`; + pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`; } else if (user) { - pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; - if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; + pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; + if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; } - else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`; - else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`; + else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`; + else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemKey}`; // Append tagger filter so item nav stays in tagger context if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`; if (mime) { - // If it already has itemid at the end, insert mime before it - pushUrl = pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`); + // If it already has itemKey at the end, insert mime before it + pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`); } - - // Re-append hash if present if (hash) pushUrl += hash; @@ -3788,8 +3789,7 @@ window.cancelAnimFrame = (function () { if (window.initVisualizer) window.initVisualizer(); } // Try to extract ID from response if possible or just use itemid - document.title = `${window.f0ckDomain} - ${itemid}`; - if (navbar) navbar.classList.remove("pbwork"); + document.title = `${window.f0ckDomain} - ${itemKey}`; window.f0ckDebug("AJAX load complete"); // Notify extensions — also triggers CommentSystem init which renders comments @@ -3821,6 +3821,7 @@ window.cancelAnimFrame = (function () { console.error("AJAX load failed:", err); } finally { isNavigating = false; + if (navbar) navbar.classList.remove("pbwork"); } }; @@ -3999,15 +4000,16 @@ window.cancelAnimFrame = (function () { fetch(randomUrl) .then(r => r.json()) .then(data => { - if (data.success && data.items && data.items.id) { + if (data.success && data.items && (data.items.slug || data.items.id)) { + const targetKey = data.items.slug || data.items.id; // Navigate in the same context (user hall, favs, tag, etc.) if (wUserHall && wUserHallOwner) { - loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${data.items.id}`, true); + loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true); } else if (wFavsUser) { // Preserve /user/:name/favs/:id context so next/prev arrows stay within favs - loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${data.items.id}`, true); + loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true); } else { - loadItemAjax(`/${data.items.id}`, true); + loadItemAjax(`/${targetKey}`, true); } } else { window.location.href = link.href; @@ -4079,7 +4081,7 @@ window.cancelAnimFrame = (function () { return false; } - if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) { + if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) { e.preventDefault(); e.stopImmediatePropagation(); @@ -4092,9 +4094,10 @@ window.cancelAnimFrame = (function () { const parts = pathname.split('/').filter(Boolean); const isItemLink = !pathname.match(/\/p\//) && ( pathname.match(/^\/\d+/) || - (parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) || - (parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) || - (parts.length >= 3 && parts[0] === 'h' && /^\d+$/.test(parts[parts.length - 1])) + pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || + (parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || + (parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || + (parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ); if (isItemLink) { // Links inside comment bodies or MOTD should not inherit tag/hall/user context @@ -4162,6 +4165,25 @@ window.cancelAnimFrame = (function () { }) .catch(console.error); + } else if (target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn')) { + e.preventDefault(); + const visTrigger = target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn'); + const id = visTrigger.dataset.itemId || document.getElementById('visibility-item-id')?.value; + const visBtnMain = document.getElementById('a_visibility'); + const rawVis = visTrigger.dataset.visibility ?? visBtnMain?.dataset.visibility ?? '0'; + const currentVis = parseInt(rawVis, 10); + const modal = document.getElementById('visibility-modal'); + if (modal) { + const inputId = document.getElementById('visibility-item-id'); + if (inputId && id) inputId.value = id; + const radios = modal.querySelectorAll('input[name="visibility"]'); + radios.forEach(r => { + r.checked = (parseInt(r.value, 10) === currentVis); + }); + modal.style.display = 'flex'; + document.body.classList.add('modal-open'); + } + } else if (target.closest('#a_rethumb')) { e.preventDefault(); const reBtn = target.closest('#a_rethumb'); @@ -4530,8 +4552,10 @@ window.cancelAnimFrame = (function () { const parts = p.split('/').filter(Boolean); const isItem = !p.match(/\/p\//) && ( p.match(/^\/\d+/) || - (parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) || - (parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) + p.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || + (parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || + (parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) || + (parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ); const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking'); const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/'; @@ -8103,7 +8127,7 @@ class NotificationSystem { } } - if (typeof window.loadItemAjax === 'function' && href.match(/^\/\d+/)) { + if (typeof window.loadItemAjax === 'function' && (href.match(/^\/\d+/) || href.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || href.match(/\/(?:user|tag|h)\/.*?\/(?:\d+|[a-zA-Z0-9_-]{11})/))) { window.loadItemAjax(href, false); } else if (typeof window.loadPageAjax === 'function') { window.loadPageAjax(href, true); @@ -8389,7 +8413,8 @@ class NotificationSystem { } renderHistoryItem(n) { - let link = `/${n.item_id}`; + const itemKey = n.item_slug || n.slug || n.item_id; + let link = `/${itemKey}`; let msg = ''; let user = n.from_display_name || n.from_user || 'System'; @@ -8398,14 +8423,14 @@ class NotificationSystem { const isDeleted = n.type === 'item_deleted'; const label = isDeleted ? (i18n.notif_upload_deleted || 'A moderator deleted your upload') : (i18n.notif_upload_denied || 'Your Upload was denied'); const userLabel = isDeleted ? (i18n.notif_moderation || 'Moderation') : (i18n.notif_system || 'System'); - const itemLink = `/${n.item_id}`; + const itemLink = `/${itemKey}`; return `
thumb
${userLabel}
- ${label} #${n.item_id} + ${label} #${itemKey}
${i18n.notif_reason_label || 'Reason:'} ${n.reason || (i18n.notif_no_reason || 'No reason provided')}
${new Date(n.created_at).toLocaleString()}
@@ -8425,7 +8450,7 @@ class NotificationSystem { if (n.data?.msg) msg += `
${n.data.msg}`; if (n.data?.url) msg += `
${n.data.url}`; user = (window.f0ckI18n && window.f0ckI18n.notif_system) || 'System'; - link = n.item_id ? `/${n.item_id}` : '#'; + link = n.item_id ? `/${itemKey}` : '#'; } else if (n.type === 'admin_pending') { link = '/mod/approve'; user = (window.f0ckI18n && window.f0ckI18n.notif_admin) || 'Admin'; @@ -8441,7 +8466,7 @@ class NotificationSystem { if (n.reason) msg += `
${n.reason}
`; } else { // Comment notification - link = `/${n.item_id}#c${n.comment_id || n.reference_id}`; + link = `/${itemKey}#c${n.comment_id || n.reference_id}`; if (n.type === 'comment_reply') msg = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you'; else if (n.type === 'subscription') msg = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow'; else if (n.type === 'mention') msg = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you'; @@ -8482,8 +8507,9 @@ class NotificationSystem { } renderItem(n) { + const itemKey = n.item_slug || n.slug || n.item_id; if (n.type === 'approve') { - const link = `/${n.item_id}`; + const link = `/${itemKey}`; return `
thumb
@@ -8498,7 +8524,7 @@ class NotificationSystem { } if (n.type === 'upload_success') { - const link = `/${n.item_id}`; + const link = `/${itemKey}`; return `
thumb
@@ -8517,7 +8543,7 @@ class NotificationSystem { const errMsg = n.data?.msg || ''; const errDisplay = errMsg ? `
${errMsg}
` : ''; const urlDisplay = url ? `
${url}
` : ''; - const link = n.item_id ? `/${n.item_id}` : '#'; + const link = n.item_id ? `/${itemKey}` : '#'; return `
@@ -8541,7 +8567,7 @@ class NotificationSystem {
thumb
- ${label} #${n.item_id} + ${label} #${itemKey}
${(window.f0ckI18n && window.f0ckI18n.notif_click_reason) || 'Click to see reason'}
${new Date(n.created_at).toLocaleString()} @@ -8600,10 +8626,10 @@ class NotificationSystem { if (n.type === 'comment_reply') typeText = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you'; else if (n.type === 'subscription') typeText = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow'; else if (n.type === 'mention') typeText = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you'; - else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${n.item_id}`; + else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${itemKey}`; const cid = n.comment_id || n.reference_id; - const link = `/${n.item_id}#c${cid}`; + const link = `/${itemKey}#c${cid}`; const thumb = n.item_id ? `
thumb
` : ''; return ` @@ -8768,10 +8794,11 @@ class NotificationSystem { // Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color) const fragment = document.createDocumentFragment(); data.favs.forEach(fav => { - if (fav.hide_fav_badge) { + const isSelf = window.f0ckSession && window.f0ckSession.user && (window.f0ckSession.user === fav.user); + if (fav.hide_fav_badge && !isSelf) { const a = document.createElement('a'); a.className = 'ghost-fav'; - a.setAttribute('tooltip', 'Ghost Fav'); + a.setAttribute('tooltip', '?'); a.setAttribute('flow', 'up'); a.style.cursor = 'default'; @@ -8884,12 +8911,14 @@ class NotificationSystem { if (!isOwnUpload) return; // Silently drop as before for other users' uploads + const itemKey = data.slug || data.id; + // Don't add duplicates - if (grid.querySelector(`a[href$="/${data.id}"]`)) return; + if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return; // Determine the link prefix from existing items const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb'); - const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/'; + const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/'; // Build filter-reason label const i18n = window.f0ckI18n || {}; @@ -8904,7 +8933,7 @@ class NotificationSystem { const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null'; const ghost = document.createElement('a'); - ghost.href = `${linkBase}${data.id}`; + ghost.href = `${linkBase}${itemKey}`; ghost.className = 'thumb lazy-thumb filtered-upload-ghost loaded'; ghost.dataset.file = data.dest; ghost.dataset.mime = data.mime; @@ -8935,19 +8964,21 @@ class NotificationSystem { } } + const itemKey = data.slug || data.id; + // Don't add duplicates - if (grid.querySelector(`a[href$="/${data.id}"]`)) return; + if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return; // Determine the link prefix from existing items const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb'); - const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/'; + const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/'; // Respect mode filter const nsflId = window.f0ckSession?.nsfl_tag_id; const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null'; const thumb = document.createElement('a'); - thumb.href = `${linkBase}${data.id}`; + thumb.href = `${linkBase}${itemKey}`; thumb.className = 'thumb lazy-thumb'; thumb.dataset.file = data.dest; thumb.dataset.mime = data.mime; @@ -9129,10 +9160,11 @@ class NotificationSystem {
`; } + const itemKey = c.item_slug || c.slug || c.item_id; itemPreview = `
`; } @@ -11302,6 +11334,83 @@ document.addEventListener('click', (e) => { return; } + const visCancelBtn = e.target.closest('#visibility-modal-cancel'); + if (visCancelBtn || e.target.id === 'visibility-modal') { + const modal = document.getElementById('visibility-modal'); + if (modal) { + modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + return; + } + + const visSaveBtn = e.target.closest('#visibility-modal-save'); + if (visSaveBtn) { + e.preventDefault(); + const modal = document.getElementById('visibility-modal'); + const inputId = document.getElementById('visibility-item-id'); + const selectedRadio = modal ? modal.querySelector('input[name="visibility"]:checked') : null; + if (!inputId || !selectedRadio) return; + + const id = inputId.value; + const nextVis = parseInt(selectedRadio.value, 10); + + visSaveBtn.disabled = true; + const origText = visSaveBtn.textContent; + visSaveBtn.textContent = 'Saving...'; + + fetch('/api/v2/item/visibility', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': window.f0ckSession?.csrf_token + }, + body: new URLSearchParams({ postid: id, id: id, visibility: nextVis }) + }) + .then(r => r.json()) + .then(data => { + visSaveBtn.disabled = false; + visSaveBtn.textContent = origText; + if (data.success) { + const visVal = parseInt(data.visibility, 10); + const titles = ['Public', 'Unlisted', 'Private']; + const icons = ['fa-globe', 'fa-link', 'fa-lock']; + const colors = ['var(--color-success, #00C851)', 'var(--color-warning, #ffbb33)', 'var(--color-danger, #ff4444)']; + + const visBtn = document.getElementById('a_visibility'); + if (visBtn) { + visBtn.dataset.visibility = visVal; + visBtn.setAttribute('title', `Visibility: ${titles[visVal]} (Click to change)`); + } + + const infoVisBtn = document.getElementById('info-visibility-edit-btn'); + if (infoVisBtn) { + infoVisBtn.dataset.visibility = visVal; + } + + const infoVisLabel = document.getElementById('info-visibility-label'); + if (infoVisLabel) { + infoVisLabel.innerHTML = ` ${titles[visVal]}`; + } + + if (modal) { + modal.style.display = 'none'; + document.body.classList.remove('modal-open'); + } + if (window.flashMessage) window.flashMessage(`VISIBILITY SET TO ${titles[visVal].toUpperCase()}`); + } else { + if (window.flashError) window.flashError(data.msg || 'Failed to update visibility'); + } + }) + .catch(err => { + visSaveBtn.disabled = false; + visSaveBtn.textContent = origText; + console.error('Error changing visibility:', err); + if (window.flashError) window.flashError('Network error'); + }); + return; + } + // Title save button const saveBtn = e.target.closest('#info-title-save'); if (saveBtn) { diff --git a/public/s/js/settings.js b/public/s/js/settings.js index d765818..df57235 100644 --- a/public/s/js/settings.js +++ b/public/s/js/settings.js @@ -1181,6 +1181,35 @@ }); } + const defaultUploadVisSelect = document.getElementById('default_upload_visibility_select'); + if (defaultUploadVisSelect) { + defaultUploadVisSelect.addEventListener('change', async function() { + const vis = parseInt(defaultUploadVisSelect.value, 10); + try { + const res = await fetch('/api/v2/settings/default_upload_visibility', { + method: 'PUT', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : '' + }, + body: new URLSearchParams({ default_upload_visibility: vis }) + }); + const data = await res.json(); + if (data.success) { + showStatus('Default upload visibility updated!', 'success'); + if (window.f0ckSession) { + window.f0ckSession.default_upload_visibility = vis; + } + } else { + alert(data.msg || 'Error saving preference'); + } + } catch (err) { + console.error('Update Default Upload Visibility error:', err); + alert('Connection error'); + } + }); + } + // New Dual Column Layout Toggle const layoutToggle = document.getElementById('use_new_layout_toggle'); if (layoutToggle) { diff --git a/public/s/js/sidebar-activity.js b/public/s/js/sidebar-activity.js index 964ebf6..a90f2e7 100644 --- a/public/s/js/sidebar-activity.js +++ b/public/s/js/sidebar-activity.js @@ -479,8 +479,9 @@ }; const renderActivityItem = (c) => { + const itemKey = c.item_slug || c.slug || c.item_id; const rawContent = c.content || c.body || ''; - let displayContent = renderCommentContent(rawContent, c.id, c.item_id); + let displayContent = renderCommentContent(rawContent, c.id, itemKey); displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent; @@ -497,7 +498,7 @@ } const attachmentsHtml = renderCommentAttachments(c.files, rawContent); - const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id); + const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey); // Build avatar URL — same priority as the rest of the app let avatarSrc = '/a/default.png'; @@ -542,8 +543,8 @@ itemPreview = ` `; } diff --git a/public/s/js/upload.js b/public/s/js/upload.js index 5983812..c0cdbf2 100644 --- a/public/s/js/upload.js +++ b/public/s/js/upload.js @@ -908,7 +908,7 @@ window.initUploadForm = (selector) => { } lines.forEach(url => { if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) { - selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false }); + selectedFiles.push({ type: 'url', url, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false }); } }); urlInput.value = ''; @@ -931,7 +931,7 @@ window.initUploadForm = (selector) => { const val = urlInput.value.trim(); if (!val || !/^https?:\/\//i.test(val)) return; if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) { - selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false }); + selectedFiles.push({ type: 'url', url: val, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false }); } urlInput.value = ''; if (urlBadge) urlBadge.style.display = 'none'; @@ -1177,7 +1177,7 @@ window.initUploadForm = (selector) => { if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) { if (isShitpost) { - selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false }); + selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false }); } else { selectedFiles.push(file); // Legacy single file mode uses raw File } @@ -1490,6 +1490,7 @@ window.initUploadForm = (selector) => { infoRow.className = 'file-meta-row-small'; let ratingSwitch = ''; + let visibilitySwitch = ''; let tagsUI = ''; let ocUI = ''; let commentUI = ''; @@ -1517,6 +1518,26 @@ window.initUploadForm = (selector) => {
`; + const globalVis = form.querySelector('input[name="visibility"]:checked')?.value || '0'; + const visValue = (item.visibility !== undefined && item.visibility !== '') ? item.visibility : globalVis; + item.visibility = visValue; + visibilitySwitch = ` +
+ + + +
+ `; + const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...'; const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : ''; tagsUI = ` @@ -1560,19 +1581,27 @@ window.initUploadForm = (selector) => {
${titleUI} ${ratingSwitch} + ${visibilitySwitch} ${tagsUI} ${commentUI} `; if (isShitpost) { // Handle Rating - infoRow.querySelectorAll('.item-rating-option input').forEach(radio => { + infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => { radio.onchange = () => { item.rating = radio.value; updateSubmitButton(); }; }); + // Handle Visibility + infoRow.querySelectorAll('.item-visibility-container input').forEach(radio => { + radio.onchange = () => { + item.visibility = radio.value; + }; + }); + // Handle Comment const commentInput = infoRow.querySelector('.item-comment-input'); const emojiTrigger = infoRow.querySelector('.item-emoji-trigger'); @@ -2436,6 +2465,8 @@ window.initUploadForm = (selector) => { } try { + const globalVisEl = form.querySelector('input[name="visibility"]:checked'); + const visibilityVal = globalVisEl ? globalVisEl.value : '0'; const resp = await fetch('/api/v2/upload-url', { method: 'POST', headers: { @@ -2445,7 +2476,8 @@ window.initUploadForm = (selector) => { }, body: JSON.stringify({ url, - rating: globalRatingEl.value, + rating: globalRatingEl ? globalRatingEl.value : 'sfw', + visibility: visibilityVal, tags: tags.join(','), comment: comment, is_oc: isOc, @@ -2552,9 +2584,11 @@ window.initUploadForm = (selector) => { for (let i = 0; i < selectedFiles.length; i++) { const item = selectedFiles[i]; + const globalVisEl = form.querySelector('input[name="visibility"]:checked'); const isUrlItem = isShitpost && item.type === 'url'; const file = !isUrlItem ? (isShitpost ? item.file : item) : null; const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw'); + const fileVisibility = isShitpost ? (item.visibility || globalVisEl?.value || '0') : (globalVisEl?.value || '0'); const fileTags = isShitpost ? item.tags : tags; const fileComment = isShitpost ? item.comment : comment; const fileTitle = isShitpost ? (item.title || '') : titleVal; @@ -2571,6 +2605,7 @@ window.initUploadForm = (selector) => { formData.append('file', file); } formData.append('rating', fileRating); + formData.append('visibility', fileVisibility); formData.append('tags', fileTags.join(',')); formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false'); if (isShitpost) formData.append('is_shitpost', 'true'); @@ -2622,6 +2657,7 @@ window.initUploadForm = (selector) => { xhr.send(JSON.stringify({ url: item.url, rating: fileRating, + visibility: fileVisibility, tags: fileTags.join(','), is_oc: (isShitpost ? item.is_oc : isOc), comment: fileComment, @@ -2738,17 +2774,14 @@ window.initUploadForm = (selector) => { // Skip redirect if every item was a background URL job const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url'); if (!allPending) { - // Inject now if the grid is already in the DOM (upload modal open on main page) - injectNewItem(); - // Navigate to main page, then inject again after the grid has loaded. - // Awaiting loadPageAjax ensures the .posts grid DOM is present before the - // handleNewItem call — this covers the item-page drag-and-upload scenario - // where no grid exists until after navigation completes. + const targetUrl = (lastData && (lastData.visibility > 0 || lastData.redirect || lastData.slug)) + ? (lastData.redirect || `/${lastData.slug || lastData.itemid}`) + : '/'; if (typeof window.loadPageAjax === 'function') { - await window.loadPageAjax('/', true, { bypassCache: true }); - injectNewItem(); + await window.loadPageAjax(targetUrl, true, { bypassCache: true }); + if (targetUrl === '/') injectNewItem(); } else { - window.location.href = '/'; + window.location.href = targetUrl; } } } else { diff --git a/public/s/js/user.js b/public/s/js/user.js index c76e055..8cfc8b7 100644 --- a/public/s/js/user.js +++ b/public/s/js/user.js @@ -1,14 +1,20 @@ (async () => { // Helper to get dynamic context from the DOM const getContext = () => { - const idLink = document.querySelector("a.id-link"); - if (!idLink) return null; + const commentsEl = document.querySelector("#comments-container"); + const favoEl = document.querySelector("#a_favo"); + const infoEl = document.querySelector("#a_info"); + const idLinkEl = document.querySelector("a.id-link"); + + const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText; + if (!rawId) return null; + const tagsContainer = document.querySelector("#tags"); - const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer; + const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null; return { - postid: +idLink.innerText, + postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(), poster: document.querySelector("a#a_username")?.innerText, - tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) + tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : [] }; }; diff --git a/public/s/js/user_comments.js b/public/s/js/user_comments.js index 588f76f..d1fc78f 100644 --- a/public/s/js/user_comments.js +++ b/public/s/js/user_comments.js @@ -410,11 +410,12 @@ if (!window.UserCommentSystem) { } renderComment(c) { + const itemKey = c.item_slug || c.slug || c.item_id; const timeAgo = this.timeAgo(c.created_at); const fullDate = new Date(c.created_at).toISOString(); - const content = this.renderCommentContent(c.content, c.item_id); + const content = this.renderCommentContent(c.content, itemKey); - return `
${this.username}
${timeAgo}
${content}
${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}
#${c.id}
`; + return `
${this.username}
${timeAgo}
${content}
${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}
#${c.id}
`; } startLiveTimestamps() { diff --git a/src/inc/lib.mjs b/src/inc/lib.mjs index 224e7a3..1c31678 100644 --- a/src/inc/lib.mjs +++ b/src/inc/lib.mjs @@ -38,6 +38,16 @@ export default new class { .replace(/'/g, "'"); } + generateSlug(length = 11) { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-'; + let slug = ''; + const bytes = crypto.randomBytes(length); + for (let i = 0; i < length; i++) { + slug += chars[bytes[i] % chars.length]; + } + return slug; + } + formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) { return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; }; diff --git a/src/inc/locales/de.json b/src/inc/locales/de.json index f40cf9e..74f12b5 100644 --- a/src/inc/locales/de.json +++ b/src/inc/locales/de.json @@ -169,6 +169,8 @@ "favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.", "hide_fav_badge": "Favoriten-Badge-Avatar verbergen", "hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an", + "default_upload_visibility": "Standard Upload-Sichtbarkeit", + "default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.", "image_expand_on_click": "Bilder beim Klicken inline erweitern", "image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.", "enable_bg_blur": "Hintergrundunschärfe aktivieren", @@ -799,6 +801,12 @@ "delete_confirm": "Diesen Einladungstoken löschen?", "slot_refreshes_on": "Slot erneuert sich am {date}", "slot_refreshed": "Slot erneuert", - "admin_desc": "Du bist Admin, leg los." + "admin_desc": "Du bist Admin, leg los.", + "visibility": { + "public": "Öffentlich", + "unlisted": "Nicht gelistet", + "private": "Privat", + "change_visibility": "Sichtbarkeit ändern" + } } } \ No newline at end of file diff --git a/src/inc/locales/en.json b/src/inc/locales/en.json index d113355..6cfef90 100644 --- a/src/inc/locales/en.json +++ b/src/inc/locales/en.json @@ -169,6 +169,8 @@ "favorites_private_hint": "Only you and administrators can view your favorites list.", "hide_fav_badge": "Hide Favorite Badge Avatar", "hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile", + "default_upload_visibility": "Default Upload Visibility", + "default_upload_visibility_hint": "Set the default visibility level for your new uploads.", "image_expand_on_click": "Expand images inline on click", "image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.", "enable_bg_blur": "Enable Background blur", @@ -801,6 +803,12 @@ "delete_confirm": "Delete this invite token?", "slot_refreshes_on": "slot refreshes on {date}", "slot_refreshed": "slot refreshed", - "admin_desc": "You are an admin, go ahead." + "admin_desc": "You are an admin, go ahead.", + "visibility": { + "public": "Public", + "unlisted": "Unlisted", + "private": "Private", + "change_visibility": "Change Visibility" + } } } \ No newline at end of file diff --git a/src/inc/routeinc/f0cklib.mjs b/src/inc/routeinc/f0cklib.mjs index b2ad199..8ebccd8 100644 --- a/src/inc/routeinc/f0cklib.mjs +++ b/src/inc/routeinc/f0cklib.mjs @@ -302,6 +302,9 @@ export default { userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`; } + const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator)); + const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce(items.visibility, 0) = 0`; + const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }); let total = getCachedCount(cacheKey); @@ -313,6 +316,7 @@ export default { where ${db.unsafe(modequery)} and items.active = true + ${visibilityFilter} ${tagFilter} ${titleFilter} ${fav ? db`and fav_u.user ilike ${user}` : db``} @@ -355,6 +359,7 @@ export default { where ${db.unsafe(modequery)} and items.active = true + ${visibilityFilter} ${tagFilter} ${titleFilter} ${fav ? db`and fav_u.user ilike ${user}` : db``} @@ -385,6 +390,8 @@ export default { const rows = (await db` select items.id, + items.slug, + items.visibility, items.mime, items.dest, items.username as username, @@ -521,7 +528,17 @@ export default { if (uhData.length) userHallObj = uhData[0]; } const mime = (rawMime ?? ""); - const itemid = rawItemid ? +rawItemid : null; + const rawIdOrSlug = rawItemid ?? null; + if (rawIdOrSlug === null || rawIdOrSlug === undefined || rawIdOrSlug === '') { + return { + success: false, + message: "404 - upload not found" + }; + } + + const isNumeric = /^\d+$/.test(String(rawIdOrSlug)); + const itemLookup = isNumeric ? db`items.id = ${+rawIdOrSlug}` : db`items.slug = ${String(rawIdOrSlug)}`; + const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m)); const mimeSQL = mimeParts.length > 0 ? db`and (${mimeParts.map(m => m === 'flash' @@ -535,19 +552,12 @@ export default { const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; const isStrict = strictParams.length > 0; - const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner }; + const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner }; const effMode = Number(mode ?? 0); const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null; const modequery = multiRatingSQL ?? lib.getMode(effMode); - if (itemid === null) { - return { - success: false, - message: "404 - upload not found" - }; - } - let tagFilter = db``; let titleFilter = db``; if (isTitleSearch && titleQuery) { @@ -588,6 +598,7 @@ export default { return db` ${db.unsafe(modequery)} and items.active = true + and coalesce(items.visibility, 0) = 0 ${tagFilter} ${titleFilter} ${hallFilter} @@ -601,11 +612,9 @@ export default { }; const startTime = Date.now(); - console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`); + console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`); // 1. Fetch the main item - // We only apply the active check and global NSFW filter (for guests) here. - // We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible). const items = await db` select distinct on (items.id) items.*, @@ -629,7 +638,7 @@ export default { left join "user_options" uo on uo.user_id = author_u.id ${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``} where - items.id = ${itemid} and + ${itemLookup} and items.active = true ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} limit 1 @@ -637,7 +646,41 @@ export default { const actitem = items[0]; - if (actitem && user_id) { + if (!actitem) { + return { + success: false, + message: "404 - upload not found" + }; + } + + const itemid = actitem.id; + + // Check visibility permissions: + const isOwnerOrAdmin = session && ( + (session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) || + session.admin || session.is_moderator + ); + + // If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private): + // Block numeric enumeration unless viewer is owner/admin + if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) { + return { + success: false, + message: "404 - upload not found" + }; + } + + // If item is Private (visibility === 2): + // Direct link only allowed for owner/admin + if (actitem.visibility === 2 && !isOwnerOrAdmin) { + return { + success: false, + is_private: true, + message: "403 - private upload" + }; + } + + if (user_id) { db` insert into user_video_views (user_id, video_id, view_count, last_viewed) values (${user_id}, ${itemid}, 1, now()) @@ -646,34 +689,26 @@ export default { last_viewed = now() `.catch(e => console.error('Failed to track view:', e)); } - - if (!actitem) { - // Item not found or filtered out - check if it exists but was filtered (for OG meta tags) - if (!session && getGlobalfilter()) { + // Guest global filter check if item was filtered out + if (!session && getGlobalfilter() && !actitem) { const unfilteredItem = await db` - select id from items where id = ${itemid} and active = true limit 1 + select id from items where ${itemLookup} and active = true limit 1 `; if (unfilteredItem[0]) { - // Item exists but was filtered - return minimal data for OG tags with blurred thumbnail const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall; return { success: false, message: "Sorry, this post is currently not visible.", item: { - id: itemid, - og_thumbnail: `${cfg.websrv.paths.thumbnails}/${itemid}_blur.webp`, + id: unfilteredItem[0].id, + og_thumbnail: `${cfg.websrv.paths.thumbnails}/${unfilteredItem[0].id}_blur.webp`, og_url: hallSlug - ? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}` - : `https://${cfg.main.url.domain}/${itemid}`, + ? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${unfilteredItem[0].id}` + : `https://${cfg.main.url.domain}/${unfilteredItem[0].id}`, og_description: `Content not visible in current mode` } }; } - } - return { - success: false, - message: "Sorry, this post is currently not visible." - }; } // 2. Fetch Next/Prev/Start/End/Cheat in parallel @@ -685,7 +720,7 @@ export default { const baseQuery = (whereClause, orderBy, limit = 1) => { return db` - select items.id + select items.id, items.slug from items left join tags_assign on tags_assign.item_id = items.id left join tags on tags.id = tags_assign.tag_id @@ -696,7 +731,7 @@ export default { where ${buildConditions()} ${whereClause} - group by items.id + group by items.id, items.slug ${orderBy} limit ${limit} `; @@ -711,7 +746,7 @@ export default { const checkFilter = !session && nsfpIds.length > 0; const query = db` - SELECT ta.item_id as id + SELECT ta.item_id as id, items.slug FROM tags_assign ta INNER JOIN items ON items.id = ta.item_id ${checkFilter @@ -781,24 +816,24 @@ export default { if (actitem.checksum && actitem.checksum.includes('_bypass_')) { const baseChecksum = actitem.checksum.split('_bypass_')[0]; const repostRows = await db` - SELECT id, username, stamp FROM items + SELECT id, slug, username, stamp FROM items WHERE active = true AND id != ${itemid} AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'}) ORDER BY id ASC `; - repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' })); + repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' })); } else if (actitem.checksum) { // Even without bypass, check if other bypass-entries exist with this same hash const baseChecksum = actitem.checksum; const repostRows = await db` - SELECT id, username, stamp FROM items + SELECT id, slug, username, stamp FROM items WHERE active = true AND id != ${itemid} AND checksum LIKE ${baseChecksum + '_bypass_%'} ORDER BY id ASC `; - repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' })); + repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' })); } // Also find visually-similar items via phash, merging with checksum results @@ -808,7 +843,7 @@ export default { const existingIds = new Set(repostItems.map(r => r.id)); for (const pm of phashMatches) { if (!existingIds.has(pm.id)) { - repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' }); + repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' }); existingIds.add(pm.id); } } @@ -843,7 +878,7 @@ export default { else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags - if (modeBlocked) { + if (modeBlocked && !isOwnerOrAdmin && actitem.visibility !== 1) { const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall; return { success: false, @@ -871,6 +906,8 @@ export default { }, item: { id: actitem.id, + slug: actitem.slug || null, + visibility: actitem.visibility !== undefined ? actitem.visibility : 0, username: actitem.username, author_id: actitem.author_id, author_color: actitem.author_color, @@ -933,13 +970,13 @@ export default { height: actitem.height || null, original_filename: actitem.original_filename || null }, - title: `${actitem.id} - ${cfg.websrv.domain}`, + title: `${(cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`, pagination: { - end: endItem[0]?.id || itemid, - start: startItem[0]?.id || itemid, - next: nextItem[0]?.id || null, - prev: prevItem[0]?.id || null, - page: actitem.id, + end: (cfg.enable_item_slugs !== false && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid), + start: (cfg.enable_item_slugs !== false && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid), + next: (cfg.enable_item_slugs !== false && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null), + prev: (cfg.enable_item_slugs !== false && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null), + page: (cfg.enable_item_slugs !== false && actitem.slug) ? actitem.slug : actitem.id, cheat: cheat }, phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)], diff --git a/src/inc/routes/ajax.mjs b/src/inc/routes/ajax.mjs index 5b5df55..37e9937 100644 --- a/src/inc/routes/ajax.mjs +++ b/src/inc/routes/ajax.mjs @@ -4,7 +4,7 @@ import cfg from "../config.mjs"; import { createI18n } from "../i18n.mjs"; export default (router, tpl) => { - router.get(/\/ajax\/item\/(?\d+)/, async (req, res) => { + router.get(/\/ajax\/item\/(?[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => { const tAjaxStart = Date.now(); let query = {}; if (typeof req.url === 'string') { @@ -35,12 +35,12 @@ export default (router, tpl) => { const ratingsRaw = req.cookies.ratings; const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; - const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1]; + const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1]; const data = await f0cklib.getf0ck({ itemid: itemid, mode: query.mode !== undefined ? +query.mode : req.mode, ratings: ratingsArr, - session: !!req.session, + session: req.session, url: contextUrl, user: query.user, tag: query.tag, @@ -170,7 +170,8 @@ export default (router, tpl) => { html: itemHtml, pagination: paginationHtml, title: data.title, - id: itemid + id: itemid, + slug: data.item?.slug || null }) }); }); diff --git a/src/inc/routes/apiv2/index.mjs b/src/inc/routes/apiv2/index.mjs index 9d4b8a0..cb94bfb 100644 --- a/src/inc/routes/apiv2/index.mjs +++ b/src/inc/routes/apiv2/index.mjs @@ -594,10 +594,12 @@ export default router => { ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, strict: isStrict, session: !!req.session, - exclude: req.session?.excluded_tags || [] + exclude: req.session?.excluded_tags || [], + user_id: req.session?.id, + is_admin: req.session?.admin }); - if (!data.itemid) { + if (!data || !data.itemid) { return res.json({ success: false, items: [] @@ -636,6 +638,7 @@ export default router => { items: { ...safeItem, id: item.id, + slug: item.slug || null, dest: relativeDest, url: directUrl, direct_url: directUrl @@ -1038,7 +1041,24 @@ export default router => { }); group.post(/\/togglefav$/, lib.loggedin, async (req, res) => { - const postid = +req.post.postid; + const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid; + if (rawPostid === undefined || rawPostid === null) { + return res.json({ success: false, msg: 'Missing postid' }, 400); + } + + // Support both numeric item ID and string slug + const isNumeric = /^\d+$/.test(String(rawPostid)); + const itemRow = await db` + SELECT id FROM items + WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false + LIMIT 1 + `; + + if (!itemRow.length) { + return res.json({ success: false, msg: 'Item not found' }, 404); + } + + const postid = itemRow[0].id; // Check if already faved by this user — compare as numbers to avoid type mismatch const existing = await db` @@ -1168,6 +1188,47 @@ export default router => { }); }); + group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => { + if (cfg.enable_private_uploads === false) { + return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403); + } + const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id; + const visibility = parseInt(req.post?.visibility ?? req.body?.visibility, 10); + if (!postid || isNaN(visibility) || ![0, 1, 2].includes(visibility)) { + return res.json({ success: false, msg: 'Invalid parameters' }, 400); + } + + const isNumeric = /^\d+$/.test(String(postid)); + const item = await db` + SELECT id, slug, username, visibility + FROM items + WHERE ${isNumeric ? db`id = ${+postid}` : db`slug = ${String(postid)}`} AND active = true AND is_deleted = false + LIMIT 1 + `; + + if (item.length === 0) { + return res.json({ success: false, msg: 'Item not found' }, 404); + } + + const isOwner = item[0].username === req.session.user; + const isAdmin = req.session.admin || req.session.is_moderator; + + if (!isOwner && !isAdmin) { + return res.json({ success: false, msg: 'Unauthorized' }, 403); + } + + await db`UPDATE items SET visibility = ${visibility} WHERE id = ${item[0].id}`; + + f0cklib.clearCountCache(); + + return res.json({ + success: true, + itemid: item[0].id, + slug: item[0].slug, + visibility: visibility + }); + }); + group.post(/\/item\/(?[0-9]+)\/rating$/, lib.loggedin, async (req, res) => { const itemid = +req.params.id; if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400); diff --git a/src/inc/routes/apiv2/settings.mjs b/src/inc/routes/apiv2/settings.mjs index 8339c61..fe67d86 100644 --- a/src/inc/routes/apiv2/settings.mjs +++ b/src/inc/routes/apiv2/settings.mjs @@ -353,6 +353,27 @@ export default router => { } }); + // Update Default Upload Visibility preference + group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => { + const vis = parseInt(req.post.default_upload_visibility, 10); + if (isNaN(vis) || ![0, 1, 2].includes(vis)) { + return res.json({ success: false, msg: 'Invalid visibility option' }, 400); + } + + try { + await db` + update user_options + set default_upload_visibility = ${vis} + where user_id = ${+req.session.id} + `; + if (req.session) req.session.default_upload_visibility = vis; + return res.json({ success: true, default_upload_visibility: vis }, 200); + } catch (e) { + console.error('Update Default Upload Visibility pref error:', e); + return res.json({ success: false, msg: 'Error updating preference' }, 500); + } + }); + // Update Username Color preference group.put(/\/username_color/, lib.loggedin, async (req, res) => { const { color } = req.post; diff --git a/src/inc/routes/apiv2/upload.mjs b/src/inc/routes/apiv2/upload.mjs index 4318e8e..21c2afc 100644 --- a/src/inc/routes/apiv2/upload.mjs +++ b/src/inc/routes/apiv2/upload.mjs @@ -137,6 +137,16 @@ const parseMultipart = (buffer, boundary) => { import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs"; +const getTargetVisibility = (req, postVis) => { + if (cfg.enable_private_uploads === false) return 0; + const rawHeader = req.headers ? req.headers['x-upload-visibility'] : null; + const val = (rawHeader || postVis || '').toString().trim().toLowerCase(); + if (val === 'private' || val === '2') return 2; + if (val === 'unlisted' || val === '1') return 1; + if (val === 'public' || val === '0') return 0; + return req.session?.default_upload_visibility || 0; +}; + // Collect request body as buffer with debug logging const collectBody = (req) => { return new Promise((resolve, reject) => { @@ -369,6 +379,9 @@ export default router => { // Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube const filename = `yt:${videoId}`; + const targetVisibility = getTargetVisibility(req, req.post?.visibility); + const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null; + const [{ id: itemid }] = await db` insert into items ${db({ src: ytUrl, @@ -383,8 +396,10 @@ export default router => { stamp: ~~(Date.now() / 1000), active: !isApprovalRequired, is_oc: !!is_oc, - title: title - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')} + title: title, + visibility: targetVisibility, + slug: itemSlug + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')} RETURNING id `; @@ -437,6 +452,9 @@ export default router => { }); } else { // ===== REGULAR URL DOWNLOAD (Asynchronous) ===== + const targetVisibility = getTargetVisibility(req, req.post?.visibility); + const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null; + const session = { id: req.session.id, user: req.session.user, @@ -689,8 +707,10 @@ export default router => { stamp: ~~(Date.now() / 1000), active: !isApprovalRequired, is_oc: !!is_oc, - title: title - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')} + title: title, + visibility: targetVisibility, + slug: itemSlug + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug')} RETURNING id `; diff --git a/src/inc/routes/comments.mjs b/src/inc/routes/comments.mjs index 6415b13..430be14 100644 --- a/src/inc/routes/comments.mjs +++ b/src/inc/routes/comments.mjs @@ -174,7 +174,7 @@ export default (router, tpl) => { const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id'); const comments = await db` - SELECT c.*, i.mime, i.id as item_id + SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug FROM comments c LEFT JOIN items i ON c.item_id = i.id WHERE c.user_id = ${userId} AND c.is_deleted = false @@ -570,6 +570,7 @@ export default (router, tpl) => { // Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here) const itemQuery = await db` SELECT + i.slug, i.xd_score, (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) @@ -592,6 +593,7 @@ export default (router, tpl) => { type: 'comment', id: commentId, item_id: item_id, + item_slug: itemQuery[0]?.slug || null, parent_id: parent_id || null, body: notifyBody, username: req.session.user, @@ -1003,6 +1005,7 @@ export default (router, tpl) => { c.*, i.mime, i.id as item_id, + i.slug as item_slug, i.dest as item_dest, (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[]) diff --git a/src/inc/routes/index.mjs b/src/inc/routes/index.mjs index 05c9587..0392294 100644 --- a/src/inc/routes/index.mjs +++ b/src/inc/routes/index.mjs @@ -73,7 +73,7 @@ export default (router, tpl) => { ratings: ratingsArr, mime: mime, fav: false, - session: !!req.session, + session: req.session, user_id: req.session?.id, random: isRandom }); @@ -253,7 +253,7 @@ export default (router, tpl) => { console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); if (!data.success) { - if (data.is_private) { + if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) { const { t: tErr } = createI18n(req.session?.language || req.lang || 'en'); return res.reply({ code: 403, @@ -414,11 +414,11 @@ export default (router, tpl) => { // Specific route for direct item links: /user/:user/:itemid // This avoids ambiguity with the profile route - router.get(/\/user\/(?[^/]+)\/(?\d+)$/, handleGenericRoute); + router.get(/\/user\/(?[^/]+)\/(?[a-zA-Z0-9_-]+)$/, handleGenericRoute); // Generic router for everything else (Index, Tags, standard User Grids) - // We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them. - router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?.+?))?(?:\/h\/(?.+?))?(?:\/user\/(?.+?)\/(?f0cks|uploads|favs))?(?:\/(?(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?\d+))?(?:\/(?\d+))?\/?(?:\?.*)?$/, handleGenericRoute); + // We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them. + router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?.+?))?(?:\/h\/(?.+?))?(?:\/user\/(?.+?)\/(?f0cks|uploads|favs))?(?:\/(?(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?\d+))?(?:\/(?[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute); /* */ router.get(/^\/(about)$/, (req, res) => { diff --git a/src/inc/routes/notifications.mjs b/src/inc/routes/notifications.mjs index cd4fa15..02bc72e 100644 --- a/src/inc/routes/notifications.mjs +++ b/src/inc/routes/notifications.mjs @@ -122,7 +122,7 @@ db.listen('activity', async (payload) => { // We need the username, avatar, and item mime for the preview // trigger only gave us user_id and item_id const [details] = await db` - SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime, + SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime, i.slug as item_slug, (SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id FROM "user" u LEFT JOIN user_options uo ON u.id = uo.user_id @@ -138,6 +138,7 @@ db.listen('activity', async (payload) => { data.username_color = details.username_color; data.display_name = details.display_name || null; data.tag_id = details.tag_id; + data.item_slug = details.item_slug; } else { data.username = 'System'; } @@ -367,7 +368,7 @@ export default (router, tpl) => { const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null); const notifications = typeFilter ? await db` - SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data, + SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data, COALESCE(u.user, 'System') as from_user, COALESCE(uo.display_name, '') as from_display_name, COALESCE(u.id, 0) as from_user_id, @@ -392,7 +393,7 @@ export default (router, tpl) => { OFFSET ${offset} ` : await db` - SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data, + SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data, COALESCE(u.user, 'System') as from_user, COALESCE(uo.display_name, '') as from_display_name, COALESCE(u.id, 0) as from_user_id, @@ -443,7 +444,7 @@ export default (router, tpl) => { try { const notifications = await db` - SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data, + SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data, COALESCE(u.user, 'System') as from_user, COALESCE(uo.display_name, '') as from_display_name, COALESCE(u.id, 0) as from_user_id, diff --git a/src/inc/routes/ranking.mjs b/src/inc/routes/ranking.mjs index 122c3f6..26e3070 100644 --- a/src/inc/routes/ranking.mjs +++ b/src/inc/routes/ranking.mjs @@ -97,11 +97,11 @@ export default (router, tpl) => { `; const favotop = await db` - select favorites.item_id, count(*) favs + select favorites.item_id as id, items.slug, count(*) favs from favorites join items on items.id = favorites.item_id where items.active = true - group by favorites.item_id + group by favorites.item_id, items.slug having count(*) > 1 order by favs desc limit 10 @@ -110,7 +110,7 @@ export default (router, tpl) => { let xdtop = []; if (config.websrv.enable_xd_score) { const xdRows = await db` - select id, xd_score + select id, slug, xd_score from items where active = true and is_deleted = false and xd_score > 0 order by xd_score desc diff --git a/src/inc/routes/subscriptions.mjs b/src/inc/routes/subscriptions.mjs index 8d0a849..675aed0 100644 --- a/src/inc/routes/subscriptions.mjs +++ b/src/inc/routes/subscriptions.mjs @@ -34,7 +34,7 @@ export default (router, tpl) => { const subs = await db` SELECT s.created_at as sub_date, - i.id, i.dest, i.mime, i.username as uploader_name + i.id, i.slug, i.dest, i.mime, i.username as uploader_name FROM comment_subscriptions s JOIN items i ON s.item_id = i.id WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true @@ -45,6 +45,7 @@ export default (router, tpl) => { const items = subs.map(i => ({ id: i.id, + slug: i.slug || null, user: i.uploader_name || 'System', sub_created: new Date(i.sub_date).toLocaleString(), thumb: `/t/${i.id}.webp` @@ -108,7 +109,7 @@ export default (router, tpl) => { const subs = await db` SELECT s.created_at as sub_date, - i.id, i.dest, i.mime, i.username as uploader_name + i.id, i.slug, i.dest, i.mime, i.username as uploader_name FROM comment_subscriptions s JOIN items i ON s.item_id = i.id WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true @@ -118,6 +119,7 @@ export default (router, tpl) => { const items = subs.map(i => ({ id: i.id, + slug: i.slug || null, user: i.uploader_name || 'System', sub_created: new Date(i.sub_date).toLocaleString(), thumb: `/t/${i.id}.webp` diff --git a/src/inc/routes/user_halls.mjs b/src/inc/routes/user_halls.mjs index cacb18e..c2ea8f4 100644 --- a/src/inc/routes/user_halls.mjs +++ b/src/inc/routes/user_halls.mjs @@ -92,7 +92,7 @@ export default (router, tpl) => { const data = await f0cklib.getf0cks({ page: req.params.page, mode: req.mode, - session: !!req.session, + session: req.session, exclude: req.session?.excluded_tags || [], user_id: req.session?.id, userHall: slug, @@ -138,7 +138,7 @@ export default (router, tpl) => { const data = await f0cklib.getf0ck({ itemid: req.params.itemid, mode: req.mode, - session: !!req.session, + session: req.session, exclude: req.session?.excluded_tags || [], user_id: req.session?.id, userHall: slug, diff --git a/src/index.mjs b/src/index.mjs index 039cac6..948e7e2 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -672,7 +672,7 @@ process.on('uncaughtException', err => { user = [_cachedRow]; } else { user = await db` - select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode + select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode from "user_sessions" left join "user" on "user".id = "user_sessions".user_id left join "user_options" on "user_options".user_id = "user_sessions".user_id @@ -1362,6 +1362,7 @@ process.on('uncaughtException', err => { default_font: cfg.websrv.default_font || "", site_description: cfg.websrv.description || "The webs dumpster", enable_nsfl: !!cfg.enable_nsfl, + enable_private_uploads: cfg.enable_private_uploads !== false, nsfl_tag_id: cfg.nsfl_tag_id || 3, scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'], themes_json: JSON.stringify(cfg.websrv.themes || []), diff --git a/src/upload_handler.mjs b/src/upload_handler.mjs index 3f3b385..194683b 100644 --- a/src/upload_handler.mjs +++ b/src/upload_handler.mjs @@ -43,6 +43,10 @@ db`ALTER TABLE items ALTER COLUMN checksum TYPE character varying(255)`.catch(() db`ALTER TABLE items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {}); db`ALTER TABLE comment_files ALTER COLUMN dest TYPE character varying(60)`.catch(() => {}); +// One-time migration: fix NULL visibility values and ensure NOT NULL default going forward +db`UPDATE items SET visibility = 0 WHERE visibility IS NULL`.catch(() => {}); +db`ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0`.catch(() => {}); + export const handleUpload = async (req, res, self) => { // Manual session lookup is required here because this handler is called from a // bypass middleware that runs in parallel with the main session middleware. @@ -138,6 +142,27 @@ export const handleUpload = async (req, res, self) => { const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true; + // Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference + let targetVisibility = 0; + if (cfg.enable_private_uploads !== false) { + const rawVisHeader = req.headers['x-upload-visibility']; + const rawVisBody = parts.visibility; + const visVal = (rawVisHeader || rawVisBody || '').toString().trim().toLowerCase(); + + if (visVal === 'private' || visVal === '2') { + targetVisibility = 2; + } else if (visVal === 'unlisted' || visVal === '1') { + targetVisibility = 1; + } else if (visVal === 'public' || visVal === '0') { + targetVisibility = 0; + } else { + targetVisibility = req.session?.default_upload_visibility || 0; + } + } + + // Generate slug if enabled + const itemSlug = (cfg.enable_item_slugs !== false) ? lib.generateSlug(11) : null; + const maxLen = cfg.main.comment_max_length; if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) { return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400); @@ -456,8 +481,10 @@ export const handleUpload = async (req, res, self) => { original_filename: originalFilename, title: title, width: itemWidth, - height: itemHeight - }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height')} + height: itemHeight, + visibility: targetVisibility, + slug: itemSlug + }, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug')} `; const itemid = await queue.getItemID(filename); @@ -768,13 +795,16 @@ export const handleUpload = async (req, res, self) => { : 'Upload successful! Your upload is now live.'; const imagesPath = cfg.websrv.paths?.images || '/b'; + const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`; return sendJson(res, { success: true, msg: successMsg, itemid: itemid, + slug: itemSlug, + visibility: targetVisibility, manual_approval: manualApproval, - redirect: !manualApproval ? `/${itemid}` : null, - url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : `${cfg.main.url.full}/`, + redirect: !manualApproval ? itemRoute : null, + url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`, file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null, // Fields for immediate client-side grid injection (avoids SSE race condition) dest: filename, diff --git a/views/index-partial.html b/views/index-partial.html index 7d1ca0e..e47ca14 100644 --- a/views/index-partial.html +++ b/views/index-partial.html @@ -3,7 +3,7 @@ @include(snippets/page-title)
@each(items as item) - +
@if(item.is_pinned) diff --git a/views/item-partial-legacy.html b/views/item-partial-legacy.html index 3beeacd..49a5af5 100644 --- a/views/item-partial-legacy.html +++ b/views/item-partial-legacy.html @@ -6,7 +6,7 @@
-
{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}
+
{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}
@if(enable_item_title) @@ -102,7 +102,7 @@
- +
@@ -111,9 +111,9 @@
@if(session) @if(user_has_favorited) - + @else - + @endif @endif @if(item.is_oc)OC@endif @@ -125,13 +125,13 @@ - {{ item.id }} + {{ item.slug || item.id }} @if(!user_alternative_infobox) — [{!! item.author_display_name || item.username || 'unknown' !!}] @endif @if(!user_alternative_infobox) @if(item.is_oc) — OC@endif @endif - @if(!user_alternative_infobox) — @if(halls_enabled && item.primaryHall) — @endif @endif + @if(!user_alternative_infobox) — @if(halls_enabled && item.primaryHall) — @endif @endif @if(halls_enabled && item.primaryHall) {{ item.primaryHall.name }}@if(item.otherHalls && item.otherHalls.length)+{{ item.otherHalls.length }}@each(item.otherHalls as oh){{ oh.name }}@endeach@endif @@ -143,9 +143,9 @@ @if(session) @if(!user_alternative_infobox) @if(user_has_favorited) - + @else - + @endif @endif @@ -155,6 +155,7 @@ @endif @if(can_manage_item) + @if(can_extract_meta) @@ -197,7 +198,7 @@ @each(item.favorites as fav) - @if(fav.hide_fav_badge) + @if(fav.hide_fav_badge && (!session || session.user !== fav.user)) @else @@ -255,6 +256,27 @@ {!! item.title !!} @endif + + ID + {{ item.id }} + + + Visibility + + + @if(item.visibility === 2) + Private + @elseif(item.visibility === 1) + Unlisted + @else + Public + @endif + + @if(can_manage_item) + + @endif + + {{ t('info_modal.file_size') || 'File Size' }} {{ item.size }} @@ -272,7 +294,7 @@ @if(item.checksum) {{ t('info_modal.sha256') || 'SHA-256 Hash' }} - {{ item.checksum.split('_bypass_')[0] }} +
{{ item.checksum.split('_bypass_')[0] }}
@endif @if(item.is_repost || (item.reposts && item.reposts.length > 0)) @@ -281,9 +303,9 @@ @each(item.reposts as rp) @if(rp.match_type === 'phash') - ~#{{ rp.id }} + ~#{{ rp.slug || rp.id }} @else - #{{ rp.id }} + #{{ rp.slug || rp.id }} @endif @endeach @@ -310,3 +332,43 @@
+ + diff --git a/views/item-partial-modern.html b/views/item-partial-modern.html index f6546f5..501d4f3 100644 --- a/views/item-partial-modern.html +++ b/views/item-partial-modern.html @@ -58,7 +58,7 @@
-
{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}
+
{{ link.mainDisplay || link.main }}{{ item.slug || item.id }}{{ link.suffix }}
@if(enable_item_title) @@ -119,25 +119,26 @@
- {{ item.id }} — [{!! item.author_display_name || item.username || 'unknown' !!}] @if(item.is_oc) — OC@endif + {{ item.slug || item.id }} — [{!! item.author_display_name || item.username || 'unknown' !!}] @if(item.is_oc) — OC@endif @if(halls_enabled && item.primaryHall) — @endif @if(halls_enabled && item.primaryHall) {{ item.primaryHall.name }}@if(item.otherHalls && item.otherHalls.length)+{{ item.otherHalls.length }}@each(item.otherHalls as oh){{ oh.name }}@endeach@endif — @endif - +
@if(session) @if(user_has_favorited) - + @else - + @endif @if(can_manage_item) + @if(is_flash_item) @@ -157,7 +158,7 @@
@each(item.favorites as fav) - @if(fav.hide_fav_badge) + @if(fav.hide_fav_badge && (!session || session.user !== fav.user)) @else @@ -197,6 +198,27 @@ {!! item.title !!} @endif + + ID + {{ item.id }} + + + Visibility + + + @if(item.visibility === 2) + Private + @elseif(item.visibility === 1) + Unlisted + @else + Public + @endif + + @if(can_manage_item) + + @endif + + {{ t('info_modal.file_size') || 'File Size' }} {{ item.size }} @@ -214,7 +236,7 @@ @if(item.checksum) {{ t('info_modal.sha256') || 'SHA-256 Hash' }} - {{ item.checksum.split('_bypass_')[0] }} +
{{ item.checksum.split('_bypass_')[0] }}
@endif @if(item.is_repost || (item.reposts && item.reposts.length > 0)) @@ -223,9 +245,9 @@ @each(item.reposts as rp) @if(rp.match_type === 'phash') - ~#{{ rp.id }} + ~#{{ rp.slug || rp.id }} @else - #{{ rp.id }} + #{{ rp.slug || rp.id }} @endif @endeach @@ -251,4 +273,44 @@
+
+ + \ No newline at end of file diff --git a/views/ranking.html b/views/ranking.html index 205ca39..02d8cfd 100644 --- a/views/ranking.html +++ b/views/ranking.html @@ -94,7 +94,7 @@ @each(favotop as favo) - #{{ favo.item_id }} + #{{ favo.slug || favo.id }} {{ favo.favs }} {{ t('ranking.favs') }} @endeach @@ -109,7 +109,7 @@ @each(xdtop as item) - #{{ item.id }} + #{{ item.slug || item.id }} {{ item.xd_label }} {{ item.xd_score }} diff --git a/views/settings.html b/views/settings.html index 153bad5..93bf14f 100644 --- a/views/settings.html +++ b/views/settings.html @@ -334,6 +334,17 @@ {{ t('settings.hide_fav_badge_hint') }} +
+ + + {{ t('settings.default_upload_visibility_hint') }} +
@elseif(n.type === 'deny') - +
thumb
{{ t('notifications.system') }}
- {{ t('notifications.upload_denied').replace('{id}', n.item_id) }} + {{ t('notifications.upload_denied').replace('{id}', n.item_slug || n.item_id) }}
Reason: {{ n.reason }}
{{ new Date(n.created_at).toLocaleString() }}
@elseif(n.type === 'item_deleted') - +
thumb
{{ t('notifications.moderation') }}
- {{ t('notifications.upload_deleted').replace('{id}', n.item_id) }} + {{ t('notifications.upload_deleted').replace('{id}', n.item_slug || n.item_id) }}
Reason: {{ n.reason }}
{{ new Date(n.created_at).toLocaleString() }}
@elseif(n.type === 'upload_comment') - +
thumbnail
{{ t('notifications.new_comments') }}
-
{{ t('notifications.on_your_upload').replace('{id}', n.item_id) }}
+
{{ t('notifications.on_your_upload').replace('{id}', n.item_slug || n.item_id) }}
{{ new Date(n.created_at).toLocaleString() }}
@elseif(n.type === 'upload_success') - +
thumb
@@ -83,7 +83,7 @@
@elseif(n.type === 'upload_error') - + @if(n.item_id)
thumb @@ -116,7 +116,7 @@
@else -
+ @if(n.item_id)
thumb @@ -125,9 +125,9 @@ + @if(enable_private_uploads !== false) +
+ +
+ + + +
+
+ @endif +