79 Commits

Author SHA1 Message Date
2a6e0f44f3 fdsa 2026-08-22 20:02:28 +02:00
1eef246bc1 ji 2026-08-22 02:26:02 +02:00
64508b555a kp 2026-08-22 02:21:10 +02:00
9377f05454 hgfd 2026-08-22 02:14:22 +02:00
162a6bc0fc gfsd 2026-08-19 23:28:01 +02:00
2fdbb1276a mnji 2026-08-19 23:25:52 +02:00
dac84a6328 gfds 2026-08-19 23:21:13 +02:00
794e21c66f hgdf 2026-08-19 23:15:22 +02:00
11a4535ca9 hhpmc 2026-08-19 23:01:06 +02:00
3359e9f0d8 hgdf 2026-08-19 22:49:43 +02:00
5c25182f6a hgdf 2026-08-19 22:34:23 +02:00
c644f7fa2a f 2026-08-19 22:24:02 +02:00
e793b50348 4444444444444443333333333333 2026-08-19 22:22:10 +02:00
53417063e4 gfsd 2026-08-19 22:00:16 +02:00
d6e273a065 nice 2026-08-19 21:53:34 +02:00
0b2ece9447 xddd 2026-08-19 21:51:41 +02:00
63ea313742 hghdf 2026-08-19 21:14:42 +02:00
619a6f9557 settings overhaul v1 2026-08-19 21:06:08 +02:00
39646aa8c5 xd 2026-08-19 20:53:10 +02:00
3e4f8c80b2 xxdddd 2026-08-19 20:38:53 +02:00
d700ed1aa5 gfsd 2026-08-19 20:07:51 +02:00
8b5da35341 fdsa 2026-08-18 21:40:19 +02:00
3d1579eed9 hgd 2026-08-18 21:35:06 +02:00
ef44808210 f 2026-08-18 20:03:34 +02:00
5ebc3adff9 dsaa 2026-08-18 20:01:27 +02:00
fcf55b0156 v 2026-08-18 19:43:49 +02:00
febf0f9ae5 h 2026-08-18 19:43:40 +02:00
6fb5cc5c35 hgf 2026-08-18 01:07:59 +02:00
972371e7c8 automigrate xd 2026-08-18 01:05:52 +02:00
7dbd7fe270 kjgh 2026-08-17 23:56:53 +02:00
57d41913d1 fix abyss 2026-08-15 23:33:00 +02:00
d872e7bae3 hide tagger name from pub 2026-08-13 21:51:01 +02:00
4a2bd0bd9a tgre 2026-08-13 21:42:00 +02:00
82298681af sexo? 2026-08-13 21:38:51 +02:00
22db479bc2 public no untagged 2026-08-13 21:33:08 +02:00
9ec07e8e6c fix rating and fav 2026-08-13 21:14:20 +02:00
402a043b57 xd 2026-08-13 18:07:00 +02:00
9f07dae200 fix comment visibility for private uploads 2026-08-13 18:04:44 +02:00
23cbc689fb wizardry 2026-08-12 21:15:09 +02:00
b710f606ec visbility filter and slug resolution to id 2026-08-12 17:25:51 +02:00
c92306867f fix untagged behaviour in pagination 2026-08-12 17:10:53 +02:00
97604bda70 fix set rating on item view 2026-08-12 16:59:01 +02:00
3f7958f869 uga aga 2026-08-11 23:54:21 +02:00
ebd7855f15 gfds 2026-08-11 23:49:19 +02:00
fb3cbc76ec mjimnji 2026-08-11 23:41:37 +02:00
2865a2d4e7 testing url uplaod via api 2026-08-11 23:35:40 +02:00
f074d82bf6 gfd 2026-08-11 18:07:25 +02:00
07a874ec99 uga aga 2026-08-11 17:59:24 +02:00
e894795fe0 gfdgsd 2026-08-10 18:38:14 +02:00
2e53e7e173 stampy 2026-08-10 17:42:48 +02:00
8cdd4fa69a add import script 2026-08-10 17:36:10 +02:00
66386213a1 ngfd 2026-08-09 04:18:57 +02:00
06dd14712d fds 2026-08-09 04:12:11 +02:00
3e8c7c03ee uga aga 2026-08-09 04:01:56 +02:00
cb1b91c4c9 pickle rick 2026-08-09 03:56:33 +02:00
665924c84e maji manji 2026-08-09 03:30:25 +02:00
22dc1cd25b wef 2026-08-09 03:27:12 +02:00
a8a093450f gfd 2026-08-09 03:24:21 +02:00
3e20394c85 h 2026-08-09 03:21:56 +02:00
f1611be50a gfgpfgifp 2026-08-09 03:19:21 +02:00
2f9e0f19e4 hgd 2026-08-09 03:13:37 +02:00
ff735f49fa protect items via api 2026-08-09 03:05:08 +02:00
6e01e6e36f vcyx 2026-08-09 03:00:23 +02:00
40ce0ccbb1 hgdf 2026-08-09 02:58:06 +02:00
b9c7e24757 hgdf 2026-08-09 02:54:26 +02:00
9ae1d55521 starto 2026-08-09 02:46:02 +02:00
98ee76f61c gohan 2026-08-09 02:41:57 +02:00
b10ddac6f3 fdfsa 2026-08-09 02:22:22 +02:00
3179d15b42 cleanip 2026-08-08 10:10:27 +02:00
6dd70a0fc2 pickle rick 2026-08-08 10:04:54 +02:00
6ab0505e7e jg 2026-08-08 09:57:29 +02:00
162f1b40d0 pickle rick 2026-08-07 22:38:51 +02:00
9d2b0137da f 2026-08-07 22:35:56 +02:00
e179eb1955 create koepfe 2026-08-07 22:33:52 +02:00
a80e6dab96 jfh 2026-08-07 22:22:51 +02:00
c22ae5bde0 pisse aus meinem arsch 2026-08-07 22:18:32 +02:00
c9f5dc02fa hgdf 2026-08-07 22:14:23 +02:00
4fd15ec338 g 2026-08-07 22:08:53 +02:00
feb408338a 69 2026-08-07 21:42:12 +02:00
74 changed files with 9245 additions and 4345 deletions

View File

@@ -16,7 +16,7 @@ fi
# Parse arguments # Parse arguments
TARGET="master" TARGET="master"
CACHE_FLAG="" CACHE_FLAG=""
BRANCH_TARGET="" CUSTOM_BRANCH=""
for arg in "$@"; do for arg in "$@"; do
case $arg in case $arg in
@@ -27,8 +27,8 @@ for arg in "$@"; do
CACHE_FLAG="--no-cache" CACHE_FLAG="--no-cache"
;; ;;
*) *)
if [ -z "$BRANCH_TARGET" ]; then if [ -z "$CUSTOM_BRANCH" ]; then
BRANCH_TARGET=$arg CUSTOM_BRANCH=$arg
fi fi
;; ;;
esac esac
@@ -43,7 +43,10 @@ fi
# Set Image name and branch defaults # Set Image name and branch defaults
IMAGE_NAME="f0ckm" IMAGE_NAME="f0ckm"
if [ "$TARGET" == "master" ]; then if [ -n "$CUSTOM_BRANCH" ]; then
BRANCH_TARGET="$CUSTOM_BRANCH"
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
elif [ "$TARGET" == "master" ]; then
echo "--- PRODUCTION DEPLOYMENT (master) ---" echo "--- PRODUCTION DEPLOYMENT (master) ---"
BRANCH_TARGET="master" BRANCH_TARGET="master"
elif [ "$TARGET" == "stg" ]; then elif [ "$TARGET" == "stg" ]; then

View File

@@ -1,3 +1,4 @@
client_max_body_size 10000M; client_max_body_size 10000M;
client_body_timeout 120s; client_body_timeout 120s;
client_header_timeout 120s; client_header_timeout 120s;

View File

@@ -29,6 +29,11 @@
], ],
"enable_pdf": false, "enable_pdf": false,
"enable_nsfl": false, "enable_nsfl": false,
"enable_private_uploads": true,
"enable_expiring_uploads": true,
"default_upload_visibility": 0,
"allow_user_upload_visibility": true,
"enable_item_slugs": true,
"nsfl_tag_id": 4, "nsfl_tag_id": 4,
"allowedMimes": [ "allowedMimes": [
"audio", "audio",
@@ -123,7 +128,10 @@
"enable_profile_description": true, "enable_profile_description": true,
"user_alternative_infobox": false, "user_alternative_infobox": false,
"user_banner_enabled": true, "user_banner_enabled": true,
"comment_banner_enabled": true,
"comment_banner_max_height": 300,
"user_alternative_steuerung": false, "user_alternative_steuerung": false,
"expose_repost_links_to_guests": false,
"enable_swf": false, "enable_swf": false,
"swf_thumb": "/s/img/swf.png", "swf_thumb": "/s/img/swf.png",
"enable_archive": true, "enable_archive": true,

View File

@@ -31,6 +31,8 @@ services:
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z - ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z - ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z - ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z - ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
environment: environment:
@@ -39,6 +41,12 @@ services:
- VIRTUAL_PORT=1337 - VIRTUAL_PORT=1337
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-} - LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-} - LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
- DB_HOST=f0ckm-db
- DB_PORT=5432
- DB_USER=${POSTGRES_USER:-f0ckm}
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
- DB_NAME=${POSTGRES_DB:-f0ckm}
- NODE_ENV=production
ports: ports:
- "1337:1337" - "1337:1337"
restart: unless-stopped restart: unless-stopped
@@ -102,21 +110,17 @@ services:
f0ckm-nginx: f0ckm-nginx:
image: nginxproxy/nginx-proxy:latest image: nginxproxy/nginx-proxy:latest
container_name: f0ckm-nginx container_name: f0ckm-nginx
network_mode: "host"
profiles: profiles:
- f0ckm-nginx - f0ckm-nginx
environment: environment:
- ENABLE_IPV6=true - ENABLE_IPV6=true
ports:
- "80:80"
- "443:443"
volumes: volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro - /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:rw - certs:/etc/nginx/certs:rw
- vhost:/etc/nginx/vhost.d:rw - vhost:/etc/nginx/vhost.d:rw
- html:/usr/share/nginx/html:rw - html:/usr/share/nginx/html:rw
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro - ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
networks:
- f0ckm-net
restart: unless-stopped restart: unless-stopped
acme-companion: acme-companion:

View File

View File

@@ -0,0 +1,3 @@
-- Migration: Add expires_at column to items table for expiring uploads
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,2 @@
-- Fix items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;

12
package-lock.json generated
View File

@@ -75,9 +75,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.0", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -492,9 +492,9 @@
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==" "integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
}, },
"brace-expansion": { "brace-expansion": {
"version": "2.1.0", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"requires": { "requires": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }

View File

@@ -16,7 +16,8 @@
"build": "node scripts/build-css.mjs", "build": "node scripts/build-css.mjs",
"copy-vendor": "node scripts/copy-vendor.mjs", "copy-vendor": "node scripts/copy-vendor.mjs",
"seed": "node scripts/seed.mjs", "seed": "node scripts/seed.mjs",
"create-admin": "node scripts/create-admin.mjs" "create-admin": "node scripts/create-admin.mjs",
"import": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node scripts/import.mjs"
}, },
"author": "Kibi Kelburton", "author": "Kibi Kelburton",
"license": "MIT", "license": "MIT",

File diff suppressed because it is too large Load Diff

View File

@@ -362,6 +362,8 @@
} }
.upload-form.shitpost-mode-active .global-rating-section, .upload-form.shitpost-mode-active .global-rating-section,
.upload-form.shitpost-mode-active .global-visibility-section,
.upload-form.shitpost-mode-active .global-expiry-section,
.upload-form.shitpost-mode-active .global-comment-section, .upload-form.shitpost-mode-active .global-comment-section,
.upload-form.shitpost-mode-active .global-tag-section { .upload-form.shitpost-mode-active .global-tag-section {
display: none !important; display: none !important;
@@ -419,6 +421,26 @@
border-color: var(--badge-nsfl); 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 { .item-rating-label:hover {
@@ -662,6 +684,32 @@
opacity: 1; 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 */ /* Tags */
.tag-input-container { .tag-input-container {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);

View File

@@ -1,18 +1,24 @@
(async () => { (async () => {
// Helper to get dynamic context // Helper to get dynamic context
const getContext = () => { const getContext = () => {
const idLink = document.querySelector("a.id-link"); const commentsEl = document.querySelector("#comments-container");
if (!idLink) return null; 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 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"); const usernameEl = document.querySelector("a#a_username");
return { 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. // 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'. // Never fall back to innerText — it may be a display name or the literal string 'unknown'.
poster: (usernameEl?.dataset?.username || '').trim() || null, poster: (usernameEl?.dataset?.username || '').trim() || null,
authorId: (usernameEl?.dataset?.authorId || '').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)) : []
}; };
}; };
@@ -180,6 +186,9 @@
postid: postid postid: postid
}); });
if (res.success) { if (res.success) {
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
// New state is the logical opposite of what it was before the API call // New state is the logical opposite of what it was before the API call
const isNowFav = !wasAlreadyFav; const isNowFav = !wasAlreadyFav;

View File

@@ -468,9 +468,15 @@ class CommentSystem {
// 2. Check for duplicates (if we just posted it ourselves via optimistic insert). // 2. Check for duplicates (if we just posted it ourselves via optimistic insert).
// Even on early return, ensure the button is present if body was truncated. // Even on early return, ensure the button is present if body was truncated.
if (document.getElementById('c' + data.id)) { if (document.getElementById('c' + data.id)) {
const el = document.getElementById('c' + data.id);
if (el && data.banner_file && data.banner_file !== 'null') {
el.style.setProperty('--author-banner', `url('/a/${data.banner_file}')`);
el.style.setProperty('--author-banner-position', data.banner_position === 'center' ? 'center top' : (data.banner_position || 'center top'));
el.style.setProperty('--author-banner-size', (data.banner_size && data.banner_size !== 'cover') ? data.banner_size : '100% auto');
el.style.setProperty('--author-banner-repeat', 'no-repeat');
}
if (data.body && data.body.endsWith('\u2026')) { if (data.body && data.body.endsWith('\u2026')) {
console.log('[handleLiveComment] duplicate+truncated, ensuring button for', data.id); console.log('[handleLiveComment] duplicate+truncated, ensuring button for', data.id);
const el = document.getElementById('c' + data.id);
const contentEl = el?.querySelector('.comment-content'); const contentEl = el?.querySelector('.comment-content');
if (contentEl && !contentEl.querySelector('.load-full-comment-btn')) { if (contentEl && !contentEl.querySelector('.load-full-comment-btn')) {
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment'; const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
@@ -507,6 +513,10 @@ class CommentSystem {
avatar: data.avatar, avatar: data.avatar,
avatar_file: data.avatar_file, avatar_file: data.avatar_file,
username_color: data.username_color, username_color: data.username_color,
banner_file: data.banner_file || null,
banner_position: data.banner_position || null,
banner_size: data.banner_size || null,
banner_repeat: data.banner_repeat || null,
video_time: data.video_time ?? null, video_time: data.video_time ?? null,
is_new: true, is_new: true,
is_deleted: false, is_deleted: false,
@@ -593,7 +603,20 @@ class CommentSystem {
// Update in-memory data so future reconciles use the full content // Update in-memory data so future reconciles use the full content
if (this.lastData) { if (this.lastData) {
const cached = this.lastData.find(c => String(c.id) === String(commentId)); const cached = this.lastData.find(c => String(c.id) === String(commentId));
if (cached) cached.content = fullContent; if (cached) {
cached.content = fullContent;
if (json.comment.banner_file) cached.banner_file = json.comment.banner_file;
if (json.comment.banner_position) cached.banner_position = json.comment.banner_position;
if (json.comment.banner_size) cached.banner_size = json.comment.banner_size;
if (json.comment.banner_repeat) cached.banner_repeat = json.comment.banner_repeat;
}
}
if (json.comment && json.comment.banner_file) {
el.style.setProperty('--author-banner', `url('/a/${json.comment.banner_file}')`);
el.style.setProperty('--author-banner-position', json.comment.banner_position === 'center' ? 'center top' : (json.comment.banner_position || 'center top'));
el.style.setProperty('--author-banner-size', (json.comment.banner_size && json.comment.banner_size !== 'cover') ? json.comment.banner_size : '100% auto');
el.style.setProperty('--author-banner-repeat', 'no-repeat');
} }
contentEl.dataset.raw = fullContent; contentEl.dataset.raw = fullContent;
@@ -1039,10 +1062,6 @@ class CommentSystem {
preview.style.position = 'fixed'; preview.style.position = 'fixed';
preview.style.zIndex = (100000 + level).toString(); preview.style.zIndex = (100000 + level).toString();
// Try to place it to the right of the link, or top/bottom if needed
let left = rect.right + 10;
let top = rect.top;
document.body.appendChild(preview); document.body.appendChild(preview);
CommentSystem.playEmojiVideos(preview); CommentSystem.playEmojiVideos(preview);
@@ -1067,16 +1086,28 @@ class CommentSystem {
const previewRect = preview.getBoundingClientRect(); const previewRect = preview.getBoundingClientRect();
// Boundary checks // Default: position preview to the right of the link
if (left + previewRect.width > window.innerWidth) { let left = rect.right + 10;
let top = rect.top;
// If there's NOT enough space on the right, open to the left
if (left + previewRect.width > window.innerWidth - 10) {
left = rect.left - previewRect.width - 10; left = rect.left - previewRect.width - 10;
} }
if (left < 0) left = 10;
if (top + previewRect.height > window.innerHeight) { // Clamp to screen boundaries if left/right are both tight
if (left < 10) {
left = 10;
}
if (left + previewRect.width > window.innerWidth - 10) {
left = Math.max(10, window.innerWidth - previewRect.width - 10);
}
// Clamp top & bottom boundary
if (top + previewRect.height > window.innerHeight - 10) {
top = window.innerHeight - previewRect.height - 10; top = window.innerHeight - previewRect.height - 10;
} }
if (top < 0) top = 10; if (top < 10) top = 10;
preview.style.left = left + 'px'; preview.style.left = left + 'px';
preview.style.top = top + 'px'; preview.style.top = top + 'px';
@@ -1140,6 +1171,7 @@ class CommentSystem {
} }
quoteComment(id, openerEl, body) { quoteComment(id, openerEl, body) {
if (!window.f0ckSession || !window.f0ckSession.logged_in) return;
// If no reply input open anywhere, open the local one // If no reply input open anywhere, open the local one
let textarea = document.querySelector('.comment-input.reply-input textarea'); let textarea = document.querySelector('.comment-input.reply-input textarea');
let isNew = false; let isNew = false;
@@ -1501,6 +1533,13 @@ class CommentSystem {
el.classList.toggle('pinned', !!incoming.is_pinned); el.classList.toggle('pinned', !!incoming.is_pinned);
const pinIcon = el.querySelector('.pin-icon'); const pinIcon = el.querySelector('.pin-icon');
if (pinIcon) pinIcon.style.display = incoming.is_pinned ? 'block' : 'none'; if (pinIcon) pinIcon.style.display = incoming.is_pinned ? 'block' : 'none';
if (incoming && incoming.banner_file && incoming.banner_file !== 'null') {
el.style.setProperty('--author-banner', `url('/a/${incoming.banner_file}')`);
el.style.setProperty('--author-banner-position', incoming.banner_position === 'center' ? 'center top' : (incoming.banner_position || 'center top'));
el.style.setProperty('--author-banner-size', (incoming.banner_size && incoming.banner_size !== 'cover') ? incoming.banner_size : '100% auto');
el.style.setProperty('--author-banner-repeat', 'no-repeat');
}
} }
}); });
@@ -2169,7 +2208,15 @@ class CommentSystem {
} }
} }
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}"><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`; const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
const bannerStyle = (bannerEnabled && comment.banner_file && comment.banner_file !== 'null')
? `style="--author-banner: url('/a/${comment.banner_file}'); --author-banner-position: ${comment.banner_position === 'center' ? 'center top' : (comment.banner_position || 'center top')}; --author-banner-size: ${(comment.banner_size && comment.banner_size !== 'cover') ? comment.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
: '';
const authorUserId = comment.user_id ?? (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? (window.f0ckSession.id || window.f0ckSession.user_id) : null);
const authorUsernameColor = comment.username_color || (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? window.f0ckSession.username_color : null);
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
} }
timeAgo(date) { timeAgo(date) {
@@ -2405,10 +2452,50 @@ class CommentSystem {
} }
}, { passive: true }); }, { 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) => { document.addEventListener('click', (e) => {
const isLink = e.target.closest('.comment-context-link'); const target = e.target;
const isPopup = e.target.closest('.comment-preview-popup');
// 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',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
);
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) { if (!isLink && !isPopup) {
this.closePreviewsAboveLevel(-1); this.closePreviewsAboveLevel(-1);
@@ -2823,7 +2910,10 @@ class CommentSystem {
if (loadFullBtn) { if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content'); const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) { 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) { if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true); contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
// Append "see less" button after full content // Append "see less" button after full content
@@ -2831,6 +2921,7 @@ class CommentSystem {
contentEl.insertAdjacentHTML('beforeend', contentEl.insertAdjacentHTML('beforeend',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>` `<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
); );
CommentSystem.playEmojiVideos(contentEl);
} }
} }
return; return;
@@ -2841,9 +2932,13 @@ class CommentSystem {
if (collapseBtn) { if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content'); const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) { 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) { if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false); contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
CommentSystem.playEmojiVideos(contentEl);
} }
} }
return; return;
@@ -3164,6 +3259,9 @@ class CommentSystem {
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) { if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
const el = target.closest('.comment-permalink, .comment-time'); const el = target.closest('.comment-permalink, .comment-time');
if (el) { if (el) {
if (!window.f0ckSession || !window.f0ckSession.logged_in) {
return; // Let standard anchor link behavior scroll/jump to #c{id}
}
const id = el.dataset.id; const id = el.dataset.id;
const commentEl = el.closest('[id^="c"]'); const commentEl = el.closest('[id^="c"]');
const body = commentEl ? commentEl.querySelector('.comment-body') : null; const body = commentEl ? commentEl.querySelector('.comment-body') : null;
@@ -3442,6 +3540,50 @@ class CommentSystem {
} }
} }
let resolvedBannerFile = (json.comment?.banner_file && json.comment.banner_file !== 'null') ? json.comment.banner_file : ((session.banner_file && session.banner_file !== 'null') ? session.banner_file : null);
let resolvedBannerPosition = (json.comment?.banner_position && json.comment.banner_position !== 'null') ? json.comment.banner_position : ((session.banner_position && session.banner_position !== 'null') ? session.banner_position : null);
let resolvedBannerSize = (json.comment?.banner_size && json.comment.banner_size !== 'null') ? json.comment.banner_size : ((session.banner_size && session.banner_size !== 'null') ? session.banner_size : null);
let resolvedBannerRepeat = (json.comment?.banner_repeat && json.comment.banner_repeat !== 'null') ? json.comment.banner_repeat : ((session.banner_repeat && session.banner_repeat !== 'null') ? session.banner_repeat : null);
if (!resolvedBannerFile && this.lastData && currentUsername) {
const existingByMe = this.lastData.find(c =>
c.username && c.username.toLowerCase() === currentUsername.toLowerCase() && c.banner_file && c.banner_file !== 'null'
);
if (existingByMe) {
resolvedBannerFile = existingByMe.banner_file || null;
resolvedBannerPosition = existingByMe.banner_position || null;
resolvedBannerSize = existingByMe.banner_size || null;
resolvedBannerRepeat = existingByMe.banner_repeat || null;
}
}
if (!resolvedBannerFile && currentUsername) {
const existingCommentEl = document.querySelector(`.comment-author[href="/user/${currentUsername}"], .user-infobox-block[style*="--author-banner"]`);
if (existingCommentEl) {
const commentDiv = existingCommentEl.closest('.comment') || existingCommentEl.closest('.user-infobox-block');
if (commentDiv) {
const style = commentDiv.getAttribute('style') || '';
const bannerMatch = style.match(/--author-banner:\s*url\(['"]?\/a\/([^'"]+)['"]?\)/);
if (bannerMatch) {
resolvedBannerFile = bannerMatch[1];
const posMatch = style.match(/--author-banner-position:\s*([^;]+)/);
if (posMatch) resolvedBannerPosition = posMatch[1].trim();
const sizeMatch = style.match(/--author-banner-size:\s*([^;]+)/);
if (sizeMatch) resolvedBannerSize = sizeMatch[1].trim();
const repeatMatch = style.match(/--author-banner-repeat:\s*([^;]+)/);
if (repeatMatch) resolvedBannerRepeat = repeatMatch[1].trim();
}
}
}
}
if (resolvedBannerFile && window.f0ckSession) {
window.f0ckSession.banner_file = resolvedBannerFile;
if (resolvedBannerPosition) window.f0ckSession.banner_position = resolvedBannerPosition;
if (resolvedBannerSize) window.f0ckSession.banner_size = resolvedBannerSize;
if (resolvedBannerRepeat) window.f0ckSession.banner_repeat = resolvedBannerRepeat;
}
const newComment = { const newComment = {
id: json.comment.id, id: json.comment.id,
item_id: this.itemId, item_id: this.itemId,
@@ -3450,11 +3592,15 @@ class CommentSystem {
files: files, files: files,
created_at: json.comment.created_at || new Date().toISOString(), created_at: json.comment.created_at || new Date().toISOString(),
username: currentUsername, username: currentUsername,
user_id: session.id || null, user_id: (json.comment && json.comment.user_id) || session.id || session.user_id || null,
display_name: session.display_name || null, display_name: session.display_name || null,
avatar: resolvedAvatar, avatar: resolvedAvatar,
avatar_file: resolvedAvatarFile, avatar_file: resolvedAvatarFile,
username_color: session.username_color || null, username_color: (json.comment && json.comment.username_color) || session.username_color || null,
banner_file: resolvedBannerFile,
banner_position: resolvedBannerPosition,
banner_size: resolvedBannerSize,
banner_repeat: resolvedBannerRepeat,
is_deleted: false, is_deleted: false,
is_pinned: false, is_pinned: false,
video_time: json.comment.video_time ?? null, video_time: json.comment.video_time ?? null,
@@ -3481,7 +3627,7 @@ class CommentSystem {
if (!this.lastData) this.lastData = []; if (!this.lastData) this.lastData = [];
const existingInLastData = this.lastData.find(c => c.id === newComment.id); const existingInLastData = this.lastData.find(c => c.id === newComment.id);
if (existingInLastData) { if (existingInLastData) {
existingInLastData.files = files; Object.assign(existingInLastData, newComment);
} else { } else {
if (this.sort === 'new') this.lastData.unshift(newComment); if (this.sort === 'new') this.lastData.unshift(newComment);
else this.lastData.push(newComment); else this.lastData.push(newComment);
@@ -4707,3 +4853,44 @@ document.addEventListener('f0ck:contentLoaded', () => {
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent` // If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
// Looking at f0ck.js, it seems to just replace innerHTML. // Looking at f0ck.js, it seems to just replace innerHTML.
(function initCommentHeightObserver() {
if (typeof window === 'undefined') return;
if (window.ResizeObserver) {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const height = entry.contentRect ? entry.contentRect.height : entry.target.offsetHeight;
if (height > 90) {
entry.target.classList.add('tall-comment');
} else {
entry.target.classList.remove('tall-comment');
}
}
});
const observeAll = (root = document) => {
root.querySelectorAll('.comment').forEach(el => observer.observe(el));
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => observeAll());
} else {
observeAll();
}
if (window.MutationObserver) {
const mutObs = new MutationObserver(mutations => {
for (const m of mutations) {
m.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('comment')) observer.observe(node);
if (node.querySelectorAll) node.querySelectorAll('.comment').forEach(el => observer.observe(el));
}
});
}
});
mutObs.observe(document.body, { childList: true, subtree: true });
}
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -137,10 +137,13 @@
const statusDiv = document.getElementById('avatar-upload-status'); const statusDiv = document.getElementById('avatar-upload-status');
const preview = document.getElementById('avatar-preview'); const preview = document.getElementById('avatar-preview');
const showStatus = (msg, type) => { const showStatus = (msg, type = 'info') => {
if (msg && window.flashMessage) {
window.flashMessage(msg, 2500, type);
}
if (statusDiv) { if (statusDiv) {
statusDiv.textContent = msg; statusDiv.textContent = msg;
statusDiv.className = 'avatar-status ' + type; statusDiv.className = 'avatar-status ' + (type || '');
} }
}; };
@@ -177,6 +180,7 @@
filenameSpan.textContent = file.name; filenameSpan.textContent = file.name;
uploadBtn.disabled = false; uploadBtn.disabled = false;
showStatus('', ''); showStatus('', '');
uploadBtn.click();
}); });
} }
@@ -1181,6 +1185,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 // New Dual Column Layout Toggle
const layoutToggle = document.getElementById('use_new_layout_toggle'); const layoutToggle = document.getElementById('use_new_layout_toggle');
if (layoutToggle) { if (layoutToggle) {
@@ -1645,8 +1678,11 @@
// Two-way sync: swatch → text input // Two-way sync: swatch → text input
if (colorPicker && colorHex) { if (colorPicker && colorHex) {
const colorBadge = document.getElementById('color-picker-badge');
colorPicker.addEventListener('input', () => { colorPicker.addEventListener('input', () => {
colorHex.value = colorPicker.value; colorHex.value = colorPicker.value;
if (colorBadge) colorBadge.style.background = colorPicker.value;
const liveBox = document.getElementById('live-profile-preview-box'); const liveBox = document.getElementById('live-profile-preview-box');
if (liveBox) { if (liveBox) {
liveBox.style.setProperty('--author-accent', colorPicker.value); liveBox.style.setProperty('--author-accent', colorPicker.value);
@@ -1654,6 +1690,11 @@
} }
}); });
colorPicker.addEventListener('change', () => {
if (colorBadge) colorBadge.style.background = colorPicker.value;
if (saveColorBtn) saveColorBtn.click();
});
// Text input → swatch (validate on each keystroke) // Text input → swatch (validate on each keystroke)
colorHex.addEventListener('input', () => { colorHex.addEventListener('input', () => {
const val = colorHex.value.trim(); const val = colorHex.value.trim();
@@ -1823,7 +1864,9 @@
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
await applyFontLive(font); await applyFontLive(font);
showStatus('Website font updated!', 'success'); if (window.flashMessage) {
window.flashMessage('Website font updated!', 2500, 'success');
}
} else { } else {
alert(data.msg || 'Error saving font preference'); alert(data.msg || 'Error saving font preference');
} }
@@ -1839,6 +1882,35 @@
const saveDescriptionBtn = document.getElementById('btn-save-description'); const saveDescriptionBtn = document.getElementById('btn-save-description');
const descriptionStatus = document.getElementById('description-status'); const descriptionStatus = document.getElementById('description-status');
const liveDesc = document.getElementById('live-preview-description');
const descControls = document.getElementById('desc-edit-controls');
if (liveDesc && descriptionTextarea) {
liveDesc.addEventListener('focus', () => {
if (descControls) descControls.style.display = 'flex';
});
liveDesc.addEventListener('input', () => {
descriptionTextarea.value = liveDesc.innerText.trim();
});
liveDesc.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
liveDesc.blur();
if (saveDescriptionBtn) saveDescriptionBtn.click();
}
});
}
if (descriptionTextarea) {
descriptionTextarea.addEventListener('input', () => {
if (liveDesc && document.activeElement !== liveDesc) {
liveDesc.textContent = descriptionTextarea.value || '';
}
});
}
if (saveDescriptionBtn && descriptionTextarea) { if (saveDescriptionBtn && descriptionTextarea) {
saveDescriptionBtn.addEventListener('click', async () => { saveDescriptionBtn.addEventListener('click', async () => {
const description = descriptionTextarea.value; const description = descriptionTextarea.value;
@@ -1919,10 +1991,13 @@
const emailStatus = document.getElementById('email-status'); const emailStatus = document.getElementById('email-status');
const displayEmail = document.getElementById('display-email'); const displayEmail = document.getElementById('display-email');
const showAccountStatus = (el, msg, type) => { const showAccountStatus = (el, msg, type = 'info') => {
if (msg && window.flashMessage) {
window.flashMessage(msg, 2500, type);
}
if (el) { if (el) {
el.textContent = msg; el.textContent = msg;
el.className = 'avatar-status ' + type; el.className = 'avatar-status ' + (type || '');
if (type === 'success') { if (type === 'success') {
setTimeout(() => { el.textContent = ''; el.className = 'avatar-status'; }, 5000); setTimeout(() => { el.textContent = ''; el.className = 'avatar-status'; }, 5000);
} }
@@ -2008,11 +2083,36 @@
const displayNameBtn = document.getElementById('btn-update-display-name'); const displayNameBtn = document.getElementById('btn-update-display-name');
const displayNameInput = document.getElementById('display_name_input'); const displayNameInput = document.getElementById('display_name_input');
const displayNameStatus = document.getElementById('display-name-status'); const displayNameStatus = document.getElementById('display-name-status');
const liveUsernameEl = document.getElementById('live-preview-username');
if (liveUsernameEl && displayNameInput && displayNameBtn) {
liveUsernameEl.addEventListener('input', () => {
displayNameInput.value = liveUsernameEl.textContent.trim();
});
liveUsernameEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
liveUsernameEl.blur();
}
});
liveUsernameEl.addEventListener('blur', () => {
const val = liveUsernameEl.textContent.trim();
if (!val) {
liveUsernameEl.textContent = window.f0ckSession?.user || 'Username';
displayNameInput.value = window.f0ckSession?.user || '';
} else {
displayNameInput.value = val;
}
displayNameBtn.click();
});
}
if (displayNameBtn && displayNameInput) { if (displayNameBtn && displayNameInput) {
displayNameInput.addEventListener('input', () => { displayNameInput.addEventListener('input', () => {
const liveName = document.getElementById('live-preview-username'); if (liveUsernameEl && document.activeElement !== liveUsernameEl) {
if (liveName) { liveUsernameEl.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
liveName.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
} }
}); });

View File

@@ -479,8 +479,9 @@
}; };
const renderActivityItem = (c) => { const renderActivityItem = (c) => {
const itemKey = c.item_slug || c.slug || c.item_id;
const rawContent = c.content || c.body || ''; 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; displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
@@ -497,7 +498,7 @@
} }
const attachmentsHtml = renderCommentAttachments(c.files, rawContent); 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 // Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png'; let avatarSrc = '/a/default.png';
@@ -542,14 +543,27 @@
itemPreview = ` itemPreview = `
<div class="item-preview"> <div class="item-preview">
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a> <a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a> <a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
</div>`; </div>`;
} }
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
let bannerFile = c.banner_file;
let bannerPos = c.banner_position;
let bannerSz = c.banner_size;
if (!bannerFile && window.f0ckSession && window.f0ckSession.id && (c.user_id === window.f0ckSession.id || c.username === window.f0ckSession.user)) {
bannerFile = window.f0ckSession.banner_file;
bannerPos = window.f0ckSession.banner_position;
bannerSz = window.f0ckSession.banner_size;
}
const bannerStyle = (bannerEnabled && bannerFile && bannerFile !== 'null')
? `style="--author-banner: url('/a/${bannerFile}'); --author-banner-position: ${bannerPos === 'center' ? 'center top' : (bannerPos || 'center top')}; --author-banner-size: ${(bannerSz && bannerSz !== 'cover') ? bannerSz : '100% auto'}; --author-banner-repeat: no-repeat;"`
: '';
return ` return `
<div class="comment" id="sc${c.id}"> <div class="comment" id="sc${c.id}" ${bannerStyle}>
<div class="comment-body"> <div class="comment-body">
<div class="comment-header"> <div class="comment-header">
<div class="comment-header-left"> <div class="comment-header-left">

View File

@@ -168,11 +168,48 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`); const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
if (row) { row.classList.add('uut-job--fading'); setTimeout(() => row.remove(), 300); } if (row) { row.classList.add('uut-job--fading'); setTimeout(() => row.remove(), 300); }
setTimeout(() => { _active.delete(jobId); _sync(); }, 350); setTimeout(() => { _active.delete(jobId); _sync(); }, 350);
// Toast flash notification
if (typeof window.flashMessage === 'function') {
const msg = (window.f0ckI18n && window.f0ckI18n.notif_upload_success) || 'Your background upload is finished!';
window.flashMessage(msg, 3500, 'success');
}
// Refresh notification bell count and items
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
window.NotificationSystemInstance.pollDebounced();
}
const itemId = notif.item_id;
if (itemId && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
const grid = document.querySelector('.posts');
if (grid && !grid.querySelector(`a[href$="/${itemId}"]`)) {
fetch(`/api/v2/item/${itemId}`)
.then(r => r.json())
.then(res => {
if (res && res.success && res.rows) {
const item = res.rows;
window.NotificationSystemInstance.handleNewItem({
id: item.id,
dest: item.dest,
mime: item.mime,
username: item.username,
display_name: item.display_name || null,
tag_id: item.rating_tag_id || item.tag_id || 0,
is_oc: !!item.is_oc,
slug: item.slug,
visibility: item.visibility || 0
});
}
})
.catch(err => console.error('[UrlTracker] Failed to fetch item for live injection:', err));
}
}
} else { } else {
// Failure: update the row to show the error, keep until user closes // Failure: update the row to show the error, keep until user closes
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`); const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
if (row) {
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed'; const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
if (row) {
const stageEl = row.querySelector('.uut-stage'); const stageEl = row.querySelector('.uut-stage');
if (stageEl) { if (stageEl) {
if (notif.item_id) { if (notif.item_id) {
@@ -188,6 +225,13 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
if (barFill) { barFill.style.width = '100%'; barFill.style.background = '#ff5050'; barFill.style.backgroundImage = 'none'; barFill.style.animation = 'none'; } if (barFill) { barFill.style.width = '100%'; barFill.style.background = '#ff5050'; barFill.style.backgroundImage = 'none'; barFill.style.animation = 'none'; }
} }
_sync(); // update badge (count stays 0 for resolved jobs) _sync(); // update badge (count stays 0 for resolved jobs)
if (typeof window.flashMessage === 'function') {
window.flashMessage('✕ ' + errMsg, 4500, 'error');
}
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
window.NotificationSystemInstance.pollDebounced();
}
} }
}; };
@@ -908,7 +952,7 @@ window.initUploadForm = (selector) => {
} }
lines.forEach(url => { lines.forEach(url => {
if (!selectedFiles.some(item => item.type === 'url' && item.url === 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 = ''; urlInput.value = '';
@@ -931,7 +975,7 @@ window.initUploadForm = (selector) => {
const val = urlInput.value.trim(); const val = urlInput.value.trim();
if (!val || !/^https?:\/\//i.test(val)) return; if (!val || !/^https?:\/\//i.test(val)) return;
if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) { 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 = ''; urlInput.value = '';
if (urlBadge) urlBadge.style.display = 'none'; if (urlBadge) urlBadge.style.display = 'none';
@@ -1177,7 +1221,7 @@ window.initUploadForm = (selector) => {
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) { if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
if (isShitpost) { 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 { } else {
selectedFiles.push(file); // Legacy single file mode uses raw File selectedFiles.push(file); // Legacy single file mode uses raw File
} }
@@ -1490,6 +1534,8 @@ window.initUploadForm = (selector) => {
infoRow.className = 'file-meta-row-small'; infoRow.className = 'file-meta-row-small';
let ratingSwitch = ''; let ratingSwitch = '';
let visibilitySwitch = '';
let expirySwitch = '';
let tagsUI = ''; let tagsUI = '';
let ocUI = ''; let ocUI = '';
let commentUI = ''; let commentUI = '';
@@ -1517,6 +1563,56 @@ window.initUploadForm = (selector) => {
</div> </div>
`; `;
const hasVisSection = !!form.querySelector('.global-visibility-section');
if (hasVisSection) {
const globalVis = form.querySelector('input[name="visibility"]:checked')?.value || '0';
const visValue = (item.visibility !== undefined && item.visibility !== '') ? item.visibility : globalVis;
item.visibility = visValue;
visibilitySwitch = `
<div class="item-visibility-container item-rating-container" style="margin-top: 4px;">
<label class="item-rating-option">
<input type="radio" name="visibility_${index}" value="0" ${visValue === '0' || visValue === 0 ? 'checked' : ''}>
<span class="item-rating-label sfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-globe"></i> Public</span>
</label>
<label class="item-rating-option">
<input type="radio" name="visibility_${index}" value="1" ${visValue === '1' || visValue === 1 ? 'checked' : ''}>
<span class="item-rating-label nsfw" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-link"></i> Unlisted</span>
</label>
<label class="item-rating-option">
<input type="radio" name="visibility_${index}" value="2" ${visValue === '2' || visValue === 2 ? 'checked' : ''}>
<span class="item-rating-label nsfl" style="display: inline-flex; align-items: center; gap: 4px;"><i class="fa-solid fa-lock"></i> Private</span>
</label>
</div>
`;
} else {
visibilitySwitch = '';
}
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
const hasExpirySection = !!globalExpiryEl || window.f0ckEnableExpiringUploads !== false;
if (hasExpirySection && globalExpiryEl) {
const globalExpiry = globalExpiryEl.value || 'permanent';
const expiryValue = (item.expiry !== undefined && item.expiry !== '') ? item.expiry : globalExpiry;
item.expiry = expiryValue;
expirySwitch = `
<div class="item-expiry-container" style="margin-top: 4px;">
<select class="item-expiry-select" style="width: 100%; padding: 4px 8px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 4px; font-size: 0.85em; cursor: pointer;">
<option value="permanent" ${expiryValue === 'permanent' ? 'selected' : ''}>Permanent (Never expires)</option>
<option value="30minutes" ${expiryValue === '30minutes' ? 'selected' : ''}>30 Minutes</option>
<option value="1hour" ${expiryValue === '1hour' ? 'selected' : ''}>1 Hour</option>
<option value="24hours" ${expiryValue === '24hours' ? 'selected' : ''}>24 Hours</option>
<option value="1week" ${expiryValue === '1week' ? 'selected' : ''}>1 Week</option>
<option value="1month" ${expiryValue === '1month' ? 'selected' : ''}>1 Month</option>
</select>
</div>
`;
} else {
expirySwitch = '';
}
const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...'; const tagsPlaceholder = window.f0ckI18n?.upload_tags_placeholder || 'Tags...';
const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : ''; const minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
tagsUI = ` tagsUI = `
@@ -1560,19 +1656,37 @@ window.initUploadForm = (selector) => {
</div> </div>
${titleUI} ${titleUI}
${ratingSwitch} ${ratingSwitch}
${visibilitySwitch}
${expirySwitch}
${tagsUI} ${tagsUI}
${commentUI} ${commentUI}
`; `;
if (isShitpost) { if (isShitpost) {
// Handle Rating // Handle Rating
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => { infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
radio.onchange = () => { radio.onchange = () => {
item.rating = radio.value; item.rating = radio.value;
updateSubmitButton(); updateSubmitButton();
}; };
}); });
// Handle Visibility
infoRow.querySelectorAll('.item-visibility-container input').forEach(radio => {
radio.onchange = () => {
item.visibility = radio.value;
};
});
// Handle Expiry
const expirySelect = infoRow.querySelector('.item-expiry-select');
if (expirySelect) {
expirySelect.onchange = () => {
item.expiry = expirySelect.value;
};
}
// Handle Comment // Handle Comment
const commentInput = infoRow.querySelector('.item-comment-input'); const commentInput = infoRow.querySelector('.item-comment-input');
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger'); const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
@@ -2436,6 +2550,11 @@ window.initUploadForm = (selector) => {
} }
try { try {
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
const visibilityVal = globalVisEl ? globalVisEl.value : '0';
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
const expiryVal = globalExpiryEl ? globalExpiryEl.value : 'permanent';
const resp = await fetch('/api/v2/upload-url', { const resp = await fetch('/api/v2/upload-url', {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -2445,7 +2564,9 @@ window.initUploadForm = (selector) => {
}, },
body: JSON.stringify({ body: JSON.stringify({
url, url,
rating: globalRatingEl.value, rating: globalRatingEl ? globalRatingEl.value : 'sfw',
visibility: visibilityVal,
expiry: expiryVal,
tags: tags.join(','), tags: tags.join(','),
comment: comment, comment: comment,
is_oc: isOc, is_oc: isOc,
@@ -2552,9 +2673,13 @@ window.initUploadForm = (selector) => {
for (let i = 0; i < selectedFiles.length; i++) { for (let i = 0; i < selectedFiles.length; i++) {
const item = selectedFiles[i]; const item = selectedFiles[i];
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
const isUrlItem = isShitpost && item.type === 'url'; const isUrlItem = isShitpost && item.type === 'url';
const file = !isUrlItem ? (isShitpost ? item.file : item) : null; const file = !isUrlItem ? (isShitpost ? item.file : item) : null;
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw'); const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
const fileVisibility = isShitpost ? (item.visibility || globalVisEl?.value || '0') : (globalVisEl?.value || '0');
const fileExpiry = isShitpost ? (item.expiry || globalExpiryEl?.value || 'permanent') : (globalExpiryEl?.value || 'permanent');
const fileTags = isShitpost ? item.tags : tags; const fileTags = isShitpost ? item.tags : tags;
const fileComment = isShitpost ? item.comment : comment; const fileComment = isShitpost ? item.comment : comment;
const fileTitle = isShitpost ? (item.title || '') : titleVal; const fileTitle = isShitpost ? (item.title || '') : titleVal;
@@ -2571,6 +2696,8 @@ window.initUploadForm = (selector) => {
formData.append('file', file); formData.append('file', file);
} }
formData.append('rating', fileRating); formData.append('rating', fileRating);
formData.append('visibility', fileVisibility);
formData.append('expiry', fileExpiry);
formData.append('tags', fileTags.join(',')); formData.append('tags', fileTags.join(','));
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false'); formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
if (isShitpost) formData.append('is_shitpost', 'true'); if (isShitpost) formData.append('is_shitpost', 'true');
@@ -2622,6 +2749,8 @@ window.initUploadForm = (selector) => {
xhr.send(JSON.stringify({ xhr.send(JSON.stringify({
url: item.url, url: item.url,
rating: fileRating, rating: fileRating,
visibility: fileVisibility,
expiry: fileExpiry,
tags: fileTags.join(','), tags: fileTags.join(','),
is_oc: (isShitpost ? item.is_oc : isOc), is_oc: (isShitpost ? item.is_oc : isOc),
comment: fileComment, comment: fileComment,
@@ -2717,40 +2846,15 @@ window.initUploadForm = (selector) => {
} }
} }
// Helper: inject the new item into the grid (deduplication is handled inside handleNewItem) // URL background uploads do not redirect; standard file uploads redirect back to main page ('/')
const injectNewItem = () => { const isBgUrlUpload = lastData?.pending && selectedFiles.every(i => i.type === 'url');
if (lastData && !lastData.manual_approval && !lastData.pending && if (isBgUrlUpload) {
lastData.itemid && lastData.dest && lastData.mime && if (typeof window.flashMessage === 'function') {
window.NotificationSystemInstance && window.flashMessage((window.f0ckI18n && window.f0ckI18n.url_upload_started) || 'Background upload started', 3000, 'info');
typeof window.NotificationSystemInstance.handleNewItem === 'function') {
window.NotificationSystemInstance.handleNewItem({
id: lastData.itemid,
dest: lastData.dest,
mime: lastData.mime,
username: lastData.username || window.f0ckSession?.user || '',
display_name: lastData.display_name || window.f0ckSession?.display_name || null,
tag_id: lastData.tag_id ?? 0,
is_oc: !!lastData.is_oc
});
} }
};
// 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.
if (typeof window.loadPageAjax === 'function') {
await window.loadPageAjax('/', true, { bypassCache: true });
injectNewItem();
} else { } else {
window.location.href = '/'; window.location.href = '/';
} }
}
} else { } else {
restoreBtn(); restoreBtn();
if (progressContainer) progressContainer.style.display = 'none'; if (progressContainer) progressContainer.style.display = 'none';

View File

@@ -1,14 +1,20 @@
(async () => { (async () => {
// Helper to get dynamic context from the DOM // Helper to get dynamic context from the DOM
const getContext = () => { const getContext = () => {
const idLink = document.querySelector("a.id-link"); const commentsEl = document.querySelector("#comments-container");
if (!idLink) return null; 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 tagsContainer = document.querySelector("#tags");
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer; const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
return { return {
postid: +idLink.innerText, postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
poster: document.querySelector("a#a_username")?.innerText, 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)) : []
}; };
}; };
@@ -169,6 +175,9 @@
postid: postid postid: postid
}); });
if (res.success) { if (res.success) {
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
// New state is the logical opposite of what it was before the API call // New state is the logical opposite of what it was before the API call
const isNowFav = !wasAlreadyFav; const isNowFav = !wasAlreadyFav;

View File

@@ -410,11 +410,16 @@ if (!window.UserCommentSystem) {
} }
renderComment(c) { renderComment(c) {
const itemKey = c.item_slug || c.slug || c.item_id;
const timeAgo = this.timeAgo(c.created_at); const timeAgo = this.timeAgo(c.created_at);
const fullDate = new Date(c.created_at).toISOString(); 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 `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`; const bannerStyle = (c.banner_file && c.banner_file !== 'null')
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
: '';
return `<div class="comment" id="c${c.id}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
} }
startLiveTimestamps() { startLiveTimestamps() {

529
scripts/import.mjs Normal file
View File

@@ -0,0 +1,529 @@
import fs from "fs";
import path from "path";
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
import cfg from "../src/inc/config.mjs";
import queue from "../src/inc/queue.mjs";
import f0cklib from "../src/inc/routeinc/f0cklib.mjs";
import { getBypassDuplicateCheck } from "../src/inc/settings.mjs";
// Helper for parsing CLI flags
function parseArgs() {
const args = process.argv.slice(2);
const options = {
user: null,
rating: null,
tags: [],
dir: cfg.paths.import || path.resolve("f0ckm-data/import"),
keep: false,
bypass: null, // null means use config default, true = force bypass, false = force check
is_oc: false,
title: null,
help: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--user' || arg === '-u') {
options.user = args[++i];
} else if (arg === '--rating' || arg === '-r') {
options.rating = args[++i]?.toLowerCase();
} else if (arg === '--tags' || arg === '-t') {
const rawTags = args[++i];
if (rawTags) {
options.tags = rawTags.split(',').map(t => t.trim()).filter(Boolean);
}
} else if (arg === '--dir' || arg === '-d') {
options.dir = path.resolve(args[++i]);
} else if (arg === '--keep' || arg === '-k') {
options.keep = true;
} else if (arg === '--bypass' || arg === '-b') {
options.bypass = true;
} else if (arg === '--check') {
options.bypass = false;
} else if (arg === '--oc') {
options.is_oc = true;
} else if (arg === '--title') {
options.title = args[++i];
}
}
return options;
}
function showHelp() {
console.log(`
f0ckm Bulk Import Script
========================
Imports all media files in f0ckm-data/import (or custom directory) into f0ckm.
Usage:
node scripts/import.mjs [options]
Options:
--user, -u <username> User to attribute uploads to (defaults to first admin)
--rating, -r <sfw|nsfw|nsfl|untagged> Default rating for imported items
--tags, -t <tag1,tag2> Global tags to assign to imported items
--dir, -d <path> Directory to scan (default: f0ckm-data/import)
--keep, -k Keep source files in import dir after import (default: delete)
--bypass, -b Force bypass of duplicate checksum/pHash checks
--check Force duplicate checking even if config enables bypass
--oc Mark all imported files as Original Content (OC)
--title <title> Set title for imported files
--help, -h Show this help screen
Folder & Sidecar JSON Features:
- Rating Subfolders: Files in 'import/sfw/', 'import/nsfw/', or 'import/nsfl/' automatically receive that rating.
- Sidecar Metadata: Place a file.jpg.json or file.json alongside file.jpg to supply metadata:
{ "title": "My Post", "rating": "sfw", "tags": ["funny", "cat"], "comment": "First comment", "is_oc": true }
`);
}
// Derive archive MIME types from cfg.mimes
const ARCHIVE_MIMES = new Set(
Object.entries(cfg.mimes || {})
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
.map(([mime]) => mime)
);
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
// Recursively collect all target files in directory (follows symlinked dirs and files)
async function collectFiles(dir, visitedDirs = new Set()) {
const results = [];
let canonicalDir;
try {
canonicalDir = await fs.promises.realpath(dir);
} catch (_) {
canonicalDir = dir;
}
if (visitedDirs.has(canonicalDir)) {
return results; // Avoid infinite loops on circular symlink references
}
visitedDirs.add(canonicalDir);
let entries;
try {
entries = await fs.promises.readdir(dir);
} catch (e) {
console.warn(`[IMPORT] Warning: unable to read directory '${dir}':`, e.message);
return results;
}
for (const name of entries) {
if (name.startsWith('.')) continue; // skip hidden files & .gitkeep
const fullPath = path.join(dir, name);
try {
// fs.promises.stat follows symlinks to inspect the underlying file/dir
const st = await fs.promises.stat(fullPath);
if (st.isDirectory()) {
const subResults = await collectFiles(fullPath, visitedDirs);
results.push(...subResults);
} else if (st.isFile()) {
if (name.endsWith('.json')) continue; // skip sidecar metadata files
results.push(fullPath);
}
} catch (err) {
console.warn(`[IMPORT] Warning: unable to stat '${fullPath}' (broken symlink?):`, err.message);
}
}
return results;
}
// Read optional sidecar JSON metadata
async function readSidecarMetadata(filePath) {
const candidates = [
`${filePath}.json`,
path.join(path.dirname(filePath), `${path.parse(filePath).name}.json`)
];
for (const cand of candidates) {
try {
if (fs.existsSync(cand)) {
const raw = await fs.promises.readFile(cand, 'utf-8');
return { meta: JSON.parse(raw), sidecarPath: cand };
}
} catch (e) {
console.warn(`[IMPORT] Warning: failed to parse sidecar JSON at ${cand}:`, e.message);
}
}
return { meta: {}, sidecarPath: null };
}
async function runImport() {
const opts = parseArgs();
if (opts.help) {
showHelp();
process.exit(0);
}
console.log(`--- f0ckm Bulk Import Starting ---`);
console.log(`Scan Directory: ${opts.dir}`);
if (!fs.existsSync(opts.dir)) {
console.error(`[IMPORT ERROR] Directory does not exist: ${opts.dir}`);
process.exit(1);
}
// 1. Resolve Target User
let targetUser = null;
if (opts.user) {
const userRows = await db`
SELECT id, "user", login, admin, is_moderator
FROM "user"
WHERE LOWER(login) = ${opts.user.toLowerCase()} OR LOWER("user") = ${opts.user.toLowerCase()}
LIMIT 1
`;
if (userRows.length > 0) {
targetUser = userRows[0];
} else {
console.error(`[IMPORT ERROR] Specified user '${opts.user}' not found in database.`);
process.exit(1);
}
} else {
const adminRows = await db`
SELECT id, "user", login, admin, is_moderator
FROM "user"
WHERE admin = true
ORDER BY id ASC
LIMIT 1
`;
if (adminRows.length > 0) {
targetUser = adminRows[0];
} else {
const anyUser = await db`SELECT id, "user", login, admin, is_moderator FROM "user" ORDER BY id ASC LIMIT 1`;
if (anyUser.length > 0) {
targetUser = anyUser[0];
} else {
console.error(`[IMPORT ERROR] No users found in database. Please run npm run create-admin first.`);
process.exit(1);
}
}
}
console.log(`Attributing uploads to user: ${targetUser.user} (ID: ${targetUser.id})`);
// Ensure target directories exist
await fs.promises.mkdir(cfg.paths.b, { recursive: true });
await fs.promises.mkdir(cfg.paths.t, { recursive: true });
await fs.promises.mkdir(cfg.paths.ca, { recursive: true });
// 2. Discover files
const fileList = await collectFiles(opts.dir);
if (fileList.length === 0) {
console.log(`[IMPORT] No files found in ${opts.dir}. Place files in '${opts.dir}' and re-run.`);
process.exit(0);
}
console.log(`Found ${fileList.length} file(s) to process.\n`);
const allowedCats = Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.map(c => c.toLowerCase()) : null;
const allowedMimes = allowedCats
? Object.keys(cfg.mimes).filter(m => allowedCats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)))
: Object.keys(cfg.mimes);
let successCount = 0;
let skippedCount = 0;
let failedCount = 0;
// 3. Process each file sequentially
for (let index = 0; index < fileList.length; index++) {
const filePath = fileList[index];
const baseName = path.basename(filePath);
const relPath = path.relative(opts.dir, filePath);
const prefix = `[${index + 1}/${fileList.length}]`;
try {
// Check sidecar JSON metadata
const { meta: sidecarMeta, sidecarPath } = await readSidecarMetadata(filePath);
// Determine Rating: Sidecar -> CLI option -> Folder name (import/sfw/ etc.) -> Default
let effectiveRating = sidecarMeta.rating || opts.rating;
if (!effectiveRating) {
const parentDirName = path.basename(path.dirname(filePath)).toLowerCase();
if (['sfw', 'nsfw', 'nsfl', 'untagged'].includes(parentDirName)) {
effectiveRating = parentDirName;
}
}
if (effectiveRating === 'untagged') effectiveRating = null;
if (!effectiveRating && !cfg.websrv.shitpost_mode) {
effectiveRating = 'sfw'; // Default fallback if rating required
}
// Determine Title & Comments & OC
const effectiveTitle = sidecarMeta.title || opts.title || null;
const effectiveComment = sidecarMeta.comment || null;
const effectiveIsOc = (sidecarMeta.is_oc !== undefined) ? !!sidecarMeta.is_oc : opts.is_oc;
const effectiveVisibility = (typeof sidecarMeta.visibility === 'number') ? sidecarMeta.visibility : 0;
// Collect tags
let mergedTags = [...opts.tags];
if (Array.isArray(sidecarMeta.tags)) {
mergedTags.push(...sidecarMeta.tags);
} else if (typeof sidecarMeta.tags === 'string') {
mergedTags.push(...sidecarMeta.tags.split(',').map(t => t.trim()));
}
mergedTags = mergedTags.filter(t => t && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
// Verify file stats & MIME
const stat = await fs.promises.stat(filePath);
if (stat.size === 0) {
console.warn(`${prefix} [SKIP] ${relPath} (0 bytes)`);
skippedCount++;
continue;
}
// Detect actual MIME via file command (with -L to follow symlinks)
let actualMime = (await queue.spawn('file', ['-L', '--mime-type', '-b', filePath])).stdout.trim();
if ((actualMime === 'application/octet-stream' || !actualMime) && baseName.toLowerCase().endsWith('.swf')) {
actualMime = 'application/x-shockwave-flash';
}
if (!allowedMimes.includes(actualMime)) {
console.warn(`${prefix} [SKIP] ${relPath} - Unsupported MIME type: '${actualMime}'`);
skippedCount++;
continue;
}
// Phase A: SHA-256, PHash, Dimensions, Audio-MP4 probe
const needsPHash = !isArchiveMime(actualMime);
const needsDims = !isArchiveMime(actualMime);
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
const [checksum, phash, dimResult, audioMp4Result] = await Promise.all([
queue.spawn('sha256sum', [filePath]).then(r => r.stdout.trim().split(' ')[0]),
needsPHash ? queue.generatePHash(filePath).catch(e => { console.error(`[IMPORT] PHash error:`, e.message); return null; }) : Promise.resolve(null),
needsDims ? (async () => {
try {
if (actualMime.startsWith('image/')) {
const { stdout: magickOut } = await queue.spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { quiet: true, ignoreExitCode: true });
const match = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
const { stdout: probeOut } = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath], { quiet: true, ignoreExitCode: true });
const dimParts = probeOut.trim().split(',');
if (dimParts.length >= 2) {
const w = parseInt(dimParts[0], 10);
const h = parseInt(dimParts[1], 10);
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
}
}
} catch (_) {}
return null;
})() : Promise.resolve(null),
needsAudioMP4 ? (async () => {
const ext = baseName.split('.').pop().toLowerCase();
if (['m4a', 'aac'].includes(ext)) return 'audio/mp4';
try {
const probe = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath]);
if (!probe.stdout.trim()) return 'audio/mp4';
} catch (_) {}
return null;
})() : Promise.resolve(null)
]);
if (audioMp4Result) {
actualMime = audioMp4Result;
}
// Phase B: Duplicate check
const bypassDupes = (opts.bypass !== null) ? opts.bypass : (getBypassDuplicateCheck() || cfg.websrv.bypass_duplicate_check === true);
if (!bypassDupes) {
const [repostBySum, repostByPhash] = await Promise.all([
queue.checkrepostsum(checksum),
phash ? queue.checkrepostphash(phash) : Promise.resolve(null)
]);
if (repostBySum) {
console.warn(`${prefix} [SKIP] ${relPath} - Duplicate file (matches existing Item #${repostBySum})`);
skippedCount++;
continue;
}
if (repostByPhash) {
console.warn(`${prefix} [SKIP] ${relPath} - Visual duplicate (matches existing Item #${repostByPhash})`);
skippedCount++;
continue;
}
}
// Generate target filename & UUID
const uuid = await queue.genuuid();
const ext = cfg.mimes[actualMime] || 'bin';
const filename = `${uuid}.${ext}`;
const destPath = path.join(cfg.paths.b, filename);
const nowStamp = ~~(Date.now() / 1000);
const itemSlug = lib.generateSlug(11);
// Copy file to public/b
await fs.promises.copyFile(filePath, destPath);
const insertChecksum = bypassDupes ? `${checksum}_bypass_${Date.now()}` : checksum;
// Insert into items table
await db`
INSERT INTO items ${db({
src: '',
dest: filename,
mime: actualMime,
size: stat.size,
checksum: insertChecksum,
phash: phash,
username: targetUser.user,
userchannel: 'import_script',
usernetwork: 'local',
stamp: nowStamp,
active: true,
is_oc: effectiveIsOc,
original_filename: baseName,
title: effectiveTitle,
width: dimResult?.width ?? null,
height: dimResult?.height ?? null,
visibility: effectiveVisibility,
slug: itemSlug,
expires_at: null
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
`;
const itemid = await queue.getItemID(filename);
// Subscribe uploader to comments
try {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${targetUser.id}, ${itemid})
ON CONFLICT DO NOTHING
`;
} catch (_) {}
// Generate thumbnails
try {
await queue.genThumbnail(filename, actualMime, itemid, '', false, 512);
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
}
} catch (err) {
console.warn(`${prefix} [WARN] Thumbnail generation warning for #${itemid}:`, err.message);
}
// Blurred thumbnail
await queue.genBlurredThumbnail(itemid, false);
// Assign Rating Tag
if (effectiveRating) {
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: targetUser.id })}
`;
}
// Assign Custom Tags
for (const tagName of mergedTags) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
}
const tagId = tagRow[0].id;
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: targetUser.id })}
ON CONFLICT DO NOTHING
`;
}
// Auto OC tags
if (effectiveIsOc) {
for (const tagname of ['oc', 'original content']) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
}
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
ON CONFLICT DO NOTHING
`;
}
}
// Auto Flash tags
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
for (const tagname of ['Flash', 'SWF']) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
}
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
ON CONFLICT DO NOTHING
`;
}
}
// Optional first comment
if (effectiveComment && effectiveComment.trim().length > 0) {
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: targetUser.id,
content: effectiveComment.trim()
})}
`;
}
// PostgreSQL Live Notification
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: actualMime,
username: targetUser.user,
display_name: targetUser.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!effectiveIsOc
})})`;
} catch (_) {}
// Clear cache
f0cklib.clearCountCache();
// Cleanup imported source file unless --keep was passed
if (!opts.keep) {
await fs.promises.unlink(filePath).catch(() => {});
if (sidecarPath) {
await fs.promises.unlink(sidecarPath).catch(() => {});
}
}
console.log(`${prefix} [SUCCESS] Imported '${relPath}' -> Item #${itemid} (Rating: ${effectiveRating || 'untagged'}, MIME: ${actualMime})`);
successCount++;
} catch (err) {
console.error(`${prefix} [ERROR] Failed to import '${relPath}':`, err.message);
failedCount++;
}
}
console.log(`\n========================================`);
console.log(`Import Complete!`);
console.log(`Total Scanned : ${fileList.length}`);
console.log(`Successful : ${successCount}`);
console.log(`Skipped : ${skippedCount}`);
console.log(`Failed : ${failedCount}`);
console.log(`========================================`);
process.exit(0);
}
runImport().catch(err => {
console.error(`[IMPORT FATAL ERROR]`, err);
process.exit(1);
});

View File

@@ -190,15 +190,20 @@ export const handleBannerUpload = async (req, res) => {
update user_options update user_options
set banner_file = ${finalFilename}, set banner_file = ${finalFilename},
banner_position = ${parts.banner_position || 'center'}, banner_position = ${parts.banner_position || 'center'},
banner_size = ${parts.banner_size || 'cover'} banner_size = ${parts.banner_size || 'cover'},
banner_repeat = ${parts.banner_repeat || 'repeat'}
where user_id = ${+req.session.id} where user_id = ${+req.session.id}
`; `;
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
global._invalidateSessionCache(lib.sha256(req.cookies.session));
}
const payloadStr = JSON.stringify({ const payloadStr = JSON.stringify({
user_id: +req.session.id, user_id: +req.session.id,
user: req.session.user, user: req.session.user,
banner_file: finalFilename, banner_file: finalFilename,
banner_position: parts.banner_position || 'center', banner_position: parts.banner_position || 'center',
banner_size: parts.banner_size || 'cover' banner_size: parts.banner_size || 'cover',
banner_repeat: parts.banner_repeat || 'repeat'
}); });
await db`select pg_notify('profile_update', ${payloadStr})`; await db`select pg_notify('profile_update', ${payloadStr})`;
} catch (dbErr) { } catch (dbErr) {
@@ -273,6 +278,9 @@ export const handleBannerDelete = async (req, res) => {
set banner_file = null set banner_file = null
where user_id = ${+req.session.id} where user_id = ${+req.session.id}
`; `;
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
global._invalidateSessionCache(lib.sha256(req.cookies.session));
}
const payloadStr = JSON.stringify({ const payloadStr = JSON.stringify({
user_id: +req.session.id, user_id: +req.session.id,
user: req.session.user, user: req.session.user,

View File

@@ -12,10 +12,23 @@ config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.en
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password; config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database; config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
if (process.env.NODE_ENV === 'production') { const isDocker = process.env.DB_HOST === 'f0ckm-db' || config.sql.host === 'f0ckm-db' || process.env.CONTAINER === 'true';
if (process.env.NODE_ENV === 'production' || isDocker) {
config.main.development = false; config.main.development = false;
} }
if (config.main.development) {
if (!process.env.DB_HOST && (config.sql.host === 'f0ckm-db' || !config.sql.host)) {
config.sql.host = 'localhost';
}
if (!process.env.DB_PORT && config.sql.port === 5432) {
config.sql.port = 5454;
}
if (!process.env.STORAGE_DIR) {
process.env.STORAGE_DIR = 'f0ckm-data';
}
}
// Set timezone from config if not already set via environment variable // Set timezone from config if not already set via environment variable
if (!process.env.TZ && config.main.timezone) { if (!process.env.TZ && config.main.timezone) {
process.env.TZ = config.main.timezone; process.env.TZ = config.main.timezone;
@@ -53,11 +66,12 @@ config.paths = {
koepfe: resolvePath('public/s/koepfe'), koepfe: resolvePath('public/s/koepfe'),
fonts: resolvePath('public/s/fonts'), fonts: resolvePath('public/s/fonts'),
memes: resolvePath('public/memes'), memes: resolvePath('public/memes'),
e: resolvePath('e'), e: resolvePath('public/e'),
pending: resolvePath('pending'), pending: resolvePath('pending'),
deleted: resolvePath('deleted'), deleted: resolvePath('deleted'),
logs: resolvePath('logs'), logs: resolvePath('logs'),
tmp: resolvePath('tmp') tmp: resolvePath('tmp'),
import: storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'))
}; };
export default config; export default config;

View File

@@ -38,6 +38,16 @@ export default new class {
.replace(/'/g, "&#039;"); .replace(/'/g, "&#039;");
} }
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))) { formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i]; return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
}; };
@@ -53,6 +63,29 @@ export default new class {
const timeStr = t(unitKey, { n: interval }); const timeStr = t(unitKey, { n: interval });
return t('timeago.ago', { t: timeStr }); return t('timeago.ago', { t: timeStr });
}; };
expiresIn(expiresAt) {
if (!expiresAt) return null;
const expiresNum = parseInt(expiresAt, 10);
if (isNaN(expiresNum)) return null;
const now = ~~(Date.now() / 1000);
const diff = expiresNum - now;
if (diff <= 0) return "expiring now";
if (diff < 60) return `in ${diff}s`;
if (diff < 3600) {
const mins = Math.floor(diff / 60);
return `in ${mins} minute${mins === 1 ? '' : 's'}`;
}
if (diff < 86400) {
const hours = Math.floor(diff / 3600);
const mins = Math.floor((diff % 3600) / 60);
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
}
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
};
md5(str) { md5(str) {
return crypto.createHash('md5').update(str).digest("hex"); return crypto.createHash('md5').update(str).digest("hex");
}; };
@@ -62,21 +95,22 @@ export default new class {
getMode(mode) { getMode(mode) {
let tmp; let tmp;
mode = Number(mode); mode = Number(mode);
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
switch (mode) { switch (mode) {
case 1: // nsfw case 1: // nsfw
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)"; tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
break; break;
case 2: // untagged case 2: // untagged
tmp = "not exists (select 1 from tags_assign where item_id = items.id)"; tmp = `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
break; break;
case 3: // all case 3: // all
tmp = "1 = 1"; tmp = "1 = 1";
break; break;
case 4: // nsfl case 4: // nsfl
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0"; tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${nsflId})` : "1 = 0";
break; break;
default: // sfw default: // sfw
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)"; tmp = `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`;
break; break;
} }
return tmp; return tmp;
@@ -107,7 +141,8 @@ export default new class {
parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`); parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`);
} }
if (filtered.includes('untagged')) { if (filtered.includes('untagged')) {
parts.push('not exists (select 1 from tags_assign where item_id = items.id)'); const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
parts.push(`not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`);
} }
if (parts.length === 0) return null; if (parts.length === 0) return null;
return '(' + parts.join(' OR ') + ')'; return '(' + parts.join(' OR ') + ')';
@@ -167,27 +202,28 @@ export default new class {
const tagged = +(await db` const tagged = +(await db`
select count(*) as total select count(*) as total
from "items" from "items"
where id in (select item_id from tags_assign group by item_id) and active = true where id in (select item_id from tags_assign group by item_id) and active = true and is_deleted = false
`)[0].total; `)[0].total;
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const untagged = +(await db` const untagged = +(await db`
select count(*) as total select count(*) as total
from "items" from "items"
where not exists (select 1 from tags_assign where item_id = items.id) and active = true where not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})) and active = true and is_deleted = false
`)[0].total; `)[0].total;
const sfw = +(await db` const sfw = +(await db`
select count(*) as total select count(*) as total
from "items" from "items"
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true and is_deleted = false
`)[0].total; `)[0].total;
const nsfw = +(await db` const nsfw = +(await db`
select count(*) as total select count(*) as total
from "items" from "items"
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true and is_deleted = false
`)[0].total; `)[0].total;
const nsfl = cfg.enable_nsfl ? +(await db` const nsfl = cfg.enable_nsfl ? +(await db`
select count(*) as total select count(*) as total
from "items" from "items"
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true and is_deleted = false
`)[0].total : 0; `)[0].total : 0;
const deleted = +(await db` const deleted = +(await db`
select count(*) as total select count(*) as total
@@ -246,18 +282,22 @@ export default new class {
return false; return false;
}; };
async getTags(itemid) { async getTags(itemid, session = null) {
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
const tags = await db` const tags = await db`
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "tags_assign" from "tags_assign"
left join "tags" on "tags".id = "tags_assign".tag_id left join "tags" on "tags".id = "tags_assign".tag_id
left join "user" on "user".id = "tags_assign".user_id ${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
left join user_options uo on uo.user_id = "user".id
where "tags_assign".item_id = ${+itemid} where "tags_assign".item_id = ${+itemid}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
`; `;
for (let t = 0; t < tags.length; t++) { for (let t = 0; t < tags.length; t++) {
tags[t].badge = this.getBadge(tags[t]); tags[t].badge = this.getBadge(tags[t]);
if (!hasSession) {
delete tags[t].user;
delete tags[t].display_name;
}
} }
return tags; return tags;
}; };

View File

@@ -204,3 +204,46 @@ export async function moveToDeleted(dest, deletedId) {
await fs.unlink(srcPath).catch(() => {}); await fs.unlink(srcPath).catch(() => {});
} }
} }
/**
* Periodically scan for and purge expired uploads (expires_at <= now).
* Unlinks media files (thumbnails, coverarts, main image) via safeDeleteMediaFile,
* and sets is_deleted = true, is_purged = true, active = false in DB.
*/
export async function purgeExpiredUploads() {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return;
}
try {
const now = ~~(Date.now() / 1000);
const expiredItems = await db`
SELECT id, dest, mime
FROM items
WHERE expires_at IS NOT NULL
AND expires_at <= ${now}
AND is_purged = false
`;
if (expiredItems.length > 0) {
console.log(`[EXPIRING UPLOADS] Found ${expiredItems.length} expired item(s) to purge.`);
for (const item of expiredItems) {
try {
if (item.dest) {
await safeDeleteMediaFile(item.dest, item.id);
}
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
if (item.mime && item.mime.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => {});
}
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
} catch (e) {
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
}
}
}
} catch (err) {
console.error('[EXPIRING UPLOADS] Failed running purgeExpiredUploads check:', err);
}
}

View File

@@ -169,6 +169,8 @@
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.", "favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen", "hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an", "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": "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.", "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", "enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -799,6 +801,12 @@
"delete_confirm": "Diesen Einladungstoken löschen?", "delete_confirm": "Diesen Einladungstoken löschen?",
"slot_refreshes_on": "Slot erneuert sich am {date}", "slot_refreshes_on": "Slot erneuert sich am {date}",
"slot_refreshed": "Slot erneuert", "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"
}
} }
} }

View File

@@ -169,6 +169,8 @@
"favorites_private_hint": "Only you and administrators can view your favorites list.", "favorites_private_hint": "Only you and administrators can view your favorites list.",
"hide_fav_badge": "Hide Favorite Badge Avatar", "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", "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": "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.", "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", "enable_bg_blur": "Enable Background blur",
@@ -801,6 +803,12 @@
"delete_confirm": "Delete this invite token?", "delete_confirm": "Delete this invite token?",
"slot_refreshes_on": "slot refreshes on {date}", "slot_refreshes_on": "slot refreshes on {date}",
"slot_refreshed": "slot refreshed", "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"
}
} }
} }

View File

@@ -1,12 +1,69 @@
import db from "../sql.mjs"; import db from "../sql.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { updateHallsCache } from "../halls_cache.mjs"; import { updateHallsCache } from "../halls_cache.mjs";
import queue from "../queue.mjs"; import queue from "../queue.mjs";
import fs from "fs"; import fs from "fs";
import url from "url"; import url from "url";
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null; const getGlobalfilter = () => {
if (!cfg.nsfp?.length) return null;
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(" or ") : null;
};
const computeBaseMode = (mode, ratings, session) => {
const effMode = Number(mode ?? 0);
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
let baseMode;
if (effMode === 2) {
if (ratingsArr && ratingsArr.includes('untagged') && ratingsArr.length > 1) {
baseMode = lib.getMultiRatingMode(ratingsArr);
} else {
baseMode = lib.getMode(2);
}
} else if (effMode === 3) {
baseMode = (ratingsArr && ratingsArr.length > 1) ? lib.getMultiRatingMode(ratingsArr) : lib.getMode(3);
} else {
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
baseMode = multiRatingSQL ?? lib.getMode(effMode);
}
if (!session) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if ((effMode === 0 || effMode === 3 || mode === undefined || mode === null) && (!ratingsArr || ratingsArr.length <= 1)) {
if (cfg.websrv.public_nsfw) {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
} else {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (!cfg.websrv.public_untagged) {
if (effMode === 2 || (ratingsArr && ratingsArr.length === 1 && ratingsArr[0] === 'untagged')) {
baseMode = "1 = 0";
}
}
}
return baseMode;
};
const resolveNumericItemId = async (itemIdOrSlug) => {
if (!itemIdOrSlug) return null;
if (typeof itemIdOrSlug === 'number') return itemIdOrSlug;
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
try {
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
return rows[0]?.id || null;
} catch (e) {
return null;
}
};
// All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie) // All MIME types that map to the 'swf' extension in config (e.g. application/x-shockwave-flash, application/vnd.adobe.flash.movie)
const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime); const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === 'swf').map(([mime]) => mime);
@@ -18,7 +75,7 @@ const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === '
const COUNT_CACHE_TTL_MS = 90_000; const COUNT_CACHE_TTL_MS = 90_000;
const countCache = new Map(); // key → { total, expiresAt } const countCache = new Map(); // key → { total, expiresAt }
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) { function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger, sort, timeframe, oc, pinned, hasComments }) {
return JSON.stringify([ return JSON.stringify([
modequery, modequery,
tag ?? '', tag ?? '',
@@ -31,7 +88,12 @@ function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, ex
newerThan ?? '', newerThan ?? '',
minXd, minXd,
userHallObj?.id ?? '', userHallObj?.id ?? '',
tagger ?? '' tagger ?? '',
sort ?? '',
timeframe ?? '',
oc ? 1 : 0,
pinned ? 1 : 0,
hasComments ? 1 : 0
]); ]);
} }
@@ -178,7 +240,7 @@ async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}
} }
export default { export default {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => { getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger, sort, timeframe, oc, pinned, hasComments } = {}) => {
if (fav && rawUser) { if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin }); const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) { if (isPrivate && !isAllowed) {
@@ -239,15 +301,44 @@ export default {
// xD filter: use materialized items.xd_score column (kept live by trigger) for fast indexed lookup // xD filter: use materialized items.xd_score column (kept live by trigger) for fast indexed lookup
const xdFilter = minXd > 0 ? db`and items.xd_score >= ${minXd}` : db``; const xdFilter = minXd > 0 ? db`and items.xd_score >= ${minXd}` : db``;
// Advanced filters
const ocFilter = (oc === true || oc === '1' || oc === 'true') ? db`and items.is_oc = true` : db``;
const pinnedFilter = (pinned === true || pinned === '1' || pinned === 'true') ? db`and items.is_pinned = true` : db``;
const hasCommentsFilter = (hasComments === true || hasComments === '1' || hasComments === 'true')
? db`and exists (select 1 from comments where item_id = items.id and is_deleted = false)`
: db``;
let timeframeFilter = db``;
if (timeframe && timeframe !== 'all') {
let seconds = 0;
if (timeframe === 'day') seconds = 86400;
else if (timeframe === 'week') seconds = 86400 * 7;
else if (timeframe === 'month') seconds = 86400 * 30;
else if (timeframe === 'year') seconds = 86400 * 365;
if (seconds > 0) {
const minStamp = Math.floor(Date.now() / 1000) - seconds;
timeframeFilter = db`and items.stamp >= ${minStamp}`;
}
}
const effectiveSort = sort || (random ? 'random' : 'newest');
let orderSQL = db`items.is_pinned desc, items.id desc`;
if (effectiveSort === 'oldest') {
orderSQL = db`items.is_pinned desc, items.id asc`;
} else if (effectiveSort === 'popular' || effectiveSort === 'top' || effectiveSort === 'xd') {
orderSQL = db`items.is_pinned desc, items.xd_score desc, items.id desc`;
} else if (effectiveSort === 'comments') {
orderSQL = db`items.is_pinned desc, (select count(*) from comments where item_id = items.id and is_deleted = false) desc, items.id desc`;
} else if (effectiveSort === 'random') {
orderSQL = db`random()`;
}
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0; const isStrict = strictParams.length > 0;
const tagger = rawTagger ? lib.escapeLike(rawTagger) : null; const tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger }; const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger, sort: effectiveSort, timeframe, oc, pinned, hasComments };
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment const modequery = computeBaseMode(mode, ratings, session);
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
const modequery = baseMode;
let tagFilter = db``; let tagFilter = db``;
let titleFilter = db``; let titleFilter = db``;
@@ -302,7 +393,14 @@ export default {
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`; userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
} }
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }); const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
: db`and coalesce(items.visibility, 0) = 0`);
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger, sort: effectiveSort, timeframe, oc, pinned, hasComments });
let total = getCachedCount(cacheKey); let total = getCachedCount(cacheKey);
if (total === null) { if (total === null) {
@@ -313,6 +411,7 @@ export default {
where where
${db.unsafe(modequery)} ${db.unsafe(modequery)}
and items.active = true and items.active = true
${visibilityFilter}
${tagFilter} ${tagFilter}
${titleFilter} ${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``} ${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -324,6 +423,10 @@ export default {
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``} ${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter} ${xdFilter}
${ocFilter}
${pinnedFilter}
${hasCommentsFilter}
${timeframeFilter}
`; `;
total = Number(totalRows[0].total); total = Number(totalRows[0].total);
if (total > 0) setCachedCount(cacheKey, total); if (total > 0) setCachedCount(cacheKey, total);
@@ -355,6 +458,7 @@ export default {
where where
${db.unsafe(modequery)} ${db.unsafe(modequery)}
and items.active = true and items.active = true
${visibilityFilter}
${tagFilter} ${tagFilter}
${titleFilter} ${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``} ${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -366,8 +470,12 @@ export default {
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``} ${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter} ${xdFilter}
${ocFilter}
${pinnedFilter}
${hasCommentsFilter}
${timeframeFilter}
${fav ? db`group by items.id, items.is_pinned` : db``} ${fav ? db`group by items.id, items.is_pinned` : db``}
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`} order by ${orderSQL}
offset ${newerThan ? 0 : offset} offset ${newerThan ? 0 : offset}
limit ${eps} limit ${eps}
`; `;
@@ -385,6 +493,8 @@ export default {
const rows = (await db` const rows = (await db`
select select
items.id, items.id,
items.slug,
items.visibility,
items.mime, items.mime,
items.dest, items.dest,
items.username as username, items.username as username,
@@ -521,7 +631,17 @@ export default {
if (uhData.length) userHallObj = uhData[0]; if (uhData.length) userHallObj = uhData[0];
} }
const mime = (rawMime ?? ""); 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 mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
const mimeSQL = mimeParts.length > 0 const mimeSQL = mimeParts.length > 0
? db`and (${mimeParts.map(m => m === 'flash' ? db`and (${mimeParts.map(m => m === 'flash'
@@ -535,18 +655,16 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0; 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 effMode = Number(mode ?? 0);
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null; const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const modequery = multiRatingSQL ?? lib.getMode(effMode); const itemModeQuery = session
? computeBaseMode(mode, ratings, session)
if (itemid === null) { : (cfg.websrv.public_nsfw
return { ? `not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`
success: false, : `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`
message: "404 - upload not found" );
};
}
let tagFilter = db``; let tagFilter = db``;
let titleFilter = db``; let titleFilter = db``;
@@ -585,9 +703,18 @@ export default {
// Helper to construct shared filter conditions // Helper to construct shared filter conditions
const buildConditions = () => { const buildConditions = () => {
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && session.user
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
: db`and coalesce(items.visibility, 0) = 0`);
return db` return db`
${db.unsafe(modequery)} ${db.unsafe(itemModeQuery)}
and items.active = true and items.active = true
${visibilityFilter}
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
${tagFilter} ${tagFilter}
${titleFilter} ${titleFilter}
${hallFilter} ${hallFilter}
@@ -601,11 +728,9 @@ export default {
}; };
const startTime = Date.now(); 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 // 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` const items = await db`
select distinct on (items.id) select distinct on (items.id)
items.*, items.*,
@@ -629,15 +754,48 @@ export default {
left join "user_options" uo on uo.user_id = author_u.id 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``} ${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
where where
items.id = ${itemid} and ${itemLookup} and
items.active = true 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 limit 1
`; `;
const actitem = items[0]; 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` db`
insert into user_video_views (user_id, video_id, view_count, last_viewed) insert into user_video_views (user_id, video_id, view_count, last_viewed)
values (${user_id}, ${itemid}, 1, now()) values (${user_id}, ${itemid}, 1, now())
@@ -646,15 +804,12 @@ export default {
last_viewed = now() last_viewed = now()
`.catch(e => console.error('Failed to track view:', e)); `.catch(e => console.error('Failed to track view:', e));
} }
// Guest global filter check for public items (unlisted items requested by direct link/slug bypass guest rating blocks)
if (!actitem) { if (!session && getGlobalfilter() && (actitem.visibility || 0) === 0) {
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags) const filteredItem = await db`
if (!session && getGlobalfilter()) { select 1 from tags_assign where item_id = ${itemid} and (${db.unsafe(getGlobalfilter())}) limit 1
const unfilteredItem = await db`
select id from items where id = ${itemid} and active = true limit 1
`; `;
if (unfilteredItem[0]) { if (filteredItem.length > 0) {
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall; const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return { return {
success: false, success: false,
@@ -670,22 +825,17 @@ export default {
}; };
} }
} }
return {
success: false,
message: "Sorry, this post is currently not visible."
};
}
// 2. Fetch Next/Prev/Start/End/Cheat in parallel // 2. Fetch Next/Prev/Start/End/Cheat in parallel
// Optimized: Use tags_assign driver for SFW (0) and NSFW (1) modes // Optimized: Use tags_assign driver for SFW (0) and NSFW (1) modes
// Determine the effective mode for optimization check (similar to Random) // Determine the effective mode for optimization check (similar to Random)
const nsfl_id = cfg.nsfl_tag_id || 3; const nsfl_id = cfg.nsfl_tag_id || 3;
const useTagsDriver = (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall; const useTagsDriver = !!session && (effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
const baseQuery = (whereClause, orderBy, limit = 1) => { const baseQuery = (whereClause, orderBy, limit = 1) => {
return db` return db`
select items.id select items.id, items.slug
from items from items
left join tags_assign on tags_assign.item_id = items.id left join tags_assign on tags_assign.item_id = items.id
left join tags on tags.id = tags_assign.tag_id left join tags on tags.id = tags_assign.tag_id
@@ -696,7 +846,7 @@ export default {
where where
${buildConditions()} ${buildConditions()}
${whereClause} ${whereClause}
group by items.id group by items.id, items.slug
${orderBy} ${orderBy}
limit ${limit} limit ${limit}
`; `;
@@ -711,7 +861,7 @@ export default {
const checkFilter = !session && nsfpIds.length > 0; const checkFilter = !session && nsfpIds.length > 0;
const query = db` const query = db`
SELECT ta.item_id as id SELECT ta.item_id as id, items.slug
FROM tags_assign ta FROM tags_assign ta
INNER JOIN items ON items.id = ta.item_id INNER JOIN items ON items.id = ta.item_id
${checkFilter ${checkFilter
@@ -720,6 +870,7 @@ export default {
} }
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`} WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
AND items.active = true AND items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL} ${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``} ${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = ta.item_id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -746,7 +897,7 @@ export default {
// Cheat array should include current item and neighbors, sorted // Cheat array should include current item and neighbors, sorted
const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b); const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b);
const tags = await lib.getTags(itemid); const tags = await lib.getTags(itemid, session);
const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`; const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`;
const userHallsForItem = user_id const userHallsForItem = user_id
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}` ? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
@@ -781,24 +932,24 @@ export default {
if (actitem.checksum && actitem.checksum.includes('_bypass_')) { if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
const baseChecksum = actitem.checksum.split('_bypass_')[0]; const baseChecksum = actitem.checksum.split('_bypass_')[0];
const repostRows = await db` const repostRows = await db`
SELECT id, username, stamp FROM items SELECT id, slug, username, stamp FROM items
WHERE active = true WHERE active = true
AND id != ${itemid} AND id != ${itemid}
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'}) AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
ORDER BY id ASC 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) { } else if (actitem.checksum) {
// Even without bypass, check if other bypass-entries exist with this same hash // Even without bypass, check if other bypass-entries exist with this same hash
const baseChecksum = actitem.checksum; const baseChecksum = actitem.checksum;
const repostRows = await db` const repostRows = await db`
SELECT id, username, stamp FROM items SELECT id, slug, username, stamp FROM items
WHERE active = true WHERE active = true
AND id != ${itemid} AND id != ${itemid}
AND checksum LIKE ${baseChecksum + '_bypass_%'} AND checksum LIKE ${baseChecksum + '_bypass_%'}
ORDER BY id ASC 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 // Also find visually-similar items via phash, merging with checksum results
@@ -808,7 +959,7 @@ export default {
const existingIds = new Set(repostItems.map(r => r.id)); const existingIds = new Set(repostItems.map(r => r.id));
for (const pm of phashMatches) { for (const pm of phashMatches) {
if (!existingIds.has(pm.id)) { 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); existingIds.add(pm.id);
} }
} }
@@ -831,19 +982,14 @@ export default {
const isNsfw = tags.some(t => t.id == 2); const isNsfw = tags.some(t => t.id == 2);
const isSfw = tags.some(t => t.id == 1); const isSfw = tags.some(t => t.id == 1);
const isTagged = tags.length > 0; const isTagged = tags.length > 0;
// Guest rating restriction check for public uploads (visibility === 0)
// Direct links to public unrated/untagged posts are allowed for guests.
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
let guestBlocked = false;
if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
else if (isNsfl) guestBlocked = true;
// Mode-mismatch visibility check: if (guestBlocked) {
// Only enforce for members (session users) with an explicit mode preference.
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
const userMode = Number(mode ?? 0);
if (userMode !== 3) {
let modeBlocked = false;
if (userMode === 0 && (isNsfw || isNsfl)) modeBlocked = true; // SFW mode, item is NSFW or NSFL
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
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) {
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall; const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
return { return {
success: false, success: false,
@@ -860,6 +1006,39 @@ export default {
} }
} }
// Mode-mismatch visibility check:
// Only enforce for members (session users) with an explicit mode preference.
// Mode 0=sfw, 1=nsfw, 2=untagged, 3=all
const userMode = Number(mode ?? 0);
if (userMode !== 3) {
let modeBlocked = false;
if (userMode === 0 && (isNsfw || isNsfl)) modeBlocked = true; // SFW mode, item is NSFW or NSFL
else if (userMode === 1 && !isNsfw) modeBlocked = true; // NSFW mode, item is not NSFW
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 && !isOwnerOrAdmin && actitem.visibility !== 1) {
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}${isNsfw ? '_blur' : ''}.webp`,
og_url: hallSlug
? `https://${cfg.main.url.domain}/h/${encodeURIComponent(hallSlug)}/${itemid}`
: `https://${cfg.main.url.domain}/${itemid}`,
og_description: `Content not visible in current mode`
}
};
}
}
if (getEnableItemSlugs() && !actitem.slug) {
actitem.slug = lib.generateSlug(11);
db`UPDATE items SET slug = ${actitem.slug} WHERE id = ${actitem.id} AND (slug IS NULL OR slug = '')`.catch(e => console.error('[AUTO_SLUG] Failed DB update:', e.message));
}
const data = { const data = {
success: true, success: true,
user: { user: {
@@ -871,6 +1050,8 @@ export default {
}, },
item: { item: {
id: actitem.id, id: actitem.id,
slug: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : null,
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
username: actitem.username, username: actitem.username,
author_id: actitem.author_id, author_id: actitem.author_id,
author_color: actitem.author_color, author_color: actitem.author_color,
@@ -929,17 +1110,21 @@ export default {
is_oc: actitem.is_oc || false, is_oc: actitem.is_oc || false,
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false, is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
reposts: repostItems, reposts: repostItems,
show_repost_row: !!((session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (actitem.checksum?.includes('_bypass_') || (repostItems && repostItems.length > 0))),
width: actitem.width || null, width: actitem.width || null,
height: actitem.height || null, height: actitem.height || null,
original_filename: actitem.original_filename || null original_filename: actitem.original_filename || null,
expires_at: actitem.expires_at || null,
expires_in: lib.expiresIn(actitem.expires_at)
}, },
title: `${actitem.id} - ${cfg.websrv.domain}`, title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
pagination: { pagination: {
end: endItem[0]?.id || itemid, end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
start: startItem[0]?.id || itemid, start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
next: nextItem[0]?.id || null, next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
prev: prevItem[0]?.id || null, prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
page: actitem.id, page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
cheat: cheat cheat: cheat
}, },
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)], phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
@@ -994,9 +1179,9 @@ export default {
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : []; const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
const isStrict = strictParams.length > 0; const isStrict = strictParams.length > 0;
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null; const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0); const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
const modequery = baseMode; const modequery = computeBaseMode(mode, ratings, session);
let item; let item;
@@ -1008,6 +1193,7 @@ export default {
WHERE WHERE
${db.unsafe(modequery)} ${db.unsafe(modequery)}
AND items.active = true AND items.active = true
AND coalesce(items.visibility, 0) = 0
AND items.title ILIKE ${'%' + titleQuery + '%'} AND items.title ILIKE ${'%' + titleQuery + '%'}
AND items.title IS NOT NULL AND items.title IS NOT NULL
${mimeSQL} ${mimeSQL}
@@ -1030,6 +1216,7 @@ export default {
${db.unsafe(modequery)} ${db.unsafe(modequery)}
and "user".user ilike ${user} and "user".user ilike ${user}
and items.active = true and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL} ${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
group by items.id group by items.id
@@ -1069,6 +1256,7 @@ export default {
where where
${db.unsafe(modequery)} ${db.unsafe(modequery)}
and items.active = true and items.active = true
and coalesce(items.visibility, 0) = 0
${tagFilter} ${tagFilter}
${user ? db`and items.username ilike ${user}` : db``} ${user ? db`and items.username ilike ${user}` : db``}
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``} ${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
@@ -1091,6 +1279,7 @@ export default {
${db.unsafe(modequery)} ${db.unsafe(modequery)}
and h.slug = ${hall} and h.slug = ${hall}
and items.active = true and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL} ${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``} ${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -1107,6 +1296,7 @@ export default {
${db.unsafe(modequery)} ${db.unsafe(modequery)}
and uha.hall_id = ${userHallId} and uha.hall_id = ${userHallId}
and items.active = true and items.active = true
and coalesce(items.visibility, 0) = 0
${mimeSQL} ${mimeSQL}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
order by random() order by random()
@@ -1115,9 +1305,9 @@ export default {
} else { } else {
// Uniform random logic for global requests (no user/tag/hall) // Uniform random logic for global requests (no user/tag/hall)
// When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation. // When multi-rating SQL is active, use it directly. Otherwise use the tag-join optimisation.
const globalModeQuery = multiRatingSQL ?? lib.getMode(mode ?? 0); const globalModeQuery = modequery;
// tagId optimisation only applies for single native modes (not multi-rating) // tagId optimisation only applies for single native modes for logged-in users (not multi-rating or guest mode)
const tagId = !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4) const tagId = session && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1)) ? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
: null; : null;
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible // If audio is included, we avoid the strict tagId optimization to ensure audio is visible
@@ -1133,6 +1323,7 @@ export default {
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``} ${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``} ${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
WHERE items.active = true WHERE items.active = true
AND coalesce(items.visibility, 0) = 0
${mimeSQL} ${mimeSQL}
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``} ${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
@@ -1158,7 +1349,8 @@ export default {
}; };
}, },
getComments: async (itemId, sort = 'new', process = true) => { getComments: async (itemId, sort = 'new', process = true) => {
if (!itemId) return []; const numericId = await resolveNumericItemId(itemId);
if (!numericId) return [];
const tStart = Date.now(); const tStart = Date.now();
try { try {
const comments = await db` const comments = await db`
@@ -1166,12 +1358,12 @@ export default {
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted, c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
COALESCE(c.is_pinned, false) as is_pinned, COALESCE(c.is_pinned, false) as is_pinned,
c.video_time, c.video_time,
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat,
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count (SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
FROM comments c FROM comments c
JOIN "user" u ON c.user_id = u.id JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE c.item_id = ${itemId} AND c.is_deleted = false WHERE c.item_id = ${numericId} AND c.is_deleted = false
ORDER BY COALESCE(c.is_pinned, false) DESC, ORDER BY COALESCE(c.is_pinned, false) DESC,
CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC, CASE WHEN ${sort !== 'new'} THEN c.created_at END ASC,
CASE WHEN ${sort === 'new'} THEN c.created_at END DESC CASE WHEN ${sort === 'new'} THEN c.created_at END DESC
@@ -1285,7 +1477,7 @@ export default {
c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted, c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
COALESCE(c.is_pinned, false) as is_pinned, COALESCE(c.is_pinned, false) as is_pinned,
c.video_time, c.video_time,
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat
FROM comments c FROM comments c
JOIN "user" u ON c.user_id = u.id JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON uo.user_id = u.id LEFT JOIN user_options uo ON uo.user_id = u.id
@@ -1314,11 +1506,12 @@ export default {
} }
}, },
getSubscriptionStatus: async (userId, itemId) => { getSubscriptionStatus: async (userId, itemId) => {
if (!userId || !itemId) return false; const numericId = await resolveNumericItemId(itemId);
if (!userId || !numericId) return false;
const tStart = Date.now(); const tStart = Date.now();
try { try {
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${itemId} AND is_subscribed = true`; const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${numericId} AND is_subscribed = true`;
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${itemId} in ${Date.now() - tStart}ms`); console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${numericId} in ${Date.now() - tStart}ms`);
return sub.length > 0; return sub.length > 0;
} catch (e) { } catch (e) {
return false; return false;
@@ -1326,9 +1519,10 @@ export default {
}, },
processMentions, processMentions,
markNotificationsRead: async (userId, itemId) => { markNotificationsRead: async (userId, itemId) => {
if (!userId || !itemId) return; const numericId = await resolveNumericItemId(itemId);
if (!userId || !numericId) return;
try { try {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${itemId} AND is_read = false`; await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${numericId} AND is_read = false`;
} catch (e) { } catch (e) {
console.error('[F0CKLIB] Error marking notifications as read:', e); console.error('[F0CKLIB] Error marking notifications as read:', e);
} }
@@ -1645,8 +1839,14 @@ export default {
} }
}, },
computeBaseMode,
getGlobalfilter,
processMentions,
processEmbeds,
computeXdScore, computeXdScore,
xdScoreMeta, xdScoreMeta,
// Bust the count cache (call after a new upload is accepted so page totals stay accurate) // Bust the count cache (call after a new upload is accepted so page totals stay accurate)
clearCountCache: () => countCache.clear() clearCountCache: () => countCache.clear()
}; };

View File

@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs"; import security from "../security.mjs";
import crypto from "crypto"; import crypto from "crypto";
import path from "path"; import path from "path";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
export default (router, tpl) => { export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => { router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -282,6 +282,10 @@ export default (router, tpl) => {
totals: await lib.countf0cks(), totals: await lib.countf0cks(),
session: req.session, session: req.session,
manual_approval: getManualApproval(), manual_approval: getManualApproval(),
min_tags: getMinTags(),
trusted_uploads: getTrustedUploads(),
registration_open: getRegistrationOpen(),
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
log_user_ips: getLogUserIps(), log_user_ips: getLogUserIps(),
hash_user_ips: getHashUserIps(), hash_user_ips: getHashUserIps(),
enable_cleanup: getEnableCleanup(), enable_cleanup: getEnableCleanup(),
@@ -319,7 +323,7 @@ export default (router, tpl) => {
}); });
}); });
router.get(/\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
const userId = +req.params.userId; const userId = +req.params.userId;
const user = await db`select "user", login from "user" where id = ${userId} limit 1`; const user = await db`select "user", login from "user" where id = ${userId} limit 1`;
if (user.length === 0) return res.reply({ code: 404, body: 'User not found' }); if (user.length === 0) return res.reply({ code: 404, body: 'User not found' });
@@ -651,6 +655,77 @@ export default (router, tpl) => {
return res.writeHead(302, { "Location": "/admin" }).end(); return res.writeHead(302, { "Location": "/admin" }).end();
}); });
// Config Manager API GET
router.get(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
const origin = req.headers?.origin || '*';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
const isDev = cfg.main?.development === true;
if (!isDev && (!req.session || !req.session.admin)) {
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
}
try {
const configPath = path.resolve(process.cwd(), "config.json");
const raw = await fs.readFile(configPath, "utf-8");
const json = JSON.parse(raw);
if (res.json) return res.json({ success: true, config: json });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, config: json }));
} catch (err) {
if (res.json) return res.json({ success: false, msg: err.message });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
// Config Manager API POST
router.post(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
const origin = req.headers?.origin || '*';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
const isDev = cfg.main?.development === true;
if (!isDev && (!req.session || !req.session.admin)) {
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
}
try {
let updatedConfig = req.post?.config || req.json?.config || req.post;
if (typeof updatedConfig === 'string') {
updatedConfig = JSON.parse(updatedConfig);
}
if (!updatedConfig || typeof updatedConfig !== 'object') {
throw new Error('Invalid configuration payload');
}
const configPath = path.resolve(process.cwd(), "config.json");
// Write formatted JSON to config.json on disk
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
// Mutate in-memory cfg object so changes apply immediately
Object.assign(cfg, updatedConfig);
if (getEnableItemSlugs()) {
ensureAllItemsHaveSlugs();
}
// Audit log entry (if session available)
if (req.session?.id) {
await audit.log(req.session.id, 'update_config_file', 'system', 0, { updatedKeys: Object.keys(updatedConfig) });
}
const response = { success: true, message: 'Configuration saved to config.json' };
if (res.json) return res.json(response);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
} catch (err) {
console.error('[ADMIN] Config Save Error:', err);
const response = { success: false, msg: err.message };
if (res.json) return res.json(response, 400);
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
}
});
router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => { router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => {
if (!getEnableCleanup()) { if (!getEnableCleanup()) {
return res.redirect("/admin"); return res.redirect("/admin");
@@ -661,6 +736,7 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(), enable_cleanup: getEnableCleanup(),
cleanup_start_date: getCleanupStartDate(), cleanup_start_date: getCleanupStartDate(),
cleanup_end_date: getCleanupEndDate(), cleanup_end_date: getCleanupEndDate(),
cleanup_include_engaged: getCleanupIncludeEngaged(),
totals: await lib.countf0cks(), totals: await lib.countf0cks(),
tmp: null tmp: null
}; };
@@ -674,15 +750,18 @@ export default (router, tpl) => {
try { try {
const cleanup_start_date = req.post.cleanup_start_date || ''; const cleanup_start_date = req.post.cleanup_start_date || '';
const cleanup_end_date = req.post.cleanup_end_date || ''; const cleanup_end_date = req.post.cleanup_end_date || '';
const cleanup_include_engaged = req.post.cleanup_include_engaged === 'true' || req.post.cleanup_include_engaged === 'on' || req.post.cleanup_include_engaged === '1';
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`; await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_include_engaged', ${cleanup_include_engaged ? 'true' : 'false'}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
setCleanupStartDate(cleanup_start_date); setCleanupStartDate(cleanup_start_date);
setCleanupEndDate(cleanup_end_date); setCleanupEndDate(cleanup_end_date);
setCleanupIncludeEngaged(cleanup_include_engaged);
if (req.headers['x-requested-with'] === 'XMLHttpRequest') { if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() }); const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), cleanup_include_engaged: getCleanupIncludeEngaged() });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body); return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
} }
@@ -701,17 +780,23 @@ export default (router, tpl) => {
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => { router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
try { try {
// Ensure settings are synced from DB before execution // Ensure settings are synced from DB before execution
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`; const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date', 'cleanup_include_engaged')`;
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value])); const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
const isEnabled = settingsMap['enable_cleanup'] === 'true'; const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
const startDate = settingsMap['cleanup_start_date'] || ''; const startDate = settingsMap['cleanup_start_date'] || '';
const endDate = settingsMap['cleanup_end_date'] || ''; const endDate = settingsMap['cleanup_end_date'] || '';
const reqEngaged = req.post?.include_engaged;
const includeEngaged = reqEngaged !== undefined
? (reqEngaged === 'true' || reqEngaged === true || reqEngaged === 'on' || reqEngaged === '1')
: (settingsMap['cleanup_include_engaged'] === 'true' || getCleanupIncludeEngaged());
// Update memory state // Update memory state
setEnableCleanup(isEnabled); setEnableCleanup(isEnabled);
setCleanupStartDate(startDate); setCleanupStartDate(startDate);
setCleanupEndDate(endDate); setCleanupEndDate(endDate);
setCleanupIncludeEngaged(includeEngaged);
if (!isEnabled) { if (!isEnabled) {
throw new Error('Cleanup is disabled in settings.'); throw new Error('Cleanup is disabled in settings.');
@@ -721,7 +806,7 @@ export default (router, tpl) => {
throw new Error('Please select both a Start Date and an End Date.'); throw new Error('Please select both a Start Date and an End Date.');
} }
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`); console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate} (includeEngaged: ${includeEngaged})...`);
const start_stamp = ~~(new Date(startDate).getTime() / 1000); const start_stamp = ~~(new Date(startDate).getTime() / 1000);
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
@@ -737,7 +822,7 @@ export default (router, tpl) => {
AND i.is_pinned = false AND i.is_pinned = false
AND i.stamp >= ${start_stamp} AND i.stamp >= ${start_stamp}
AND i.stamp <= ${end_stamp} AND i.stamp <= ${end_stamp}
AND ( ${includeEngaged ? db`` : db`AND (
-- Case 1: Active posts with no engagement (ignoring automatic subscriptions) -- Case 1: Active posts with no engagement (ignoring automatic subscriptions)
(i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id)) (i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id))
OR OR
@@ -746,7 +831,7 @@ export default (router, tpl) => {
OR OR
-- Case 3: Pending posts (not yet approved) that are old enough -- Case 3: Pending posts (not yet approved) that are old enough
(i.active = false AND i.is_deleted = false) (i.active = false AND i.is_deleted = false)
) )`}
`; `;
const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`; const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`;
@@ -764,12 +849,16 @@ export default (router, tpl) => {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { }); await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
} }
await db`DELETE FROM tags_assign WHERE item_id = ${item.id}`;
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`; await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
count++; count++;
} catch (e) { } catch (e) {
console.error(`[CLEANUP] Failed to delete item ${item.id}:`, e.message); console.error(`[CLEANUP] Failed to delete item ${item.id}:`, e.message);
} }
} }
// Clean up any remaining orphan tag assignments for deleted or inactive items
await db`DELETE FROM tags_assign WHERE item_id IN (SELECT id FROM items WHERE is_deleted = true OR active = false)`;
} }
// Log it in audit // Log it in audit

View File

@@ -4,7 +4,7 @@ import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs"; import { createI18n } from "../i18n.mjs";
export default (router, tpl) => { export default (router, tpl) => {
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => { router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
const tAjaxStart = Date.now(); const tAjaxStart = Date.now();
let query = {}; let query = {};
if (typeof req.url === 'string') { if (typeof req.url === 'string') {
@@ -32,15 +32,16 @@ export default (router, tpl) => {
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`); if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
const isRandom = query.random === '1' || req.cookies.random_mode === '1'; const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const reqMode = query.mode !== undefined ? +query.mode : req.mode;
const ratingsRaw = req.cookies.ratings; const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (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({ const data = await f0cklib.getf0ck({
itemid: itemid, itemid: itemid,
mode: query.mode !== undefined ? +query.mode : req.mode, mode: reqMode,
ratings: ratingsArr, ratings: ratingsArr,
session: !!req.session, session: req.session,
url: contextUrl, url: contextUrl,
user: query.user, user: query.user,
tag: query.tag, tag: query.tag,
@@ -170,7 +171,8 @@ export default (router, tpl) => {
html: itemHtml, html: itemHtml,
pagination: paginationHtml, pagination: paginationHtml,
title: data.title, title: data.title,
id: itemid id: itemid,
slug: data.item?.slug || null
}) })
}); });
}); });
@@ -194,7 +196,7 @@ export default (router, tpl) => {
// Infinite scroll endpoint for index thumbnails // Infinite scroll endpoint for index thumbnails
router.get(/\/ajax\/items/, async (req, res) => { router.get(/^\/ajax\/items/, async (req, res) => {
let query = {}; let query = {};
if (typeof req.url === 'string') { if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true); const parsedUrl = url.parse(req.url, true);
@@ -205,9 +207,16 @@ export default (router, tpl) => {
const page = parseInt(query.page) || 1; const page = parseInt(query.page) || 1;
const isRandom = query.random === '1' || req.cookies.random_mode === '1'; const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings; const ratingsRaw = query.ratings || req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null; const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const sortParam = query.sort || req.cookies.sort || 'newest';
const timeframeParam = query.timeframe || req.cookies.timeframe || 'all';
const ocParam = query.oc === '1' || query.oc === 'true';
const pinnedParam = query.pinned === '1' || query.pinned === 'true';
const hasCommentsParam = query.has_comments === '1' || query.has_comments === 'true' || query.hasComments === 'true';
const layoutParam = query.layout || req.cookies.layout || 'grid';
const data = await f0cklib.getf0cks({ const data = await f0cklib.getf0cks({
page: page, page: page,
tag: query.tag || null, tag: query.tag || null,
@@ -217,6 +226,11 @@ export default (router, tpl) => {
userHallOwner: query.userHallOwner || null, userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null), mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode, mode: query.mode !== undefined ? +query.mode : req.mode,
sort: sortParam,
timeframe: timeframeParam,
oc: ocParam,
pinned: pinnedParam,
hasComments: hasCommentsParam,
ratings: ratingsArr, ratings: ratingsArr,
session: req.session, session: req.session,
user_id: req.session?.id, user_id: req.session?.id,
@@ -258,7 +272,8 @@ export default (router, tpl) => {
// Render just the thumbnail items // Render just the thumbnail items
const itemsHtml = tpl.render('snippets/items-grid', { const itemsHtml = tpl.render('snippets/items-grid', {
items: data.items, items: data.items,
link: data.link link: data.link,
layout: layoutParam
}, req); }, req);
// Render pagination // Render pagination
@@ -281,6 +296,27 @@ export default (router, tpl) => {
session: (req.session && req.session.user) ? { ...req.session } : false session: (req.session && req.session.user) ? { ...req.session } : false
}, req); }, req);
// Render filter bar
const activeRatings = ratingsArr || ['sfw', 'nsfw', 'untagged'];
const activeMimes = (query.mime || req.cookies.mime || '').split(',').filter(Boolean);
const filterBarHtml = tpl.render('snippets/filter-bar', {
layout: layoutParam,
sort: sortParam,
timeframe: timeframeParam,
oc: ocParam ? '1' : '0',
pinned: pinnedParam ? '1' : '0',
hasComments: hasCommentsParam ? '1' : '0',
rating_sfw: activeRatings.includes('sfw'),
rating_nsfw: activeRatings.includes('nsfw'),
rating_nsfl: activeRatings.includes('nsfl'),
rating_untagged: activeRatings.includes('untagged'),
mime_image: activeMimes.includes('image'),
mime_video: activeMimes.includes('video'),
mime_audio: activeMimes.includes('audio'),
mime_flash: activeMimes.includes('flash')
}, req);
const hasMore = data.pagination.next !== null; const hasMore = data.pagination.next !== null;
return res.reply({ return res.reply({
@@ -294,6 +330,7 @@ export default (router, tpl) => {
success: true, success: true,
html: itemsHtml, html: itemsHtml,
titleHtml: titleHtml, titleHtml: titleHtml,
filterBarHtml: filterBarHtml,
pagination: paginationHtml, pagination: paginationHtml,
hasMore: hasMore, hasMore: hasMore,
nextPage: data.pagination.next, nextPage: data.pagination.next,

View File

@@ -2,15 +2,22 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs'; import db from '../../sql.mjs';
import lib from '../../lib.mjs'; import lib from '../../lib.mjs';
import cfg from '../../config.mjs'; import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import queue from '../../queue.mjs'; import queue from '../../queue.mjs';
import search from '../../routeinc/search.mjs'; import search from '../../routeinc/search.mjs';
import path from "path"; import path from "path";
import f0cklib from '../../routeinc/f0cklib.mjs'; import f0cklib from '../../routeinc/f0cklib.mjs';
import audit from '../../audit.mjs'; import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs'; import { parseMultipart, collectBody } from '../../multipart.mjs';
import { purgeExpiredUploads } from '../../lib_delete.mjs';
import { calculateExpiresAt } from './upload.mjs';
const allowedMimes = ["audio", "image", "video", "%"]; const allowedMimes = ["audio", "image", "video", "%"];
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null; const getGlobalfilter = () => {
if (!cfg.nsfp?.length) return null;
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(' or ') : null;
};
const metaCache = new Map(); const metaCache = new Map();
const MAX_META_CACHE = 2000; const MAX_META_CACHE = 2000;
@@ -594,10 +601,12 @@ export default router => {
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null, ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict, strict: isStrict,
session: !!req.session, 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({ return res.json({
success: false, success: false,
items: [] items: []
@@ -636,6 +645,7 @@ export default router => {
items: { items: {
...safeItem, ...safeItem,
id: item.id, id: item.id,
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
dest: relativeDest, dest: relativeDest,
url: directUrl, url: directUrl,
direct_url: directUrl direct_url: directUrl
@@ -700,10 +710,17 @@ export default router => {
}; };
const excludedTags = req.session?.excluded_tags || []; const excludedTags = req.session?.excluded_tags || [];
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
const sessionUser = req.session?.user ? req.session.user.toLowerCase() : null;
const visibilityFilter = isOwnerOrAdmin
? db``
: (sessionUser
? db`and (coalesce("items".visibility, 0) = 0 or lower("items".username) = ${sessionUser})`
: db`and coalesce("items".visibility, 0) = 0`);
const newest = (await db`select max(id) as id from "items"`)[0].id; const newest = (await db`select max(id) as id from "items"`)[0]?.id || 0;
const oldest = (await db`select min(id) as id from "items"`)[0].id; const oldest = (await db`select min(id) as id from "items"`)[0]?.id || 0;
const modequery = lib.getMode(opt.mode); const modequery = f0cklib.computeBaseMode(opt.mode, null, req.session);
const rows = (await db` const rows = (await db`
select "items".id, "items".mime, coalesce("tags_assign".tag_id, 0) as tag_id select "items".id, "items".mime, coalesce("tags_assign".tag_id, 0) as tag_id
@@ -712,6 +729,7 @@ export default router => {
where where
${db.unsafe(modequery)} and ${db.unsafe(modequery)} and
active = true active = true
${visibilityFilter}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = "items".id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = "items".id and tag_id = any(${excludedTags}::int[]))` : db``}
${opt.older ${opt.older
? db`and id <= ${opt.older}` ? db`and id <= ${opt.older}`
@@ -727,34 +745,25 @@ export default router => {
`).sort((a, b) => b.id - a.id); `).sort((a, b) => b.id - a.id);
return res.json({ return res.json({
atEnd: rows[0].id === newest, atEnd: rows.length > 0 && rows[0].id === newest,
atStart: rows[rows.length - 1].id === oldest, atStart: rows.length > 0 && rows[rows.length - 1].id === oldest,
success: true, success: true,
items: rows items: rows
}, 200); }, 200);
}); });
group.get(/\/item\/(?<id>[0-9]+)$/, async (req, res) => { group.get(/\/item\/(?<id>[a-zA-Z0-9_-]{11}|\d+)$/, async (req, res) => {
const id = +req.params.id; const rawIdOrSlug = req.params.id;
const isNumeric = /^\d+$/.test(rawIdOrSlug);
const id = isNumeric ? +rawIdOrSlug : (await db`SELECT id FROM items WHERE slug = ${rawIdOrSlug} LIMIT 1`)[0]?.id;
if (!id) {
return res.json({ success: false, msg: 'no items found' });
}
const item = await db` const item = await db`
select * select *
from "items" from "items"
where id = ${+id} and active = true where id = ${id} and active = true
limit 1
`;
const next = await db`
select id
from "items"
where id > ${+id} and active = true
order by id
limit 1
`;
const prev = await db`
select id
from "items"
where id < ${+id} and active = true
order by id desc
limit 1 limit 1
`; `;
@@ -765,12 +774,74 @@ export default router => {
}); });
} }
const actitem = item[0];
const session = req.session;
const isOwnerOrAdmin = session && (
session.admin ||
session.is_moderator ||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase())
);
// Exclude unlisted (1) and private (2) items from API responses for non-owners/non-admins
if ((actitem.visibility || 0) > 0 && !isOwnerOrAdmin) {
return res.json({
success: false,
msg: 'no items found'
});
}
const effMode = req.query.mode !== undefined ? +req.query.mode : (req.mode ?? 3);
const ratingsRaw = req.cookies?.ratings || req.query.ratings;
const ratingsArr = (effMode === 3 || effMode === 2) ? null : (ratingsRaw ? (Array.isArray(ratingsRaw) ? ratingsRaw : decodeURIComponent(ratingsRaw).split(/[|,]/)).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
let guestTagFilter = db``;
if (!session) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (cfg.websrv.public_nsfw) {
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`;
} else {
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`;
}
}
let modeCondition = db``;
if (effMode === 2) {
modeCondition = db`and not exists (select 1 from tags_assign where item_id = items.id)`;
} else if (effMode === 0) {
modeCondition = db`and (id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))`;
} else if (effMode === 1) {
modeCondition = db`and id in (select item_id from tags_assign where tag_id = 2)`;
} else if (effMode === 4) {
modeCondition = db`and id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`;
} else if (ratingsArr && ratingsArr.length > 0) {
modeCondition = db`and ${db.unsafe(lib.getMultiRatingMode(ratingsArr))}`;
}
const visibilityFilter = isOwnerOrAdmin
? db``
: (session && session.user
? db`and (coalesce(visibility, 0) = 0 or lower(username) = ${session.user.toLowerCase()})`
: db`and coalesce(visibility, 0) = 0`);
const next = await db`
select id
from "items"
where id > ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
order by id
limit 1
`;
const prev = await db`
select id
from "items"
where id < ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
order by id desc
limit 1
`;
const rows = { const rows = {
...item[0], ...actitem,
...{
next: next[0]?.id ?? null, next: next[0]?.id ?? null,
prev: prev[0]?.id ?? null prev: prev[0]?.id ?? null
}
}; };
return res.json({ return res.json({
@@ -1038,7 +1109,24 @@ export default router => {
}); });
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => { 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 // Check if already faved by this user — compare as numbers to avoid type mismatch
const existing = await db` const existing = await db`
@@ -1168,6 +1256,121 @@ 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(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.loggedin, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
const rows = await db`
SELECT id, dest, mime, username
FROM items
WHERE id = ${itemid} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
const item = rows[0];
const isOwner = item.username === req.session.user;
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
const ok = await queue.genThumbnail(item.dest, item.mime, item.id, '', false);
await queue.genBlurredThumbnail(item.id, false);
if (ok) {
db.notify('rethumb', JSON.stringify({ item_id: item.id })).catch(() => {});
audit.log(req.session.id, 'rethumb_item', 'item', item.id, {}).catch(() => {});
return res.json({ success: true, itemid: item.id });
} else {
return res.json({ success: false, msg: 'Thumbnail regeneration failed' }, 500);
}
});
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.loggedin, async (req, res) => {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
}
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
const rows = await db`
SELECT id, username, expires_at
FROM items
WHERE id = ${itemid} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
const item = rows[0];
const isOwner = item.username === req.session.user;
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
const reqExpiry = req.body?.expiry ?? req.body?.expires_at ?? req.post?.expiry ?? req.post?.expires_at;
const nowStamp = Math.floor(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(reqExpiry, nowStamp);
await db`UPDATE items SET expires_at = ${targetExpiresAt} WHERE id = ${item.id}`;
audit.log(req.session.id, 'set_item_expiry', 'item', item.id, { expires_at: targetExpiresAt }).catch(() => {});
if (targetExpiresAt && targetExpiresAt <= nowStamp) {
await purgeExpiredUploads().catch(err => {
console.error('[API ITEM EXPIRY] Purge failed:', err);
});
}
const expires_in = targetExpiresAt ? lib.expiresIn(targetExpiresAt) : null;
return res.json({
success: true,
itemid: item.id,
expires_at: targetExpiresAt,
expires_in: expires_in,
purged: !!(targetExpiresAt && targetExpiresAt <= nowStamp)
});
});
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => { group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
const itemid = +req.params.id; const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400); if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);

View File

@@ -353,6 +353,30 @@ export default router => {
} }
}); });
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
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 // Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => { group.put(/\/username_color/, lib.loggedin, async (req, res) => {
const { color } = req.post; const { color } = req.post;

View File

@@ -19,7 +19,7 @@ export default router => {
return res.json({ return res.json({
success: true, success: true,
tags: await lib.getTags(+req.params.postid) tags: await lib.getTags(+req.params.postid, req.session)
}); });
}); });

View File

@@ -3,9 +3,11 @@ import { spawn as _spawnRaw } from 'child_process';
import db from '../../sql.mjs'; import db from '../../sql.mjs';
import lib from '../../lib.mjs'; import lib from '../../lib.mjs';
import cfg from '../../config.mjs'; import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import { applyWordFilter } from '../../wordfilter.mjs'; import { applyWordFilter } from '../../wordfilter.mjs';
import queue from '../../queue.mjs'; import queue from '../../queue.mjs';
import path from "path"; import path from "path";
import f0cklib from "../../routeinc/f0cklib.mjs";
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// In-memory job progress map (keyed by jobId string) // In-memory job progress map (keyed by jobId string)
@@ -137,6 +139,71 @@ const parseMultipart = (buffer, boundary) => {
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs"; import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
const getTargetVisibility = (req, postVis) => {
if (cfg.enable_private_uploads === false) return 0;
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
? cfg.default_upload_visibility
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
if (!allowUserOverride) {
return sysDefault;
}
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 !== undefined && req.session?.default_upload_visibility !== null)
? req.session.default_upload_visibility
: sysDefault;
};
export function calculateExpiresAt(expiryVal, baseStamp = ~~(Date.now() / 1000)) {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return null;
}
if (!expiryVal || expiryVal === 'permanent' || expiryVal === 'never' || expiryVal === '0') {
return null;
}
const val = expiryVal.toString().trim().toLowerCase();
switch (val) {
case '30minutes':
case '30m':
case '1800':
return baseStamp + 1800;
case '1hour':
case '1h':
case '3600':
return baseStamp + 3600;
case '24hours':
case '24h':
case '1day':
case '86400':
return baseStamp + 86400;
case '1week':
case '1w':
case '7days':
case '604800':
return baseStamp + 604800;
case '1month':
case '1m':
case '30days':
case '2592000':
return baseStamp + 2592000;
default:
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
return parsed > 2000000000 ? parsed : baseStamp + parsed;
}
return null;
}
}
// Collect request body as buffer with debug logging // Collect request body as buffer with debug logging
const collectBody = (req) => { const collectBody = (req) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -369,6 +436,11 @@ export default router => {
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube // Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
const filename = `yt:${videoId}`; const filename = `yt:${videoId}`;
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = lib.generateSlug(11);
const nowStamp = ~~(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db` const [{ id: itemid }] = await db`
insert into items ${db({ insert into items ${db({
src: ytUrl, src: ytUrl,
@@ -380,11 +452,14 @@ export default router => {
username: req.session.user, username: req.session.user,
userchannel: 'web', userchannel: 'web',
usernetwork: 'web', usernetwork: 'web',
stamp: ~~(Date.now() / 1000), stamp: nowStamp,
active: !isApprovalRequired, active: !isApprovalRequired,
is_oc: !!is_oc, is_oc: !!is_oc,
title: title title: title,
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')} visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
RETURNING id RETURNING id
`; `;
@@ -429,6 +504,26 @@ export default router => {
} }
} }
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!isApprovalRequired) {
f0cklib.clearCountCache();
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: 'video/youtube',
username: req.session.user,
display_name: req.session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility
})})`;
} catch (err) {
console.error('[UPLOAD-URL] YouTube new_item notify failed:', err);
}
}
return res.json({ return res.json({
success: true, success: true,
msg: isApprovalRequired ? 'YouTube video embedded! Pending admin approval.' : 'YouTube video embedded!', msg: isApprovalRequired ? 'YouTube video embedded! Pending admin approval.' : 'YouTube video embedded!',
@@ -437,6 +532,9 @@ export default router => {
}); });
} else { } else {
// ===== REGULAR URL DOWNLOAD (Asynchronous) ===== // ===== REGULAR URL DOWNLOAD (Asynchronous) =====
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = lib.generateSlug(11);
const session = { const session = {
id: req.session.id, id: req.session.id,
user: req.session.user, user: req.session.user,
@@ -674,6 +772,8 @@ export default router => {
await fs.unlink(source).catch(() => { }); await fs.unlink(source).catch(() => { });
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum; const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
const nowStamp = ~~(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db` const [{ id: itemid }] = await db`
insert into items ${db({ insert into items ${db({
@@ -686,11 +786,14 @@ export default router => {
username: session.user, username: session.user,
userchannel: 'web', userchannel: 'web',
usernetwork: 'web', usernetwork: 'web',
stamp: ~~(Date.now() / 1000), stamp: nowStamp,
active: !isApprovalRequired, active: !isApprovalRequired,
is_oc: !!is_oc, is_oc: !!is_oc,
title: title title: title,
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')} visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
RETURNING id RETURNING id
`; `;
@@ -735,15 +838,17 @@ export default router => {
username: session.user, username: session.user,
display_name: session.display_name || null, display_name: session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility
})})`; })})`;
} catch (err) { } catch (err) {
console.error('[UPLOAD-URL] new_item notify failed:', err); console.error('[UPLOAD-URL] new_item notify failed:', err);
} }
} }
// Push to Matrix Channel (only if auto-approved) // Push to Matrix Channel (only if auto-approved and public)
if (!isApprovalRequired) { if (!isApprovalRequired && targetVisibility === 0) {
try { try {
const self = router.self; const self = router.self;
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix'); const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');

View File

@@ -208,7 +208,7 @@ export default (router, tpl) => {
}); });
// DELETE /api/chat/:id — admin: delete a single message // DELETE /api/chat/:id — admin: delete a single message
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => { router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_global_chat) { if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) }); return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
} }

View File

@@ -1,6 +1,7 @@
import db from "../sql.mjs"; import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import audit from "../audit.mjs"; import audit from "../audit.mjs";
import { promises as fs } from "fs"; import { promises as fs } from "fs";
@@ -69,7 +70,7 @@ export default (router, tpl) => {
comments, comments,
is_subscribed, is_subscribed,
is_locked, is_locked,
user_id: req.session ? req.session.user : null, user_id: req.session ? req.session.id : null,
is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false
}) })
}) })
@@ -155,7 +156,8 @@ export default (router, tpl) => {
} }
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or '); const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
const excludedTags = req.session ? (req.session.excluded_tags || []) : []; const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
/* <mode-override> */ /* <mode-override> */
// prioritize query mode (from AJAX) over session default // prioritize query mode (from AJAX) over session default
@@ -171,10 +173,10 @@ export default (router, tpl) => {
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null; const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
// Build mode SQL — replace items.id alias with i.id used in the activity query // Build mode SQL — replace items.id alias with i.id used in the activity query
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id'); const modequery = f0cklib.computeBaseMode(mode, ratingsArr, req.session).replace(/items\.id/g, 'i.id');
const comments = await db` 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 FROM comments c
LEFT JOIN items i ON c.item_id = i.id LEFT JOIN items i ON c.item_id = i.id
WHERE c.user_id = ${userId} AND c.is_deleted = false WHERE c.user_id = ${userId} AND c.is_deleted = false
@@ -208,6 +210,7 @@ export default (router, tpl) => {
let processedComments = mentionsProcessed.map(c => { let processedComments = mentionsProcessed.map(c => {
return { return {
...c, ...c,
item_slug: getEnableItemSlugs() ? c.item_slug : null,
content: c.content content: c.content
}; };
}); });
@@ -570,7 +573,9 @@ 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) // 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` const itemQuery = await db`
SELECT SELECT
i.slug,
i.xd_score, i.xd_score,
COALESCE(i.visibility, 0) as visibility,
(SELECT ta.tag_id FROM tags_assign ta (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[]) WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
@@ -588,16 +593,24 @@ export default (router, tpl) => {
// Large comments would silently drop the notification. The client fetches // Large comments would silently drop the notification. The client fetches
// the full content via _silentSync; the NOTIFY only needs to trigger the update. // the full content via _silentSync; the NOTIFY only needs to trigger the update.
const notifyBody = content.length > 500 ? content.substring(0, 500) + '…' : content; const notifyBody = content.length > 500 ? content.substring(0, 500) + '…' : content;
const uo = await db`SELECT banner_file, banner_position, banner_size, banner_repeat FROM user_options WHERE user_id = ${req.session.id}`;
const bannerOpt = uo[0] || {};
const livePayload = { const livePayload = {
type: 'comment', type: 'comment',
id: commentId, id: commentId,
item_id: item_id, item_id: item_id,
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
parent_id: parent_id || null, parent_id: parent_id || null,
body: notifyBody, body: notifyBody,
username: req.session.user, username: req.session.user,
user_id: req.session.id, user_id: req.session.id,
avatar: req.session.avatar, avatar: req.session.avatar,
avatar_file: req.session.avatar_file, avatar_file: req.session.avatar_file,
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
username_color: req.session.username_color, username_color: req.session.username_color,
display_name: req.session.display_name || null, display_name: req.session.display_name || null,
@@ -610,8 +623,7 @@ export default (router, tpl) => {
db.notify('comments', JSON.stringify(livePayload)); db.notify('comments', JSON.stringify(livePayload));
// 2. Sidebar activity update // 2. Sidebar activity update
// Compute is_long using the full content (not the truncated notifyBody) so the const itemVisibility = itemQuery[0]?.visibility ?? 0;
// sidebar renders the correct clamped state immediately on first paint.
const activityIsLong = content.length > 120 const activityIsLong = content.length > 120
|| content.split('\n').length > 2 || content.split('\n').length > 2
|| activityFiles.length > 0 || activityFiles.length > 0
@@ -626,6 +638,10 @@ export default (router, tpl) => {
item_rating_label: ratingLabel, item_rating_label: ratingLabel,
avatar: req.session.avatar, avatar: req.session.avatar,
avatar_file: req.session.avatar_file, avatar_file: req.session.avatar_file,
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
username: req.session.user, username: req.session.user,
username_color: req.session.username_color, username_color: req.session.username_color,
display_name: req.session.display_name || null, display_name: req.session.display_name || null,
@@ -648,8 +664,14 @@ export default (router, tpl) => {
success: true, success: true,
comment: { comment: {
...newComment[0], ...newComment[0],
user_id: req.session.id,
username_color: req.session.username_color || null,
content, content,
files: activityFiles files: activityFiles,
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null
}, },
xd_score: xdRow?.xd_score ?? null, xd_score: xdRow?.xd_score ?? null,
is_new_subscription is_new_subscription
@@ -995,14 +1017,26 @@ export default (router, tpl) => {
// Build mode SQL — replace items.id alias with i.id used in the activity query // Build mode SQL — replace items.id alias with i.id used in the activity query
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id'); const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or '); const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
const excludedTags = req.session ? (req.session.excluded_tags || []) : []; const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
const sessionUser = req.session ? (req.session.user || '').toLowerCase() : null;
const sessionUserId = req.session ? req.session.id : null;
const visibilityFilter = isOwnerOrAdmin
? db``
: (req.session
? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
: db`AND COALESCE(i.visibility, 0) = 0`);
const comments = await db` const comments = await db`
SELECT SELECT
c.*, c.*,
i.mime, i.mime,
i.id as item_id, i.id as item_id,
i.slug as item_slug,
i.dest as item_dest, i.dest as item_dest,
(SELECT ta.tag_id FROM tags_assign ta (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[]) WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
@@ -1010,6 +1044,10 @@ export default (router, tpl) => {
u.user as username, u.user as username,
uo.avatar, uo.avatar,
uo.avatar_file, uo.avatar_file,
uo.banner_file,
uo.banner_position,
uo.banner_size,
uo.banner_repeat,
uo.username_color, uo.username_color,
uo.display_name uo.display_name
FROM comments c FROM comments c
@@ -1019,6 +1057,7 @@ export default (router, tpl) => {
WHERE c.is_deleted = false WHERE c.is_deleted = false
AND i.active = true AND i.active = true
AND i.is_deleted = false AND i.is_deleted = false
${visibilityFilter}
AND ${db.unsafe(modequery)} AND ${db.unsafe(modequery)}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``} ${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``} ${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -1027,9 +1066,15 @@ export default (router, tpl) => {
LIMIT ${limit} OFFSET ${offset} LIMIT ${limit} OFFSET ${offset}
`; `;
// Normalize item_slug based on getEnableItemSlugs()
const processedCommentsList = comments.map(c => ({
...c,
item_slug: getEnableItemSlugs() ? c.item_slug : null
}));
// Fetch comment file attachments // Fetch comment file attachments
const filesMap = new Map(); const filesMap = new Map();
if (comments.length > 0) { if (processedCommentsList.length > 0) {
const commentIds = comments.map(c => c.id); const commentIds = comments.map(c => c.id);
try { try {
const files = await db` const files = await db`

View File

@@ -437,8 +437,9 @@ export default (router) => {
stamp: ~~(Date.now() / 1000), stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired, active: !isApprovalRequired,
is_oc: !!is_oc, is_oc: !!is_oc,
original_filename: original_filename || null original_filename: original_filename || null,
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')} slug: lib.generateSlug(11)
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
RETURNING id RETURNING id
`; `;

View File

@@ -73,7 +73,7 @@ export default (router, tpl) => {
ratings: ratingsArr, ratings: ratingsArr,
mime: mime, mime: mime,
fav: false, fav: false,
session: !!req.session, session: req.session,
user_id: req.session?.id, user_id: req.session?.id,
random: isRandom random: isRandom
}); });
@@ -227,6 +227,16 @@ export default (router, tpl) => {
// Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX // Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX
const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag; const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag;
const layout = req.query?.layout || req.url.qs?.layout || req.cookies.layout || req.session?.layout || 'grid';
const sort = req.query?.sort || req.url.qs?.sort || req.cookies.sort || 'newest';
const timeframe = req.query?.timeframe || req.url.qs?.timeframe || 'all';
const oc = req.query?.oc || req.url.qs?.oc || null;
const pinned = req.query?.pinned || req.url.qs?.pinned || null;
const hasComments = req.query?.has_comments || req.url.qs?.has_comments || null;
const ratingsRaw = req.query?.ratings || req.url.qs?.ratings || req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null;
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({ const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
user: req.params.user, user: req.params.user,
tag: reqTag, tag: reqTag,
@@ -236,7 +246,7 @@ export default (router, tpl) => {
hall: req.params.hall, hall: req.params.hall,
fav: req.params.mode == 'favs', fav: req.params.mode == 'favs',
mode: req.mode, mode: req.mode,
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(), ratings: ratingsArr,
session: req.session, session: req.session,
user_id: req.session?.id, user_id: req.session?.id,
is_admin: req.session?.admin, is_admin: req.session?.admin,
@@ -247,13 +257,39 @@ export default (router, tpl) => {
random: req.cookies.random_mode === '1', random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)), minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)),
lang: req.lang, lang: req.lang,
tagger: req.url.qs?.tagger || null tagger: req.url.qs?.tagger || null,
sort,
timeframe,
oc,
pinned,
hasComments
}); });
const activeRatings = ratingsArr || ['sfw', 'nsfw', 'untagged'];
const rawMimeVal = req.query?.mime || req.url.qs?.mime || req.cookies.mime || req.params.mime || '';
const activeMimes = String(rawMimeVal).split(',').filter(Boolean);
data.layout = layout;
data.sort = sort;
data.timeframe = timeframe;
data.oc = oc;
data.pinned = pinned;
data.hasComments = hasComments;
data.rating_sfw = activeRatings.includes('sfw');
data.rating_nsfw = activeRatings.includes('nsfw');
data.rating_nsfl = activeRatings.includes('nsfl');
data.rating_untagged = activeRatings.includes('untagged');
data.mime_image = activeMimes.includes('image');
data.mime_video = activeMimes.includes('video');
data.mime_audio = activeMimes.includes('audio');
data.mime_flash = activeMimes.includes('flash');
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`); console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`); console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) { 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'); const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({ return res.reply({
code: 403, code: 403,
@@ -390,6 +426,8 @@ export default (router, tpl) => {
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : ''); data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : ''; data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height); data.item_has_dimensions = !!(item.width && item.height);
data.show_repost_row = !!((data.session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (item.is_repost || (item.reposts && item.reposts.length > 0)));
data.item.show_repost_row = data.show_repost_row;
} }
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
@@ -414,11 +452,11 @@ export default (router, tpl) => {
// Specific route for direct item links: /user/:user/:itemid // Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route // This avoids ambiguity with the profile route
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute); router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>[a-zA-Z0-9_-]+)$/, handleGenericRoute);
// Generic router for everything else (Index, Tags, standard User Grids) // 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. // 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)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute); router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
/* </routing-refactor> */ /* </routing-refactor> */
router.get(/^\/(about)$/, (req, res) => { router.get(/^\/(about)$/, (req, res) => {

View File

@@ -114,7 +114,7 @@ export default (router, tpl) => {
} }
const f0ck = await db` const f0ck = await db`
select i.dest, i.mime, i.username, i.id, ta.tag_id select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
from "items" i from "items" i
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2) left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
where i.id = ${id} and i.active = false where i.id = ${id} and i.active = false
@@ -156,6 +156,9 @@ export default (router, tpl) => {
console.error('[MOD APPROVE] Failed to notify user:', err); console.error('[MOD APPROVE] Failed to notify user:', err);
} }
const isPublic = (f0ck[0].visibility || 0) === 0;
if (isPublic) {
// Push to Discord Webhook (Direct) // Push to Discord Webhook (Direct)
try { try {
const discordClient = cfg.clients.find(c => c.type === 'discord'); const discordClient = cfg.clients.find(c => c.type === 'discord');
@@ -217,6 +220,7 @@ export default (router, tpl) => {
console.error('[MOD APPROVE] new_item notify failed:', err); console.error('[MOD APPROVE] new_item notify failed:', err);
} }
} }
}
// Move files to public location // Move files to public location
const movePaths = [ const movePaths = [

View File

@@ -1,6 +1,7 @@
import db from "../sql.mjs"; import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs"; import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { setMotd } from "../motd.mjs"; import { setMotd } from "../motd.mjs";
export const clients = new Set(); export const clients = new Set();
@@ -122,7 +123,7 @@ db.listen('activity', async (payload) => {
// We need the username, avatar, and item mime for the preview // We need the username, avatar, and item mime for the preview
// trigger only gave us user_id and item_id // trigger only gave us user_id and item_id
const [details] = await db` 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, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat, i.mime, i.slug as item_slug, COALESCE(i.visibility, 0) as visibility, LOWER(i.username) as item_username,
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id (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 FROM "user" u
LEFT JOIN user_options uo ON u.id = uo.user_id LEFT JOIN user_options uo ON u.id = uo.user_id
@@ -134,18 +135,35 @@ db.listen('activity', async (payload) => {
data.username = details.username; data.username = details.username;
data.avatar = details.avatar; data.avatar = details.avatar;
data.avatar_file = details.avatar_file; data.avatar_file = details.avatar_file;
data.banner_file = data.banner_file || details.banner_file || null;
data.banner_position = data.banner_position || details.banner_position || null;
data.banner_size = data.banner_size || details.banner_size || null;
data.banner_repeat = data.banner_repeat || details.banner_repeat || null;
data.mime = details.mime; data.mime = details.mime;
data.username_color = details.username_color; data.username_color = details.username_color;
data.display_name = details.display_name || null; data.display_name = details.display_name || null;
data.tag_id = details.tag_id; data.tag_id = details.tag_id;
data.item_slug = getEnableItemSlugs() ? details.item_slug : null;
data.visibility = details.visibility;
data.item_username = details.item_username;
} else { } else {
data.username = 'System'; data.username = 'System';
data.visibility = 0;
data.item_username = null;
} }
// Broadcast to ALL connected clients // Broadcast to clients permitted to view this activity
for (const client of clients) { for (const client of clients) {
const itemVisibility = data.visibility ?? 0;
const isPublic = itemVisibility === 0;
const isCommentAuthor = client.userId && client.userId === data.user_id;
const isItemOwner = client.username && data.item_username && client.username.toLowerCase() === data.item_username.toLowerCase();
const isAdminOrMod = client.admin || client.is_moderator;
if (isPublic || isCommentAuthor || isItemOwner || isAdminOrMod) {
client.send({ type: 'activity', data }); client.send({ type: 'activity', data });
} }
}
} catch (e) { } catch (e) {
console.error('Activity broadcast error:', e); console.error('Activity broadcast error:', e);
} }
@@ -210,10 +228,14 @@ db.listen('motd', (payload) => {
db.listen('new_item', (payload) => { db.listen('new_item', (payload) => {
try { try {
const data = JSON.parse(payload); const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`); const itemVis = data.visibility || 0;
console.log(`[SSE] Broadcasting new_item (id: ${data.id}, visibility: ${itemVis}) to connected clients`);
for (const client of clients) { for (const client of clients) {
const isOwner = client.username && data.username && client.username.toLowerCase() === data.username.toLowerCase();
if (itemVis === 0 || isOwner) {
client.send({ type: 'new_item', data }); client.send({ type: 'new_item', data });
} }
}
} catch (e) { } catch (e) {
console.error('New item broadcast error:', e); console.error('New item broadcast error:', e);
} }
@@ -367,7 +389,7 @@ export default (router, tpl) => {
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null); const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
const notifications = typeFilter const notifications = typeFilter
? await db` ? 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(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name, COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id, COALESCE(u.id, 0) as from_user_id,
@@ -392,7 +414,7 @@ export default (router, tpl) => {
OFFSET ${offset} OFFSET ${offset}
` `
: await db` : 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(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name, COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id, COALESCE(u.id, 0) as from_user_id,
@@ -426,7 +448,7 @@ export default (router, tpl) => {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data; const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason; reason = data.reason || reason;
} }
return { ...n, reason }; return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
}); });
return { return {
@@ -443,7 +465,7 @@ export default (router, tpl) => {
try { try {
const notifications = await db` 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(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name, COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id, COALESCE(u.id, 0) as from_user_id,
@@ -480,7 +502,7 @@ export default (router, tpl) => {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data; const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason; reason = data.reason || reason;
} }
return { ...n, reason }; return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
}); });
return res.reply({ return res.reply({
@@ -588,6 +610,8 @@ export default (router, tpl) => {
avatar_file: req.session?.avatar_file || null, avatar_file: req.session?.avatar_file || null,
avatar: req.session?.avatar || null, avatar: req.session?.avatar || null,
username_color: req.session?.username_color || null, username_color: req.session?.username_color || null,
admin: !!req.session?.admin,
is_moderator: !!req.session?.is_moderator,
receive_system_notifications: req.session?.receive_system_notifications !== false, receive_system_notifications: req.session?.receive_system_notifications !== false,
receive_user_notifications: req.session?.receive_user_notifications !== false, receive_user_notifications: req.session?.receive_user_notifications !== false,
do_not_disturb: req.session?.do_not_disturb === true, do_not_disturb: req.session?.do_not_disturb === true,

View File

@@ -97,11 +97,11 @@ export default (router, tpl) => {
`; `;
const favotop = await db` const favotop = await db`
select favorites.item_id, count(*) favs select favorites.item_id as id, items.slug, count(*) favs
from favorites from favorites
join items on items.id = favorites.item_id join items on items.id = favorites.item_id
where items.active = true where items.active = true
group by favorites.item_id group by favorites.item_id, items.slug
having count(*) > 1 having count(*) > 1
order by favs desc order by favs desc
limit 10 limit 10
@@ -110,7 +110,7 @@ export default (router, tpl) => {
let xdtop = []; let xdtop = [];
if (config.websrv.enable_xd_score) { if (config.websrv.enable_xd_score) {
const xdRows = await db` const xdRows = await db`
select id, xd_score select id, slug, xd_score
from items from items
where active = true and is_deleted = false and xd_score > 0 where active = true and is_deleted = false and xd_score > 0
order by xd_score desc order by xd_score desc

View File

@@ -1,6 +1,7 @@
import cfg from "../config.mjs"; import cfg from "../config.mjs";
import db from "../sql.mjs"; import db from "../sql.mjs";
import lib from "../lib.mjs"; import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
export default (router, tpl) => { export default (router, tpl) => {
// Serve the scroller page // Serve the scroller page
@@ -115,10 +116,13 @@ export default (router, tpl) => {
try { try {
const slug = '%' + lib.slugify(q) + '%'; const slug = '%' + lib.slugify(q) + '%';
const rows = await db` const rows = await db`
SELECT t.tag, t.normalized, COUNT(ta.item_id) as uses SELECT t.tag, t.normalized, COUNT(DISTINCT items.id) as uses
FROM tags t FROM tags t
JOIN tags_assign ta ON ta.tag_id = t.id JOIN tags_assign ta ON ta.tag_id = t.id
JOIN items ON items.id = ta.item_id
WHERE t.id > 2 WHERE t.id > 2
AND items.active = true
AND items.is_deleted = false
AND lower(t.normalized) ILIKE ${slug} AND lower(t.normalized) ILIKE ${slug}
GROUP BY t.tag, t.normalized GROUP BY t.tag, t.normalized
ORDER BY uses DESC ORDER BY uses DESC
@@ -160,7 +164,7 @@ export default (router, tpl) => {
const excludeIds = qs.exclude const excludeIds = qs.exclude
? qs.exclude.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 500) ? qs.exclude.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 500)
: []; : [];
const modeQuery = lib.getMode(mode ?? 0); const modeQuery = f0cklib.computeBaseMode(mode ?? 0, null, req.session);
const nsfp = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null; const nsfp = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
// anchor= is a specific item ID to include first in the batch (used for hash-based deep links) // anchor= is a specific item ID to include first in the batch (used for hash-based deep links)
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null; const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
@@ -236,6 +240,13 @@ export default (router, tpl) => {
${sessionId ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sessionId})` : db`false`} AS is_faved ${sessionId ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sessionId})` : db`false`} AS is_faved
`; `;
const sid = req.session ? +req.session.id : null; const sid = req.session ? +req.session.id : null;
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
const sessionUser = req.session?.user ? req.session.user.toLowerCase() : null;
const visibilityFilter = isOwnerOrAdmin
? db``
: (sessionUser
? db`AND (COALESCE(items.visibility, 0) = 0 OR LOWER(items.username) = ${sessionUser})`
: db`AND COALESCE(items.visibility, 0) = 0`);
try { try {
let rows; let rows;
@@ -268,6 +279,7 @@ export default (router, tpl) => {
WHERE WHERE
${db.unsafe(modeQuery)} ${db.unsafe(modeQuery)}
AND items.active = true AND items.active = true
${visibilityFilter}
${excludeSwfSQL} ${excludeSwfSQL}
${excludePdfSQL} ${excludePdfSQL}
${excludeArchiveSQL} ${excludeArchiveSQL}
@@ -292,6 +304,7 @@ export default (router, tpl) => {
WHERE WHERE
${db.unsafe(modeQuery)} ${db.unsafe(modeQuery)}
AND items.active = true AND items.active = true
${visibilityFilter}
${excludeSwfSQL} ${excludeSwfSQL}
${excludePdfSQL} ${excludePdfSQL}
${excludeArchiveSQL} ${excludeArchiveSQL}

View File

@@ -24,9 +24,16 @@ export default (router, tpl) => {
`; `;
// Get custom avatar file and banner file if exists // Get custom avatar file and banner file if exists
const userOptions = (await db` let userOptions = null;
select avatar_file, banner_file from user_options where user_id = ${+req.session.id} try {
userOptions = (await db`
select avatar_file, banner_file, banner_position, banner_size from user_options where user_id = ${+req.session.id}
`)[0]; `)[0];
} catch (err) {
userOptions = (await db`
select avatar_file from user_options where user_id = ${+req.session.id}
`)[0];
}
// Get full user info // Get full user info
const user = (await db` const user = (await db`

View File

@@ -34,7 +34,7 @@ export default (router, tpl) => {
const subs = await db` const subs = await db`
SELECT SELECT
s.created_at as sub_date, 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 FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true 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 => ({ const items = subs.map(i => ({
id: i.id, id: i.id,
slug: i.slug || null,
user: i.uploader_name || 'System', user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(), sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp` thumb: `/t/${i.id}.webp`
@@ -108,7 +109,7 @@ export default (router, tpl) => {
const subs = await db` const subs = await db`
SELECT SELECT
s.created_at as sub_date, 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 FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true 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 => ({ const items = subs.map(i => ({
id: i.id, id: i.id,
slug: i.slug || null,
user: i.uploader_name || 'System', user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(), sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp` thumb: `/t/${i.id}.webp`

View File

@@ -31,7 +31,10 @@ export async function regenerateTagImage(tag, mode) {
JOIN tags_assign ta ON ta.item_id = i.id JOIN tags_assign ta ON ta.item_id = i.id
JOIN tags t ON t.id = ta.tag_id JOIN tags t ON t.id = ta.tag_id
${modeFilter} ${modeFilter}
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true WHERE (t.tag = ${tag} OR t.normalized = ${tag})
AND i.active = true
AND i.is_deleted = false
AND COALESCE(i.visibility, 0) = 0
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT 3 LIMIT 3
`; `;

View File

@@ -9,7 +9,24 @@ export default (router, tpl) => {
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => { const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : []; const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
const isGuest = !sessionObj; const isGuest = !sessionObj;
const modequery = lib.getMode(mode); let baseMode = lib.getMode(mode);
if (isGuest) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
if (cfg.websrv.public_nsfw) {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
} else {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (!cfg.websrv.public_untagged && mode === 2) {
baseMode = "1 = 0";
}
}
const modequery = baseMode;
let restrictedFilter = db``; let restrictedFilter = db``;
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) { if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
@@ -35,6 +52,8 @@ export default (router, tpl) => {
JOIN tags_assign ta ON t.id = ta.tag_id JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id JOIN items ON items.id = ta.item_id
WHERE items.active = true WHERE items.active = true
AND items.is_deleted = false
AND COALESCE(items.visibility, 0) = 0
AND t.id NOT IN (1, 2) AND t.id NOT IN (1, 2)
AND ${db.unsafe(modequery)} AND ${db.unsafe(modequery)}
${restrictedFilter} ${restrictedFilter}
@@ -58,6 +77,8 @@ export default (router, tpl) => {
JOIN items ON items.id = ta.item_id JOIN items ON items.id = ta.item_id
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%' WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
AND items.active = true AND items.active = true
AND items.is_deleted = false
AND COALESCE(items.visibility, 0) = 0
AND ${db.unsafe(modequery)} AND ${db.unsafe(modequery)}
${restrictedFilter} ${restrictedFilter}
${userExcludeFilter} ${userExcludeFilter}

View File

@@ -92,7 +92,7 @@ export default (router, tpl) => {
const data = await f0cklib.getf0cks({ const data = await f0cklib.getf0cks({
page: req.params.page, page: req.params.page,
mode: req.mode, mode: req.mode,
session: !!req.session, session: req.session,
exclude: req.session?.excluded_tags || [], exclude: req.session?.excluded_tags || [],
user_id: req.session?.id, user_id: req.session?.id,
userHall: slug, userHall: slug,
@@ -138,7 +138,7 @@ export default (router, tpl) => {
const data = await f0cklib.getf0ck({ const data = await f0cklib.getf0ck({
itemid: req.params.itemid, itemid: req.params.itemid,
mode: req.mode, mode: req.mode,
session: !!req.session, session: req.session,
exclude: req.session?.excluded_tags || [], exclude: req.session?.excluded_tags || [],
user_id: req.session?.id, user_id: req.session?.id,
userHall: slug, userHall: slug,
@@ -260,7 +260,7 @@ export default (router, tpl) => {
FROM items i FROM items i
JOIN user_halls_assign uha ON uha.item_id = i.id JOIN user_halls_assign uha ON uha.item_id = i.id
${modeFilter} ${modeFilter}
WHERE uha.hall_id = ${hall.id} AND i.active = true WHERE uha.hall_id = ${hall.id} AND i.active = true AND COALESCE(i.visibility, 0) = 0
ORDER BY RANDOM() ORDER BY RANDOM()
LIMIT 3 LIMIT 3
`; `;

View File

@@ -9,7 +9,24 @@ export default (router, tpl) => {
const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => { const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => {
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : []; const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
const isGuest = !sessionObj; const isGuest = !sessionObj;
const modequery = lib.getMode(mode); let baseMode = lib.getMode(mode);
if (isGuest) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
if (cfg.websrv.public_nsfw) {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
} else {
baseMode = cfg.websrv.public_untagged
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
: "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (!cfg.websrv.public_untagged && mode === 2) {
baseMode = "1 = 0";
}
}
const modequery = baseMode;
let restrictedFilter = db``; let restrictedFilter = db``;
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) { if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
@@ -35,6 +52,8 @@ export default (router, tpl) => {
JOIN tags_assign ta ON t.id = ta.tag_id JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id JOIN items ON items.id = ta.item_id
WHERE items.active = true WHERE items.active = true
AND items.is_deleted = false
AND COALESCE(items.visibility, 0) = 0
AND t.id NOT IN (1, 2) AND t.id NOT IN (1, 2)
AND ta.user_id = ${userId} AND ta.user_id = ${userId}
AND ${db.unsafe(modequery)} AND ${db.unsafe(modequery)}
@@ -59,6 +78,8 @@ export default (router, tpl) => {
JOIN items ON items.id = ta.item_id JOIN items ON items.id = ta.item_id
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%' WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
AND items.active = true AND items.active = true
AND items.is_deleted = false
AND COALESCE(items.visibility, 0) = 0
AND ta.user_id = ${userId} AND ta.user_id = ${userId}
AND ${db.unsafe(modequery)} AND ${db.unsafe(modequery)}
${restrictedFilter} ${restrictedFilter}

View File

@@ -26,14 +26,17 @@ export default new class {
* @returns {string} * @returns {string}
*/ */
getRealIP(req) { getRealIP(req) {
let ip = req.headers['cf-connecting-ip'] || let rawIp = req.headers['cf-connecting-ip'] ||
req.headers['true-client-ip'] || req.headers['true-client-ip'] ||
req.headers['x-client-ip'] || req.headers['x-client-ip'] ||
req.headers['x-real-ip'] || req.headers['x-real-ip'] ||
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) || req.headers['x-forwarded-for'] ||
req.socket.remoteAddress; req.socket?.remoteAddress;
if (!ip) return "unknown"; if (!rawIp) return "unknown";
// If the header contains a comma-separated proxy chain (e.g. "IP1, IP2"), extract the client IP (first element)
let ip = String(rawIp).split(',')[0].trim();
// Handle IPv6 loopback and mapped IPv4 // Handle IPv6 loopback and mapped IPv4
if (ip === "::1") ip = "127.0.0.1"; if (ip === "::1") ip = "127.0.0.1";

View File

@@ -1,4 +1,6 @@
import cfg from "./config.mjs"; import cfg from "./config.mjs";
import db from "./sql.mjs";
import lib from "./lib.mjs";
let manual_approval = true; let manual_approval = true;
let min_tags = 3; let min_tags = 3;
@@ -14,9 +16,37 @@ let enable_pdf = false;
let enable_cleanup = false; let enable_cleanup = false;
let cleanup_start_date = ''; let cleanup_start_date = '';
let cleanup_end_date = ''; let cleanup_end_date = '';
let cleanup_include_engaged = false;
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode; export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
export const setShitpostMode = (val) => {}; // No-op, strictly config-based export const setShitpostMode = (val) => {}; // No-op, strictly config-based
export const getEnableExpiringUploads = () => {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) return false;
return true;
};
export const getEnableItemSlugs = () => {
if (cfg.enable_item_slugs === false || cfg.websrv?.enable_item_slugs === false) return false;
return true;
};
export const ensureAllItemsHaveSlugs = async () => {
try {
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
if (!rows || rows.length === 0) return;
console.log(`[SLUG_BACKFILL] Found ${rows.length} item(s) missing slugs. Backfilling...`);
for (const row of rows) {
const newSlug = lib.generateSlug(11);
await db`UPDATE items SET slug = ${newSlug} WHERE id = ${row.id} AND (slug IS NULL OR slug = '')`;
}
console.log(`[SLUG_BACKFILL] Successfully backfilled ${rows.length} item slug(s).`);
} catch (err) {
console.error('[SLUG_BACKFILL] Error during slug backfill:', err.message);
}
};
export const getEnableCleanup = () => { export const getEnableCleanup = () => {
if (cfg.websrv.enable_cleanup === false) return false; if (cfg.websrv.enable_cleanup === false) return false;
return enable_cleanup; return enable_cleanup;
@@ -29,6 +59,9 @@ export const setCleanupStartDate = (val) => cleanup_start_date = val || '';
export const getCleanupEndDate = () => cleanup_end_date; export const getCleanupEndDate = () => cleanup_end_date;
export const setCleanupEndDate = (val) => cleanup_end_date = val || ''; export const setCleanupEndDate = (val) => cleanup_end_date = val || '';
export const getCleanupIncludeEngaged = () => cleanup_include_engaged;
export const setCleanupIncludeEngaged = (val) => cleanup_include_engaged = !!val;
export const getEnablePdf = () => enable_pdf; export const getEnablePdf = () => enable_pdf;
export const setEnablePdf = (val) => enable_pdf = !!val; export const setEnablePdf = (val) => enable_pdf = !!val;

View File

@@ -34,7 +34,7 @@ export default async bot => {
rows = await db` rows = await db`
select id, mime, username, size select id, mime, username, size
from "items" from "items"
where id >= ${randomId} and active = true where id >= ${randomId} and active = true and coalesce(visibility, 0) = 0
order by id asc order by id asc
limit 1 limit 1
`; `;
@@ -43,7 +43,7 @@ export default async bot => {
rows = await db` rows = await db`
select id, mime, username, size select id, mime, username, size
from "items" from "items"
where active = true where active = true and coalesce(visibility, 0) = 0
order by id asc order by id asc
limit 1 limit 1
`; `;
@@ -54,7 +54,7 @@ export default async bot => {
rows = await db` rows = await db`
select id, mime, username, size select id, mime, username, size
from "items" from "items"
where where active = true and coalesce(visibility, 0) = 0 and
${args.map(a => a.charAt(0) === "!" ${args.map(a => a.charAt(0) === "!"
? db`username not ilike ${a.slice(1)}` ? db`username not ilike ${a.slice(1)}`
: db`username ilike ${a}` : db`username ilike ${a}`

View File

@@ -752,8 +752,9 @@ export default async bot => {
userchannel: e.channel, userchannel: e.channel,
usernetwork: e.network, usernetwork: e.network,
stamp: ~~(new Date() / 1000), stamp: ~~(new Date() / 1000),
active: !getManualApproval() active: !getManualApproval(),
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')} slug: lib.generateSlug(11)
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
`; `;
const itemid = await queue.getItemID(filename); const itemid = await queue.getItemID(filename);
@@ -858,8 +859,9 @@ export default async bot => {
userchannel: e.channel, userchannel: e.channel,
usernetwork: e.network, usernetwork: e.network,
stamp: ~~(new Date() / 1000), stamp: ~~(new Date() / 1000),
active: !getManualApproval() active: !getManualApproval(),
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')} slug: lib.generateSlug(11)
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
`; `;
const itemid = await queue.getItemID(filename); const itemid = await queue.getItemID(filename);

View File

@@ -20,9 +20,11 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds } from "./inc/settings.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, ensureAllItemsHaveSlugs } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs"; import { createI18n } from "./inc/i18n.mjs";
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
import security from "./inc/security.mjs"; import security from "./inc/security.mjs";
import { createRequire } from 'module'; import { createRequire } from 'module';
@@ -432,6 +434,31 @@ process.on('uncaughtException', err => {
} }
} }
// Wait for database & schema initialization to complete (auto-bootstrap if tables are missing)
let dbReady = false;
let dbAttempts = 0;
while (!dbReady && dbAttempts < 30) {
try {
dbAttempts++;
const tableCheck = await db`SELECT 1 FROM information_schema.tables WHERE table_name = 'items' LIMIT 1`;
if (tableCheck.length === 0) {
console.log('[BOOT] Database tables missing! Initializing schema from migrations/f0ckm_schema.sql...');
await db.file('./migrations/f0ckm_schema.sql');
console.log('[BOOT] Database schema initialization complete!');
}
dbReady = true;
} catch (e) {
if (dbAttempts === 1) {
console.log(`[BOOT] Waiting for database connection... (${e.message})`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
if (dbReady) {
console.log(`[BOOT] Database schema ready (took ${dbAttempts} attempt(s))`);
} else {
console.error('[BOOT] Database schema check timed out after 30 attempts!');
}
const timeout = 10000; // 10s module load timeout const timeout = 10000; // 10s module load timeout
// Ensure clients are resolved (Safe-guard for Docker/Node versions) // Ensure clients are resolved (Safe-guard for Docker/Node versions)
@@ -467,9 +494,27 @@ process.on('uncaughtException', err => {
self._trigger.set(trigger.name, new self.trigger(trigger)); self._trigger.set(trigger.name, new self.trigger(trigger));
}); });
// Auto-migrate schema: ensure user_options & items columns exist
const runMigration = async (query) => {
try { await query; } catch (e) { /* ignore if table doesn't exist yet */ }
};
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL`);
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center'`);
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover'`);
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_repeat character varying(50) DEFAULT 'repeat'`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text DEFAULT NULL`);
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text DEFAULT NULL`);
// Initial halls cache (only if halls are enabled) // Initial halls cache (only if halls are enabled)
if (cfg.websrv.halls_enabled !== false) { if (cfg.websrv.halls_enabled !== false) {
try {
await updateHallsCache(); await updateHallsCache();
} catch (e) {
// ignore during early init
}
} }
// Log feature flags // Log feature flags
@@ -516,6 +561,37 @@ process.on('uncaughtException', err => {
} }
}); });
// Global CORS & OPTIONS preflight handler for API routes (enables standalone config_editor.html)
app.use(async (req, res) => {
if (req.url?.pathname?.startsWith('/api/')) {
const origin = req.headers?.origin || '*';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With, X-CSRF-Token, Authorization');
if (req.method === 'OPTIONS') {
res.writeHead(204).end();
req.url.pathname = '/handled_options_bypass';
return;
}
}
});
// Serve standalone config_editor.html statically
app.use(async (req, res) => {
if (req.method === 'GET' && (req.url?.pathname === '/config_editor.html' || req.url?.pathname === '/config.html')) {
try {
const filePath = path.resolve(process.cwd(), "config_editor.html");
const content = await fs.promises.readFile(filePath, "utf-8");
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }).end(content);
req.url.pathname = '/handled_config_editor_bypass';
} catch (err) {
res.writeHead(404).end("config_editor.html not found");
}
}
});
// Cache-Control headers for static assets. // Cache-Control headers for static assets.
// flummpress router.static() sends no caching headers, which forces Chrome to // flummpress router.static() sends no caching headers, which forces Chrome to
// re-fetch all thumbnails on every grid visit even when they haven't changed. // re-fetch all thumbnails on every grid visit even when they haven't changed.
@@ -672,7 +748,7 @@ process.on('uncaughtException', err => {
user = [_cachedRow]; user = [_cachedRow];
} else { } else {
user = await db` 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".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "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" from "user_sessions"
left join "user" on "user".id = "user_sessions".user_id left join "user" on "user".id = "user_sessions".user_id
left join "user_options" on "user_options".user_id = "user_sessions".user_id left join "user_options" on "user_options".user_id = "user_sessions".user_id
@@ -875,13 +951,28 @@ process.on('uncaughtException', err => {
} }
}); });
// CSRF validation helper — used by route handlers that have already populated req.session // Intercept app.readBody to allow memoized reading of request body.
// NOTE: Cannot be used in flummpress app.use() middlewares for upload/avatar bypass handlers // flummpress runs app.use() in parallel via Promise.all before routing/body-reading,
// because flummpress runs ALL middlewares in parallel (Promise.all), so the session // so validateCsrf needs to be able to read req.post without breaking subsequent router handler body parsing.
// middleware hasn't finished by the time these run. Those handlers validate CSRF inline. const _originalReadBody = app.readBody.bind(app);
const validateCsrf = (req, res) => { app.readBody = async (req) => {
if (req.post !== undefined) return req.post;
return await _originalReadBody(req);
};
// CSRF validation helper — used by route handlers and global middleware
const validateCsrf = async (req, res) => {
if (req.session && req.session.csrf_token) { if (req.session && req.session.csrf_token) {
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token; let token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.csrf_token;
// If header/query token is missing and body is not parsed yet on a non-GET method, parse it now
if (!token && req.post === undefined && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
try {
req.post = await app.readBody(req);
token = req.post?.csrf_token;
} catch (e) {}
}
if (!token || token !== req.session.csrf_token) { if (!token || token !== req.session.csrf_token) {
console.error(`[CSRF] Blocked ${req.method} ${req.url.pathname} for user ${req.session.user}. Reason: ${!token ? 'Missing token' : 'Token mismatch'}`); console.error(`[CSRF] Blocked ${req.method} ${req.url.pathname} for user ${req.session.user}. Reason: ${!token ? 'Missing token' : 'Token mismatch'}`);
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' })); res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' }));
@@ -903,7 +994,7 @@ process.on('uncaughtException', err => {
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return; if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
// User hall image upload is handled by bypass middleware below // User hall image upload is handled by bypass middleware below
if (cfg.websrv.userhalls_enabled !== false && cfg.websrv.enable_userhall_image_upload !== false && req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return; if (cfg.websrv.userhalls_enabled !== false && cfg.websrv.enable_userhall_image_upload !== false && req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return;
if (!validateCsrf(req, res)) return; if (!(await validateCsrf(req, res))) return;
}); });
// Bypass middleware for direct upload handling // Bypass middleware for direct upload handling
@@ -1108,11 +1199,11 @@ process.on('uncaughtException', err => {
let gitHash = process.env.GIT_HASH || 'unknown'; let gitHash = process.env.GIT_HASH || 'unknown';
if (gitHash === 'unknown') { if (gitHash === 'unknown') {
try { try {
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(); const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim(); const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
gitHash = `${branch}-${hash}`; gitHash = `${branch}-${hash}`;
} catch (e) { } catch (e) {
// console.warn('Could not get git hash:', e.message); // Not a git repository
} }
} }
console.log('Git hash:', gitHash); console.log('Git hash:', gitHash);
@@ -1224,6 +1315,12 @@ process.on('uncaughtException', err => {
setCleanupEndDate(endSetting[0].value); setCleanupEndDate(endSetting[0].value);
} }
console.log(`[BOOT] Cleanup End Date: ${getCleanupEndDate()}`); console.log(`[BOOT] Cleanup End Date: ${getCleanupEndDate()}`);
const engagedSetting = await db`SELECT value FROM site_settings WHERE key = 'cleanup_include_engaged' LIMIT 1`;
if (engagedSetting.length > 0) {
setCleanupIncludeEngaged(engagedSetting[0].value === 'true');
}
console.log(`[BOOT] Cleanup Include Engaged: ${getCleanupIncludeEngaged()}`);
} catch (e) { } catch (e) {
console.warn(`[BOOT] Cleanup settings fetch failed:`, e.message); console.warn(`[BOOT] Cleanup settings fetch failed:`, e.message);
setEnableCleanup(!!cfg.websrv.enable_cleanup); setEnableCleanup(!!cfg.websrv.enable_cleanup);
@@ -1309,6 +1406,9 @@ process.on('uncaughtException', err => {
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message); console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
} }
// Ensure all items in database have a unique slug backfilled
ensureAllItemsHaveSlugs();
const globals = { const globals = {
lul: cfg.websrv.lul, lul: cfg.websrv.lul,
themes: cfg.websrv.themes, themes: cfg.websrv.themes,
@@ -1362,10 +1462,16 @@ process.on('uncaughtException', err => {
default_font: cfg.websrv.default_font || "", default_font: cfg.websrv.default_font || "",
site_description: cfg.websrv.description || "The webs dumpster", site_description: cfg.websrv.description || "The webs dumpster",
enable_nsfl: !!cfg.enable_nsfl, enable_nsfl: !!cfg.enable_nsfl,
enable_private_uploads: cfg.enable_private_uploads !== false,
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
get enable_item_slugs() { return getEnableItemSlugs(); },
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
nsfl_tag_id: cfg.nsfl_tag_id || 3, 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'], 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 || []), themes_json: JSON.stringify(cfg.websrv.themes || []),
enable_profile_description: !!cfg.websrv.enable_profile_description, enable_profile_description: !!cfg.websrv.enable_profile_description,
expose_repost_links_to_guests: !!(cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links),
get private_messages() { return getPrivateMessages(); }, get private_messages() { return getPrivateMessages(); },
get dm_attachments() { return getDmAttachments(); }, get dm_attachments() { return getDmAttachments(); },
get dm_unencrypted() { return getDmUnencrypted(); }, get dm_unencrypted() { return getDmUnencrypted(); },
@@ -1374,6 +1480,7 @@ process.on('uncaughtException', err => {
get enable_cleanup() { return getEnableCleanup(); }, get enable_cleanup() { return getEnableCleanup(); },
get cleanup_start_date() { return getCleanupStartDate(); }, get cleanup_start_date() { return getCleanupStartDate(); },
get cleanup_end_date() { return getCleanupEndDate(); }, get cleanup_end_date() { return getCleanupEndDate(); },
get cleanup_include_engaged() { return getCleanupIncludeEngaged(); },
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false, matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false, telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false,
ts: Date.now(), ts: Date.now(),
@@ -1482,23 +1589,18 @@ process.on('uncaughtException', err => {
globals.recaptcha_enabled = perRequestRecaptcha; globals.recaptcha_enabled = perRequestRecaptcha;
// Resolve per-request infobox preference // Resolve per-request infobox preference
// Guests always get false — the alternative infobox is a logged-in user preference only
const activeReq = req || effectiveReq; const activeReq = req || effectiveReq;
const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean') const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
? activeReq.session.use_alternative_infobox ? activeReq.session.use_alternative_infobox
: (activeReq && !activeReq.session
? false
: (data && typeof data.user_alternative_infobox === 'boolean' : (data && typeof data.user_alternative_infobox === 'boolean'
? data.user_alternative_infobox ? data.user_alternative_infobox
: (cfg.websrv.user_alternative_infobox !== false))); : (cfg.websrv.user_alternative_infobox !== false));
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean') const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
? activeReq.session.use_alternative_steuerung ? activeReq.session.use_alternative_steuerung
: (activeReq && !activeReq.session
? false
: (data && typeof data.user_alternative_steuerung === 'boolean' : (data && typeof data.user_alternative_steuerung === 'boolean'
? data.user_alternative_steuerung ? data.user_alternative_steuerung
: (cfg.websrv.user_alternative_steuerung !== false))); : (cfg.websrv.user_alternative_steuerung !== false));
data = Object.assign({}, globals, data || {}, { data = Object.assign({}, globals, data || {}, {
t: perRequestT, t: perRequestT,
@@ -1507,6 +1609,8 @@ process.on('uncaughtException', err => {
user_alternative_infobox: useAltInfobox, user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung, user_alternative_steuerung: useAltSteuerung,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false, user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
comment_banner_enabled: (cfg.websrv.user_banner_enabled !== false) && (cfg.websrv.comment_banner_enabled !== false),
comment_banner_max_height: typeof cfg.websrv.comment_banner_max_height === 'number' ? cfg.websrv.comment_banner_max_height : 300,
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number') comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
? activeReq.session.comment_display_mode ? activeReq.session.comment_display_mode
: (data && typeof data.comment_display_mode === 'number' : (data && typeof data.comment_display_mode === 'number'
@@ -1527,7 +1631,8 @@ process.on('uncaughtException', err => {
if (!data.url) data.url = activeReq.url; if (!data.url) data.url = activeReq.url;
data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false; data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
data.user_logged_in_bool = !!activeReq.session; data.user_logged_in_bool = !!activeReq.session;
data.csrf_token = activeReq.session?.csrf_token || ''; data.csrf_token = activeReq.session?.csrf_token || data.csrf_token || '';
globals.csrf_token = data.csrf_token;
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1)); data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1)); data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload; data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
@@ -1536,6 +1641,7 @@ process.on('uncaughtException', err => {
data.theme = data.theme || cfg.websrv.theme || 'f0ck'; data.theme = data.theme || cfg.websrv.theme || 'f0ck';
data.user_strict_bool = false; data.user_strict_bool = false;
data.user_logged_in_bool = false; data.user_logged_in_bool = false;
globals.csrf_token = data.csrf_token || '';
} }
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req); return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
@@ -1568,6 +1674,11 @@ process.on('uncaughtException', err => {
setTimeout(cleanupStaleSessions, 30_000); setTimeout(cleanupStaleSessions, 30_000);
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS); setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
// Expiring uploads background purge (every 30s)
setTimeout(purgeExpiredUploads, 10_000);
setInterval(purgeExpiredUploads, 30_000);
// ── Inactivity ban — permanently ban accounts that haven't logged in for N days // ── Inactivity ban — permanently ban accounts that haven't logged in for N days
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely. // Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0; const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0;

View File

@@ -78,6 +78,7 @@ export async function handleKoepfeUpload(req, res) {
throw new Error('Unsupported format'); throw new Error('Unsupported format');
} }
await fs.mkdir(cfg.paths.koepfe, { recursive: true });
const newName = crypto.randomBytes(8).toString('hex') + ext; const newName = crypto.randomBytes(8).toString('hex') + ext;
const targetPath = path.join(cfg.paths.koepfe, newName); const targetPath = path.join(cfg.paths.koepfe, newName);

View File

@@ -6,9 +6,11 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
import queue from "./inc/queue.mjs"; import queue from "./inc/queue.mjs";
import path from "path"; import path from "path";
import https from "https"; import https from "https";
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs"; import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs"; import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import f0cklib from "./inc/routeinc/f0cklib.mjs"; import f0cklib from "./inc/routeinc/f0cklib.mjs";
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf. // Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
// Adding a new archive type to config.json is sufficient; no code change needed. // Adding a new archive type to config.json is sufficient; no code change needed.
@@ -19,6 +21,26 @@ const ARCHIVE_MIMES = new Set(
); );
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime); const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
const autoTagsFromUrl = (urlString) => {
const tags = [];
try {
const { hostname } = new URL(urlString);
const host = hostname.replace(/:\d+$/, '').toLowerCase();
const parts = host.split('.');
const shortSlds = new Set(['co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne']);
let domain;
if (parts.length >= 3 && shortSlds.has(parts[parts.length - 2])) {
domain = parts.slice(-3).join('.');
} else {
domain = parts.slice(-2).join('.');
}
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
tags.push('youtube');
}
} catch (e) {}
return [...new Set(tags)];
};
// Helper for JSON response // Helper for JSON response
const sendJson = (res, data, code = 200) => { const sendJson = (res, data, code = 200) => {
res.writeHead(code, { 'Content-Type': 'application/json' }); res.writeHead(code, { 'Content-Type': 'application/json' });
@@ -34,6 +56,8 @@ db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text`.catch(() => {});
// One-time migration: add width/height columns for image and video dimension storage // One-time migration: add width/height columns for image and video dimension storage
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer`.catch(() => {}); db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer`.catch(() => {});
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer`.catch(() => {}); db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer`.catch(() => {});
db`ALTER TABLE items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL`.catch(() => {});
// One-time migration: widen checksum column to varchar(255) for SHA-256 + bypass suffix support // One-time migration: widen checksum column to varchar(255) for SHA-256 + bypass suffix support
// (old schema had varchar(40), sized for SHA-1 — SHA-256 is 64 chars and bypass suffix adds more) // (old schema had varchar(40), sized for SHA-1 — SHA-256 is 64 chars and bypass suffix adds more)
@@ -43,6 +67,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 items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
db`ALTER TABLE comment_files 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) => { export const handleUpload = async (req, res, self) => {
// Manual session lookup is required here because this handler is called from a // Manual session lookup is required here because this handler is called from a
// bypass middleware that runs in parallel with the main session middleware. // bypass middleware that runs in parallel with the main session middleware.
@@ -101,15 +129,7 @@ export const handleUpload = async (req, res, self) => {
try { try {
const contentType = req.headers['content-type'] || ''; const contentType = req.headers['content-type'] || '';
let parts = {};
// Robust boundary extraction (handles both quoted and unquoted boundaries)
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
if (!contentType || !contentType.includes('multipart/form-data') || !boundaryMatch) {
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
const boundary = boundaryMatch[1] || boundaryMatch[2];
// Determine max file size early for collectBody // Determine max file size early for collectBody
let effectiveMaxBytes = cfg.main.maxfilesize || (150 * 1024 * 1024); let effectiveMaxBytes = cfg.main.maxfilesize || (150 * 1024 * 1024);
@@ -124,31 +144,106 @@ export const handleUpload = async (req, res, self) => {
throw bodyErr; throw bodyErr;
} }
const parts = parseMultipart(body, boundary); if (contentType.includes('multipart/form-data')) {
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
if (!boundaryMatch) {
return sendJson(res, { success: false, msg: 'Invalid multipart boundary' }, 400);
}
const boundary = boundaryMatch[1] || boundaryMatch[2];
parts = parseMultipart(body, boundary);
} else if (contentType.includes('application/json')) {
try {
parts = JSON.parse(body.toString());
} catch (e) {
return sendJson(res, { success: false, msg: 'Invalid JSON body' }, 400);
}
} else if (contentType.includes('application/x-www-form-urlencoded')) {
const params = new URLSearchParams(body.toString());
for (const [key, value] of params.entries()) {
parts[key] = value;
}
} else {
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
}
// Validate required fields // Validate required fields
const file = parts.file; let file = (typeof parts.file === 'object' && parts.file !== null && parts.file.data) ? parts.file : null;
let inputUrl = (typeof parts.url === 'string' && parts.url.trim()) ? parts.url.trim() : null;
if (!inputUrl && typeof parts.file === 'string' && /^https?:\/\//i.test(parts.file.trim())) {
inputUrl = parts.file.trim();
}
if (inputUrl) {
file = null;
try {
const parsed = new URL(inputUrl);
if (parsed.searchParams.has('igsh')) {
parsed.searchParams.delete('igsh');
inputUrl = parsed.toString();
}
} catch (e) {}
}
const rating = parts.rating; const rating = parts.rating;
const tagsRaw = parts.tags; const tagsRaw = parts.tags;
const comment = parts.comment ? parts.comment.trim() : ''; const comment = parts.comment ? String(parts.comment).trim() : '';
const rawTitle = parts.title ? parts.title.trim() : ''; const rawTitle = parts.title ? String(parts.title).trim() : '';
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null; const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1'); const is_oc = (parts.is_oc === true || parts.is_oc === 'true' || parts.is_oc === '1');
const is_shitpost = (parts.is_shitpost === true || parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true; if (!file && !inputUrl) {
return sendJson(res, { success: false, msg: 'No file or URL provided' }, 400);
}
if (inputUrl && cfg.websrv.web_url_upload === false) {
return sendJson(res, { success: false, msg: 'URL uploads are disabled' }, 403);
}
// Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference
let targetVisibility = 0;
if (cfg.enable_private_uploads !== false) {
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
? cfg.default_upload_visibility
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
if (!allowUserOverride) {
targetVisibility = sysDefault;
} else {
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 !== undefined && req.session?.default_upload_visibility !== null)
? req.session.default_upload_visibility
: sysDefault;
}
}
}
const rawExpiry = req.headers['x-upload-expiry'] || parts.expiry || parts.expires_at;
const nowStamp = ~~(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(rawExpiry, nowStamp);
// Always generate a unique item slug for the database
const itemSlug = lib.generateSlug(11);
const maxLen = cfg.main.comment_max_length; const maxLen = cfg.main.comment_max_length;
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) { if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400); return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
} }
if (!file || !file.data) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
// In shitpost mode, rating is optional — null means no rating tag is assigned (truly untagged).
// If shitpost_require_rating is configured to true, a rating is strictly required.
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null; const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null;
if (!is_shitpost && !effectiveRating) { if (!is_shitpost && !effectiveRating) {
@@ -163,20 +258,20 @@ export const handleUpload = async (req, res, self) => {
return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400); return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400);
} }
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : []; const userTags = tagsRaw ? String(tagsRaw).split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
const autoTags = (inputUrl && !file) ? autoTagsFromUrl(inputUrl) : [];
const tags = [...new Set([...userTags, ...autoTags])];
const minTags = getMinTags(); const minTags = getMinTags();
// In shitpost mode, tags are optional by default — unless shitpost_min_tags is configured.
const shitpostMinTags = is_shitpost ? (parseInt(cfg.websrv.shitpost_min_tags) || 0) : 0; const shitpostMinTags = is_shitpost ? (parseInt(cfg.websrv.shitpost_min_tags) || 0) : 0;
if (!is_shitpost && minTags > 0 && tags.length < minTags) { if (!is_shitpost && minTags > 0 && userTags.length < minTags) {
return sendJson(res, { success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400); return sendJson(res, { success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
} }
if (is_shitpost && shitpostMinTags > 0 && tags.length < shitpostMinTags) { if (is_shitpost && shitpostMinTags > 0 && userTags.length < shitpostMinTags) {
return sendJson(res, { success: false, msg: `At least ${shitpostMinTags} tag${shitpostMinTags !== 1 ? 's' : ''} required` }, 400); return sendJson(res, { success: false, msg: `At least ${shitpostMinTags} tag${shitpostMinTags !== 1 ? 's' : ''} required` }, 400);
} }
// Validate MIME type // Validate MIME type for attached file
// cfg.allowedMimes entries can be category prefixes ("image", "video", "audio")
// OR exact MIME types ("application/pdf"). Entries with "/" are matched exactly.
const allowedCats = Array.isArray(cfg.allowedMimes) const allowedCats = Array.isArray(cfg.allowedMimes)
? cfg.allowedMimes.map(c => c.toLowerCase()) ? cfg.allowedMimes.map(c => c.toLowerCase())
: null; : null;
@@ -187,27 +282,19 @@ export const handleUpload = async (req, res, self) => {
) )
) )
: Object.keys(cfg.mimes); : Object.keys(cfg.mimes);
let mime = file.contentType;
// Browsers often don't know the SWF MIME type and send application/octet-stream or nothing. if (file && !inputUrl) {
// Normalize it here based on extension so the allowedMimes check doesn't spuriously reject. let mime = file.contentType;
// The server-side `file --mime-type` check on line ~248 is the authoritative validation.
if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') && if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') &&
file.filename && file.filename.toLowerCase().endsWith('.swf')) { file.filename && file.filename.toLowerCase().endsWith('.swf')) {
mime = 'application/x-shockwave-flash'; mime = 'application/x-shockwave-flash';
} }
if (!allowedMimes.includes(mime)) { if (!allowedMimes.includes(mime)) {
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400); return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
} }
}
// Size was already validated by collectBody (effectiveMaxBytes)
const size = file.data.length;
let manualApproval = getManualApproval(); let manualApproval = getManualApproval();
// Enforce manual approval for untrusted users (configurable threshold)
// Admins and moderators are exempt from this check
const trustedThreshold = getTrustedUploads(); const trustedThreshold = getTrustedUploads();
if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) { if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) {
try { try {
@@ -218,18 +305,13 @@ export const handleUpload = async (req, res, self) => {
AND is_deleted = false AND is_deleted = false
`; `;
if (parseInt(totalUploads[0].count) < trustedThreshold) { if (parseInt(totalUploads[0].count) < trustedThreshold) {
console.log(`[UPLOAD] Forcing manual approval for new user: ${req.session.user} (Upload count: ${totalUploads[0].count}/${trustedThreshold})`);
manualApproval = true; manualApproval = true;
} }
} catch (err) { } catch (err) {
console.error('[UPLOAD] Failed to check total upload count:', err);
// Default to manual approval on error for safety if we are unsure
manualApproval = true; manualApproval = true;
} }
} }
// Rate Limit Check (if manual approval is disabled)
// Admins and moderators are exempt from rate limiting
if (!manualApproval && !req.session.admin && !req.session.is_moderator) { if (!manualApproval && !req.session.admin && !req.session.is_moderator) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600); const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db` const uploadCount = await db`
@@ -248,6 +330,130 @@ export const handleUpload = async (req, res, self) => {
} }
} }
// Check for YouTube Embeds when url is provided without a file attachment
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = (!file && inputUrl) ? inputUrl.match(ytRegex) : null;
if (ytMatch && cfg.websrv.enable_youtube_upload !== false) {
const videoId = ytMatch[1];
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
const filename = `yt:${videoId}`;
const [{ id: itemid }] = await db`
insert into items ${db({
src: ytUrl,
dest: filename,
mime: 'video/youtube',
size: 0,
checksum: `yt_${videoId}_${Date.now()}`,
phash: null,
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: nowStamp,
active: !manualApproval,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
RETURNING id
`;
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) {}
try {
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, manualApproval);
} catch (err) {
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
}
if (effectiveRating) {
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
await queue.genBlurredThumbnail(itemid, manualApproval).catch(() => {});
}
for (const tagName of tags) {
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
if (tagRow.length === 0) {
await db`insert into tags ${db({ tag: tagName }, 'tag')}`;
tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
}
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
}
if (comment && comment.length > 0) {
try {
const filteredComment = await applyWordFilter(comment);
await db`INSERT INTO comments ${db({ item_id: itemid, user_id: req.session.id, content: filteredComment })}`;
} catch (err) {}
}
if (is_oc) {
const ocTags = ['oc', 'original content'];
for (const ocName of ocTags) {
let tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
if (tagRow.length === 0) {
await db`insert into tags ${db({ tag: ocName }, 'tag')}`;
tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
}
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
}
}
if (!manualApproval) {
f0cklib.clearCountCache();
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: 'video/youtube',
username: req.session.user,
display_name: req.session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility
})})`;
} catch (err) {
console.error('[UPLOAD] YouTube new_item notify failed:', err);
}
}
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
const successMsg = manualApproval ? 'Upload successful! Your upload is pending admin approval.' : 'Upload successful! Your upload is now live.';
return sendJson(res, {
success: true,
msg: successMsg,
itemid: itemid,
slug: itemSlug,
visibility: targetVisibility,
manual_approval: manualApproval,
redirect: !manualApproval ? itemRoute : null,
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
file_url: null,
dest: filename,
mime: 'video/youtube',
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
display_name: req.session.display_name || null,
username: req.session.user
});
}
// Check repost link if uploading via URL (non-YouTube)
if (!file && inputUrl && !getBypassDuplicateCheck()) {
const repostLink = await queue.checkrepostlink(inputUrl);
if (repostLink) {
return sendJson(res, { success: false, msg: 'This URL has already been uploaded', repost: repostLink }, 409);
}
}
// Generate UUID & Base Paths // Generate UUID & Base Paths
const uuid = await queue.genuuid(); const uuid = await queue.genuuid();
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`); const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
@@ -258,8 +464,87 @@ export const handleUpload = async (req, res, self) => {
await fs.mkdir(path.join(cfg.paths.pending, 't'), { recursive: true }); await fs.mkdir(path.join(cfg.paths.pending, 't'), { recursive: true });
await fs.mkdir(path.join(cfg.paths.pending, 'ca'), { recursive: true }); await fs.mkdir(path.join(cfg.paths.pending, 'ca'), { recursive: true });
// Save temporarily to detect actual MIME if (file && file.data) {
await fs.writeFile(tmpPath, file.data); await fs.writeFile(tmpPath, file.data);
} else if (inputUrl) {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web', '--newline', '--no-colors'];
const isInstagram = /instagram\.com/i.test(inputUrl);
let downloadedFile = null;
try {
const { stdout } = await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
inputUrl,
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
'--postprocessor-args', 'ffmpeg:-bitexact',
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath',
'--merge-output-format', 'mp4'
], { quiet: true });
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err1) {
if (isInstagram) {
return sendJson(res, { success: false, msg: 'Failed to download Instagram URL' }, 400);
}
try {
const { stdout } = await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
inputUrl,
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath'
], { quiet: true });
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err2) {
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.curl_tmp`);
let referer = inputUrl;
try {
const parsedUrl = new URL(inputUrl);
let host = parsedUrl.hostname;
if (host.includes('imgur.com')) host = 'imgur.com';
referer = `${parsedUrl.protocol}//${host}/`;
} catch (e) {}
const curlArgs = [
'-s', '-S', '-f', '-L', inputUrl, '-o', fallbackTmp,
'--max-filesize', `${effectiveMaxBytes}`,
'--connect-timeout', '30',
'--max-time', '300',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'--referer', referer,
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
'-H', 'Accept-Language: en-US,en;q=0.9'
];
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
curlArgs.push('--socks5-hostname', proxyHost);
}
try {
await queue.spawn('curl', curlArgs);
downloadedFile = fallbackTmp;
} catch (curlErr) {
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
}
}
}
let exists = false;
if (downloadedFile) {
try {
await fs.access(downloadedFile);
exists = true;
} catch (e) {}
}
if (!exists) {
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
}
if (downloadedFile !== tmpPath) {
await fs.rename(downloadedFile, tmpPath);
}
}
// Verify actual MIME (second check after file-command detection) // Verify actual MIME (second check after file-command detection)
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim(); let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
@@ -335,7 +620,7 @@ export const handleUpload = async (req, res, self) => {
// 4. Audio-only MP4 reclassification probe (only when needed) // 4. Audio-only MP4 reclassification probe (only when needed)
needsAudioMP4 needsAudioMP4
? (async () => { ? (async () => {
const origExt = file.filename.split('.').pop().toLowerCase(); const origExt = file?.filename ? file.filename.split('.').pop().toLowerCase() : (inputUrl ? inputUrl.split('?')[0].split('.').pop().toLowerCase() : '');
if (['m4a', 'aac'].includes(origExt)) { if (['m4a', 'aac'].includes(origExt)) {
return 'audio/mp4'; // Extension-based reclassification return 'audio/mp4'; // Extension-based reclassification
} }
@@ -438,10 +723,18 @@ export const handleUpload = async (req, res, self) => {
// Insert // Insert
const originalFilename = file.filename || null; const size = file?.data ? file.data.length : (await fs.stat(destPath).then(s => s.size).catch(() => 0));
let originalFilename = file ? (file.filename || null) : null;
if (!originalFilename && inputUrl) {
try {
const parsedName = path.basename(new URL(inputUrl).pathname);
if (parsedName && parsedName !== '/') originalFilename = parsedName;
} catch (e) {}
}
await db` await db`
insert into items ${db({ insert into items ${db({
src: '', src: inputUrl || '',
dest: filename, dest: filename,
mime: actualMime, mime: actualMime,
size: size, size: size,
@@ -450,14 +743,17 @@ export const handleUpload = async (req, res, self) => {
username: req.session.user, username: req.session.user,
userchannel: 'web', userchannel: 'web',
usernetwork: 'web', usernetwork: 'web',
stamp: ~~(Date.now() / 1000), stamp: nowStamp,
active: !manualApproval, active: !manualApproval,
is_oc: is_oc, is_oc: is_oc,
original_filename: originalFilename, original_filename: originalFilename,
title: title, title: title,
width: itemWidth, width: itemWidth,
height: itemHeight height: itemHeight,
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height')} visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
`; `;
const itemid = await queue.getItemID(filename); const itemid = await queue.getItemID(filename);
@@ -584,7 +880,7 @@ export const handleUpload = async (req, res, self) => {
} }
// Auto-tag SWF uploads with "Flash" and "SWF" // Auto-tag SWF uploads with "Flash" and "SWF"
if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') { if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
const swfTags = ['Flash', 'SWF']; const swfTags = ['Flash', 'SWF'];
for (const tagname of swfTags) { for (const tagname of swfTags) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`; let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
@@ -678,7 +974,8 @@ export const handleUpload = async (req, res, self) => {
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly. // Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
// Discord Webhook // Discord Webhook (only for public uploads)
if (targetVisibility === 0) {
try { try {
const discordClient = cfg.clients.find(c => c.type === 'discord'); const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) { if (discordClient && discordClient.webhook_url) {
@@ -702,6 +999,7 @@ export const handleUpload = async (req, res, self) => {
} catch (err) { } catch (err) {
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err); console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
} }
}
// Broadcast new_item event for live grid updates (only if auto-approved) // Broadcast new_item event for live grid updates (only if auto-approved)
if (!manualApproval) { if (!manualApproval) {
@@ -713,14 +1011,17 @@ export const handleUpload = async (req, res, self) => {
username: req.session.user, username: req.session.user,
display_name: req.session.display_name || null, display_name: req.session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0, tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility
})})`; })})`;
} catch (err) { } catch (err) {
console.error('[UPLOAD] new_item notify failed:', err); console.error('[UPLOAD] new_item notify failed:', err);
} }
} }
// Push to Matrix Channel // Push to Matrix Channel (only if auto-approved and public)
if (!manualApproval && targetVisibility === 0) {
try { try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix'); const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) { if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
@@ -735,6 +1036,7 @@ export const handleUpload = async (req, res, self) => {
} catch (err) { } catch (err) {
console.error('[UPLOAD] Matrix notification error:', err); console.error('[UPLOAD] Matrix notification error:', err);
} }
}
// Staff Notifications // Staff Notifications
if (manualApproval) { if (manualApproval) {
@@ -768,13 +1070,16 @@ export const handleUpload = async (req, res, self) => {
: 'Upload successful! Your upload is now live.'; : 'Upload successful! Your upload is now live.';
const imagesPath = cfg.websrv.paths?.images || '/b'; const imagesPath = cfg.websrv.paths?.images || '/b';
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
return sendJson(res, { return sendJson(res, {
success: true, success: true,
msg: successMsg, msg: successMsg,
itemid: itemid, itemid: itemid,
slug: itemSlug,
visibility: targetVisibility,
manual_approval: manualApproval, manual_approval: manualApproval,
redirect: !manualApproval ? `/${itemid}` : null, redirect: !manualApproval ? itemRoute : null,
url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : `${cfg.main.url.full}/`, url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null, file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
// Fields for immediate client-side grid injection (avoids SSE race condition) // Fields for immediate client-side grid injection (avoids SSE race condition)
dest: filename, dest: filename,

View File

@@ -117,22 +117,34 @@
status.style.color = 'var(--accent)'; status.style.color = 'var(--accent)';
try { try {
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
if (!csrfToken || csrfToken.startsWith('{')) {
status.textContent = 'CSRF token missing. Please refresh the page.';
status.style.color = '#d9534f';
return;
}
const res = await fetch('/admin/settings', { const res = await fetch('/admin/settings', {
method: 'POST', method: 'POST',
headers: { headers: {
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
}, },
body: new URLSearchParams({ body: new URLSearchParams({
manual_approval: approvalToggle.checked ? 'on' : 'off', manual_approval: approvalToggle ? (approvalToggle.checked ? 'on' : 'off') : 'off',
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}), ...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
min_tags: minTagsInput.value, min_tags: minTagsInput ? minTagsInput.value : '3',
trusted_uploads: trustedUploadsInput.value, trusted_uploads: trustedUploadsInput ? trustedUploadsInput.value : '3',
csrf_token: '{{ csrf_token }}' csrf_token: csrfToken
}).toString() }).toString()
}); });
if (!res.ok) throw new Error('Server returned ' + res.status); if (!res.ok) {
if (res.status === 403) {
throw new Error('CSRF validation failed or session expired. Please reload the page.');
}
throw new Error('Server returned ' + res.status);
}
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {

View File

@@ -26,6 +26,14 @@
</div> </div>
</div> </div>
<div class="settings-item" style="margin-bottom: 20px;">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; color: #ff6b6b; font-weight: bold;">
<input type="checkbox" id="cleanup_include_engaged" name="cleanup_include_engaged" value="true" @if(cleanup_include_engaged) checked @endif style="width: 18px; height: 18px; cursor: pointer;">
<span>Include items with engagement</span>
</label>
<span style="color: #888; display: block; margin-top: 5px; margin-left: 28px;">If checked, cleanup will purge ALL posts in the selected date range, even if they have comments or favorites.</span>
</div>
<div style="display: flex; gap: 10px; align-items: center;"> <div style="display: flex; gap: 10px; align-items: center;">
<button type="submit" class="btn-primary" style="width: auto; padding: 12px 40px; font-weight: bold;">Save Configuration</button> <button type="submit" class="btn-primary" style="width: auto; padding: 12px 40px; font-weight: bold;">Save Configuration</button>
<span id="cleanup-status" style="margin-left: 15px; font-weight: bold; display: none;"></span> <span id="cleanup-status" style="margin-left: 15px; font-weight: bold; display: none;"></span>
@@ -101,8 +109,13 @@
async function runCleanup() { async function runCleanup() {
const btn = document.getElementById('run-cleanup-btn'); const btn = document.getElementById('run-cleanup-btn');
const status = document.getElementById('run-status'); const status = document.getElementById('run-status');
const includeEngaged = document.getElementById('cleanup_include_engaged')?.checked || false;
if (!confirm('Are you absolutely sure? This will PERMANENTLY delete files from disk. This action cannot be undone.')) return; const warningMsg = includeEngaged
? 'SECURITY CLEANUP WARNING: You have selected to INCLUDE posts WITH engagement (comments/favorites). Are you ABSOLUTELY SURE you want to permanently delete ALL posts in this date range?'
: 'Are you absolutely sure? This will PERMANENTLY delete files from disk. This action cannot be undone.';
if (!confirm(warningMsg)) return;
btn.disabled = true; btn.disabled = true;
btn.textContent = 'CLEANING UP...'; btn.textContent = 'CLEANING UP...';
@@ -110,12 +123,17 @@
status.style.color = 'var(--accent)'; status.style.color = 'var(--accent)';
try { try {
const formData = new URLSearchParams();
formData.append('include_engaged', includeEngaged ? 'true' : 'false');
const res = await fetch('/admin/cleanup/run', { const res = await fetch('/admin/cleanup/run', {
method: 'POST', method: 'POST',
headers: { headers: {
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token || '{{ csrf_token }}' 'X-CSRF-Token': window.f0ckSession?.csrf_token || '{{ csrf_token }}'
} },
body: formData
}); });
const cleanup_response = await res.json(); const cleanup_response = await res.json();

View File

@@ -1,9 +1,46 @@
<div class="index-layout-wrapper"> <div class="index-layout-wrapper">
<div class="index-container"> <div class="index-container">
@include(snippets/page-title) @include(snippets/page-title)
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
<div class="posts layout-{{ layout || 'grid' }}" id="posts-container" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}" data-layout="{{ layout || 'grid' }}">
@each(items as item) @each(items as item)
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
@if(layout == 'stream')
<!-- Stream / Vertical Feed Layout -->
<div class="stream-card {{ item.is_pinned ? 'is-pinned' : '' }} {{ item.has_notification ? 'has-notif' : '' }}" data-id="{{ item.id }}">
<div class="stream-card-header">
<a href="/user/{{ item.username.toLowerCase() }}" class="stream-author">
<span class="stream-username" @if(item.author_color)style="color: {{ item.author_color }}"@endif>{!! item.display_name || item.username !!}</span>
</a>
<div class="stream-meta">
@if(item.is_pinned)
<span class="badge-pin"><i class="fa-solid fa-thumbtack"></i> Pinned</span>
@endif
@if(item.is_oc)
<span class="badge-oc">OC</span>
@endif
@if(enable_xd_score && item.xd_tier > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_tier }}">xD: {{ item.xd_score }}</span>
@endif
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-permalink"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
</div>
</div>
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-media-wrapper">
<img src="/t/{{ item.id }}.webp" alt="Media thumbnail" class="stream-media-img" loading="lazy">
<span class="stream-media-badge">{{ item.mime.split('/')[1].replace('youtube', 'YT').replace('x-shockwave-flash', 'FLASH').toUpperCase() }}</span>
</a>
<div class="stream-card-footer">
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-action-btn">
<i class="fa-regular fa-comments"></i> View Post & Comments
</a>
</div>
</div>
@else
<!-- Grid & Masonry Layout (Default) -->
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<div class="thumb-indicators"> <div class="thumb-indicators">
@if(item.is_pinned) @if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i> <i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@@ -17,6 +54,8 @@
</div> </div>
<p></p> <p></p>
</a> </a>
@endif
@endeach @endeach
</div> </div>
<div id="footbar"> <div id="footbar">

View File

@@ -6,7 +6,7 @@
<div class="item-main-content"> <div class="item-main-content">
<div class="_204863"> <div class="_204863">
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div> <div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div> <div class="gapLeft"></div>
</div> </div>
@if(enable_item_title) @if(enable_item_title)
@@ -102,7 +102,7 @@
<div class="user-infobox-username-container"> <div class="user-infobox-username-container">
<a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="user-infobox-username">{!! item.author_display_name || item.username !!}</a> <a id="a_username" data-username="{{ item.username || '' }}" data-author-id="{{ item.author_id || '' }}" href="/user/{{ (item.username || '').toLowerCase() }}" tooltip="ID: {{ item.author_id }}" class="user-infobox-username">{!! item.author_display_name || item.username !!}</a>
</div> </div>
<span class="user-infobox-timestamp"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span> <span class="user-infobox-timestamp"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
</div> </div>
<div class="user-infobox-body"> <div class="user-infobox-body">
<div class="user-infobox-description"> <div class="user-infobox-description">
@@ -111,9 +111,9 @@
<div class="user-infobox-actions"> <div class="user-infobox-actions">
@if(session) @if(session)
@if(user_has_favorited) @if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i> <i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else @else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i> <i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif @endif
@endif @endif
<span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span> <span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
@@ -125,13 +125,15 @@
<span class="badge badge-dark"> <span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link" @if(user_alternative_infobox)style="display:none"@endif>{{ item.id }}</a> <a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}" @if(user_alternative_infobox)style="display:none"@endif>{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a>
@if(!user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif @if(!user_alternative_infobox) — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ (item.username || '').toLowerCase() }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] @endif
@if(!user_alternative_infobox) @if(!user_alternative_infobox)
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span> <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
@endif @endif
</span> </span>
@if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(halls_enabled && item.primaryHall) — @endif @endif @if(!user_alternative_infobox) — <span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>@if(item.expires_at) — <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>@endif@if(halls_enabled && item.primaryHall) — @endif @endif
@if(halls_enabled && item.primaryHall) @if(halls_enabled && item.primaryHall)
<span class="badge hall-badge-wrap"> <span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif <a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
@@ -143,9 +145,9 @@
@if(session) @if(session)
@if(!user_alternative_infobox) @if(!user_alternative_infobox)
@if(user_has_favorited) @if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i> <i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else @else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i> <i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif @endif
@endif @endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i> <i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
@@ -175,7 +177,7 @@
<span class="tags-inner"> <span class="tags-inner">
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)
<span tooltip="{!! tag.display_name || tag.user !!}" class="badge {{ tag.badge }}"> <span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif <a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span> </span>
@endeach @endeach
@@ -190,14 +192,14 @@
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
</a> </a>
@if(can_manage_item) @if(can_manage_item)
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button> <button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
@endif @endif
</div> </div>
@endif @endif
</span> </span>
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif> <span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
@each(item.favorites as fav) @each(item.favorites as fav)
@if(fav.hide_fav_badge) @if(fav.hide_fav_badge && (!session || session.user !== fav.user))
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a> <a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else @else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a> <a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@@ -234,79 +236,45 @@
</div> </div>
<div id="info-modal" class="modal-overlay" style="display: none;"> @include(snippets/info-modal)
<div class="modal-content" style="max-width: 500px;">
<div class="modal-body" style="padding: 20px 0; text-align: left;">
<table class="info-table"> <div id="visibility-modal" class="modal-overlay" style="display: none;">
@if(enable_item_title && can_manage_item) <div class="modal-content" style="max-width: 420px;">
<tr class="info-title-row"> <div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
<th>Title</th> <h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
<td>
<div class="info-title-edit-wrap">
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
</div> </div>
<span id="info-title-status" class="info-title-status" style="display:none"></span> <div class="modal-body" style="padding: 10px 0; text-align: left;">
</td> <form id="visibility-form" onsubmit="return false;">
</tr> <input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
@elseif(enable_item_title && item.title) <div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
<tr> <label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<th>Title</th> <input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
<td>{!! item.title !!}</td> <div>
</tr> <strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
@endif <span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
<tr>
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
<td>{{ item.size }}</td>
</tr>
@if(item_has_dimensions)
<tr>
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
<td>{{ item.width }} × {{ item.height }}</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
<td><code>{{ item.mime }}</code></td>
</tr>
@if(item.checksum)
<tr>
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
</tr>
@endif
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
<tr class="info-repost-row">
<th>Repost</th>
<td>
@each(item.reposts as rp)
@if(rp.match_type === 'phash')
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
@else
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
@endif
@endeach
</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
<td>
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
{{ t('info_modal.view_file') || 'View File' }}
</a>
</td>
</tr>
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
</tr>
@endif
</table>
</div> </div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;"> </label>
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button> <label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
</div>
</label>
</div>
</form>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -33,7 +33,7 @@
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
</a> </a>
@if(can_manage_item) @if(can_manage_item)
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button> <button class="rating-btn {{ item_rating_class }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
@endif @endif
</div> </div>
@endif @endif
@@ -42,7 +42,7 @@
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;"> <span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(typeof item.tags !== "undefined") @if(typeof item.tags !== "undefined")
@each(item.tags as tag) @each(item.tags as tag)
<span @if(session) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}"> <span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif <a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin)&nbsp;<a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
</span> </span>
@endeach @endeach
@@ -58,7 +58,7 @@
<div class="item-main-content"> <div class="item-main-content">
<div class="_204863"> <div class="_204863">
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div> <div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
<div class="gapLeft"></div> <div class="gapLeft"></div>
</div> </div>
@if(enable_item_title) @if(enable_item_title)
@@ -119,20 +119,25 @@
</div> </div>
<div class="blahlol"> <div class="blahlol">
<span class="badge badge-dark"> <span class="badge badge-dark">
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span> <a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
</span>@if(halls_enabled && item.primaryHall) — @endif </span>@if(halls_enabled && item.primaryHall) — @endif
@if(halls_enabled && item.primaryHall) @if(halls_enabled && item.primaryHall)
<span class="badge hall-badge-wrap"> <span class="badge hall-badge-wrap">
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif <a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
</span> </span>
@endif @endif
<span class="badge badge-dark"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span> <span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
@if(item.expires_at)
<span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>
@endif
<div class="gapRight"> <div class="gapRight">
@if(session) @if(session)
@if(user_has_favorited) @if(user_has_favorited)
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i> <i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@else @else
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i> <i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
@endif @endif
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i> <i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i> <i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
@@ -157,7 +162,7 @@
</div> </div>
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;"> <span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
@each(item.favorites as fav) @each(item.favorites as fav)
@if(fav.hide_fav_badge) @if(fav.hide_fav_badge && (!session || session.user !== fav.user))
<a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a> <a class="ghost-fav" tooltip="?" flow="up" style="cursor: default;"><img src="/s/img/ghost_fav.svg" style="height: 32px; width: 32px;" loading="lazy" /></a>
@else @else
<a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a> <a href="/user/{{ fav.user.toLowerCase() }}" tooltip="{!! fav.display_name || fav.user !!}" flow="up"><img src="@if(fav.avatar_file)/a/{{ fav.avatar_file }}@elseif(fav.avatar)/t/{{ fav.avatar }}.webp@else/a/default.png@endif" style="height: 32px; width: 32px@if(fav.username_color); border-color: {{ fav.username_color }}@endif" loading="lazy" /></a>
@@ -176,79 +181,45 @@
</div> </div>
<div id="info-modal" class="modal-overlay" style="display: none;"> @include(snippets/info-modal)
<div class="modal-content" style="max-width: 500px;">
<div class="modal-body" style="padding: 20px 0; text-align: left;">
<table class="info-table"> <div id="visibility-modal" class="modal-overlay" style="display: none;">
@if(enable_item_title && can_manage_item) <div class="modal-content" style="max-width: 420px;">
<tr class="info-title-row"> <div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
<th>Title</th> <h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
<td>
<div class="info-title-edit-wrap">
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
</div> </div>
<span id="info-title-status" class="info-title-status" style="display:none"></span> <div class="modal-body" style="padding: 10px 0; text-align: left;">
</td> <form id="visibility-form" onsubmit="return false;">
</tr> <input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
@elseif(enable_item_title && item.title) <div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
<tr> <label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<th>Title</th> <input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
<td>{!! item.title !!}</td> <div>
</tr> <strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
@endif <span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
<tr>
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
<td>{{ item.size }}</td>
</tr>
@if(item_has_dimensions)
<tr>
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
<td>{{ item.width }} × {{ item.height }}</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
<td><code>{{ item.mime }}</code></td>
</tr>
@if(item.checksum)
<tr>
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
</tr>
@endif
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
<tr class="info-repost-row">
<th>Repost</th>
<td>
@each(item.reposts as rp)
@if(rp.match_type === 'phash')
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
@else
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
@endif
@endeach
</td>
</tr>
@endif
<tr>
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
<td>
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
{{ t('info_modal.view_file') || 'View File' }}
</a>
</td>
</tr>
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
</tr>
@endif
</table>
</div> </div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;"> </label>
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button> <label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
</div>
</label>
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
<div>
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
</div>
</label>
</div>
</form>
</div>
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 15px;">
<button class="btn-secondary" id="visibility-modal-cancel">Cancel</button>
<button class="btn-primary" id="visibility-modal-save">Save Visibility</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -94,7 +94,7 @@
<tbody> <tbody>
@each(favotop as favo) @each(favotop as favo)
<tr> <tr>
<td><a href="/{{ favo.item_id }}">#{{ favo.item_id }}</a></td> <td><a href="/{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}">#{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}</a></td>
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td> <td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
</tr> </tr>
@endeach @endeach
@@ -109,7 +109,7 @@
<tbody> <tbody>
@each(xdtop as item) @each(xdtop as item)
<tr> <tr>
<td><a href="/{{ item.id }}">#{{ item.id }}</a></td> <td><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a></td>
<td> <td>
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up"> <span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span> {{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>

View File

@@ -2,24 +2,24 @@
<div class="pagewrapper"> <div class="pagewrapper">
<div id="main"> <div id="main">
<div class="settings"> <div class="settings">
<h1>{{ t('settings.title') }}</h1> <h1><i class="fa-solid fa-sliders"></i> {{ t('settings.title') }}</h1>
<!-- Quick navigation --> <!-- Quick navigation -->
<nav id="settings-quicknav" aria-label="Settings sections"> <nav id="settings-quicknav" aria-label="Settings sections">
<a href="#profile">{{ t('settings.profile') }}</a> <a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
<a href="#preferences">{{ t('settings.preferences') }}</a> <a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
@if(enable_data_export) @if(enable_data_export)
<a href="#export">{{ t('settings.export_data_title') }}</a> <a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
@endif @endif
<a href="#account">{{ t('settings.account') }}</a> <a href="#account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</a>
@if(matrix_enabled || telegram_enabled) @if(matrix_enabled || telegram_enabled)
<a href="#linked">{{ t('settings.linked_accounts') }}</a> <a href="#linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</a>
@endif @endif
@if(enable_user_api_keys) @if(enable_user_api_keys)
<a href="#apikey">API Key</a> <a href="#apikey"><i class="fa-solid fa-key"></i> API Key</a>
@endif @endif
@if(enable_user_invites) @if(enable_user_invites)
<a href="#invites">{{ t('invites.section_title') }}</a> <a href="#invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</a>
@endif @endif
</nav> </nav>
<style> <style>
@@ -75,404 +75,141 @@
<!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ --> <!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ -->
<h2 id="profile">{{ t('settings.profile') }}</h2> <h2 id="profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</h2>
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.avatar') }}</legend> <p class="text-muted" style="margin-bottom: 16px; font-size: 0.9em; display: flex; align-items: center; gap: 8px;">
<div class="avatar-settings-wrapper"> <i class="fa-solid fa-wand-magic-sparkles" style="color: var(--accent);"></i> Click directly on your profile card below to edit your avatar, banner, name, color, or bio.
<div class="avatar-preview-container"> </p>
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
<div class="direct-edit-profile-card-wrapper" style="position: relative; max-width: 750px; margin-bottom: 30px;">
<!-- Interactive User Card -->
<div class="user-infobox-block editable-profile-card" id="live-profile-preview-box"
style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
<!-- Avatar with Hover Overlay -->
<div class="user-infobox-avatar editable-avatar-container" id="avatar-choose-btn" title="Click to change avatar">
<a href="#" onclick="event.preventDefault(); document.getElementById('avatar-file-input').click();" style="display:block; position:relative; width:100%; height:100%;">
@if(avatar_file) @if(avatar_file)
<a href="/user/{!! session.user !!}" target="_blank"> <img id="live-preview-avatar" class="avatar-preview-img" src="/a/{{ avatar_file }}" />
<img id="avatar-preview" class="avatar-preview-img" src="/a/{{ avatar_file }}">
</a>
@elseif(session.avatar && session.avatar > 0) @elseif(session.avatar && session.avatar > 0)
<a href="/user/{!! session.user !!}" target="_blank"> <img id="live-preview-avatar" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp" />
<img id="avatar-preview" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp">
</a>
@else @else
<div id="avatar-preview" class="avatar-preview-img avatar-placeholder">?</div> <img id="live-preview-avatar" class="avatar-preview-img" src="/a/default.png" style="border-color: #444;" />
@endif @endif
<div class="avatar-hover-overlay">
<i class="fa-solid fa-camera"></i>
</div> </div>
</a>
<div class="avatar-upload-section">
<p class="avatar-hint">{{ t('settings.avatar_hint') }}</p>
<div class="avatar-upload-wrapper">
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
<button type="button" id="avatar-choose-btn" class="button">{{ t('settings.choose_file') }}</button>
<span id="avatar-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
</div>
<div class="avatar-progress-wrapper" id="avatar-progress-wrapper" style="display: none;">
<div class="avatar-progress-bar">
<div class="avatar-progress-fill" id="avatar-progress-fill"></div>
</div>
<span class="avatar-progress-text" id="avatar-progress-text">0%</span>
</div>
<div class="avatar-upload-actions">
<button type="button" id="avatar-upload-btn" class="button" disabled>{{ t('settings.upload_btn') }}</button>
@if(avatar_file) @if(avatar_file)
<button type="button" id="avatar-remove-btn" class="button button-danger">{{ t('settings.remove_custom') }}</button> <button type="button" class="avatar-quick-remove" id="avatar-remove-btn" title="Remove custom avatar">
@endif <i class="fa-solid fa-xmark"></i>
</div> </button>
<div id="avatar-upload-status" class="avatar-status"></div>
</div>
</div>
</fieldset>
@if(enable_profile_description)
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.custom_description') }}</legend>
<div class="setting-item">
<textarea id="profile_description" class="input" placeholder="{{ t('settings.description_placeholder') }}"
maxlength="2000">{!! session.description || '' !!}</textarea>
<div class="profile-settings-actions">
<button type="button" id="btn-save-description" class="button">{{ t('settings.save_description') }}</button>
<button type="button" id="btn-clear-description" class="button button-danger">{{ t('settings.clear') }}</button>
</div>
<div id="description-status" class="avatar-status"></div>
</div>
</fieldset>
@endif
@if(user_banner_enabled)
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Profile Banner</legend>
<div class="avatar-settings-wrapper">
<div class="avatar-preview-container" style="width: 360px; max-width: 100%;">
<div class="avatar-preview-label">{{ t('settings.current_banner') }}</div>
@if(banner_file)
<a href="/user/{!! session.user !!}" target="_blank" style="display:block; width: 100%;">
<div id="banner-preview" class="banner-preview-img" style="width:100%;aspect-ratio:400/140;border-radius:4px;border:1px solid var(--nav-border-color);background-image:url('/a/{{ banner_file }}');background-size:{{ banner_size || 'cover' }};background-position:{{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }};background-repeat:no-repeat;"></div>
</a>
@else
<div id="banner-preview" class="banner-preview-img banner-placeholder" style="width:100%;aspect-ratio:400/140;display:flex;align-items:center;justify-content:center;border:1px dashed var(--nav-border-color);border-radius:4px;color:var(--text-muted);font-size:0.85em;">{{ t('settings.no_banner_set') }}</div>
@endif @endif
</div> </div>
<div class="avatar-upload-section">
<p class="avatar-hint">{{ t('settings.banner_hint') }}</p>
<div class="avatar-upload-wrapper">
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
<button type="button" id="banner-choose-btn" class="button">{{ t('settings.choose_file') }}</button>
<span id="banner-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
</div>
<div class="avatar-progress-wrapper" id="banner-progress-wrapper" style="display: none;">
<div class="avatar-progress-bar">
<div class="avatar-progress-fill" id="banner-progress-fill"></div>
</div>
<span class="avatar-progress-text" id="banner-progress-text">0%</span>
</div>
<div class="avatar-upload-actions">
<button type="button" id="banner-upload-btn" class="button" disabled>Upload Banner</button>
@if(banner_file)
<button type="button" id="banner-edit-btn" class="button">Edit Position</button>
<button type="button" id="banner-remove-btn" class="button button-danger">Remove Banner</button>
@endif
</div>
<div id="banner-upload-status" class="avatar-status"></div>
</div>
</div>
</fieldset>
@endif
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.username_color') }}</legend>
<div class="setting-item">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}"
style="width: 50px; height: 30px; padding: 0; border: 1px solid var(--nav-border-color); cursor: pointer; background: none;">
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" maxlength="7"
placeholder="#ffffff" class="input"
style="width: 90px; font-family: monospace; font-size: 0.9em; padding: 4px 8px; height: 30px;">
<button type="button" id="btn-save-username-color" class="button"
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save_color') }}</button>
<button type="button" id="btn-reset-username-color" class="button button-secondary"
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.reset') }}</button>
</div>
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
</div>
</fieldset>
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.display_name') }}</legend>
<div class="setting-item">
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
<input type="text" id="display_name_input" class="input"
placeholder="{{ t('settings.display_name_placeholder') }}" value="{!! session.display_name || '' !!}"
maxlength="32" style="max-width: 200px; height: 30px; font-size: 0.9em;">
<button type="button" id="btn-update-display-name" class="button"
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save') }}</button>
</div>
<div id="display-name-status" class="avatar-status"></div>
</div>
</fieldset>
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Live Profile Preview</legend>
<div class="setting-item" style="max-width: 750px;">
<div class="user-infobox-block" id="live-profile-preview-box" style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
<div class="user-infobox-avatar">
<a href="#">
@if(avatar_file)
<img id="live-preview-avatar" src="/a/{{ avatar_file }}" />
@else
<img id="live-preview-avatar" src="/a/default.png" style="border-color: #444;" />
@endif
</a>
</div>
<div class="user-infobox-info"> <div class="user-infobox-info">
<div class="user-infobox-header"> <div class="user-infobox-header">
<div class="user-infobox-username-container"> <div class="user-infobox-username-container" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<a id="live-preview-username" href="#" class="user-infobox-username">{!! session.display_name || session.user !!}</a>
<!-- Editable Display Name -->
<div class="inline-edit-username-wrapper">
<span id="live-preview-username" class="user-infobox-username" contenteditable="true" spellcheck="false" title="Click to edit display name">{!! session.display_name || session.user !!}</span>
<i class="fa-solid fa-pen" style="font-size: 0.65em; opacity: 0.5; margin-left: 2px;" title="Editable"></i>
</div>
<!-- Color Picker Badge -->
<div class="inline-color-picker-wrapper" style="display: inline-flex; align-items: center;">
<label for="username_color_picker" class="color-picker-badge" id="color-picker-badge" style="background: {{ session.username_color || 'var(--accent)' }};" title="Click to change username color">
<i class="fa-solid fa-palette"></i>
</label>
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}" style="position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;">
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" style="display: none;">
</div>
<!-- Hidden Backing Save Buttons for JS event trigger -->
<button type="button" id="btn-update-display-name" class="inline-save-btn" style="display: none;"><i class="fa-solid fa-check"></i> Save Name</button>
<button type="button" id="btn-save-username-color" style="display: none;">Save Color</button>
<button type="button" id="btn-reset-username-color" style="display: none;">Reset</button>
</div> </div>
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span> <span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
</div> </div>
<div class="user-infobox-body"> <div class="user-infobox-body">
<div class="user-infobox-description">
{!! session.description || 'Welcome to my profile! This is a live preview.' !!} <!-- Editable Description -->
<div class="inline-edit-description-wrapper">
<div class="user-infobox-description" id="live-preview-description" contenteditable="true" spellcheck="false" title="Click to edit description" data-placeholder="Click to write a bio...">
{!! session.description || '' !!}
</div> </div>
<div class="description-edit-controls" id="desc-edit-controls" style="display: flex; margin-top: 10px; gap: 8px; align-items: center; flex-wrap: wrap;">
<button type="button" id="btn-save-description" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-floppy-disk"></i> {{ t('settings.save_description') }}</button>
<button type="button" id="btn-clear-description" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> {{ t('settings.clear') }}</button>
@if(user_banner_enabled)
<button type="button" id="banner-choose-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-camera"></i> Change Banner</button>
@if(banner_file)
<button type="button" id="banner-edit-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-arrows-up-down-left-right"></i> Reposition</button>
<button type="button" id="banner-remove-btn" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> Remove Banner</button>
@endif
@endif
</div>
</div>
<div class="user-infobox-actions"> <div class="user-infobox-actions">
<i class="iconset fa-solid fa-heart" title="Favorite"></i> <i class="iconset fa-solid fa-heart" title="Favorite"></i>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Hidden Backing Elements for JavaScript compatibility -->
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
<span id="avatar-filename" style="display: none;"></span>
<button type="button" id="avatar-upload-btn" style="display: none;"></button>
<div id="avatar-progress-wrapper" style="display: none;">
<div id="avatar-progress-fill"></div>
<span id="avatar-progress-text"></span>
</div> </div>
</fieldset>
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
<span id="banner-filename" style="display: none;"></span>
<button type="button" id="banner-upload-btn" style="display: none;"></button>
<div id="banner-progress-wrapper" style="display: none;">
<div id="banner-progress-fill"></div>
<span id="banner-progress-text"></span>
</div>
<input type="text" id="display_name_input" value="{!! session.display_name || '' !!}" style="display: none;">
@if(enable_profile_description)
<textarea id="profile_description" style="display: none;">{!! session.description || '' !!}</textarea>
@endif
</div>
<!-- ═══════════════════════════════ PREFERENCES ═══════════════════════════════ --> <!-- ═══════════════════════════════ PREFERENCES ═══════════════════════════════ -->
<h2 id="preferences">{{ t('settings.preferences') }}</h2> <h2 id="preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</h2>
<div class="preferences-settings-wrapper"> <div class="preferences-settings-wrapper">
<fieldset
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.ui_section') }}</legend>
<div class="setting-item" style="margin-bottom: 15px;">
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_motd_toggle" @if(session.show_motd !==false) checked @endif>
<span>{{ t('settings.show_motd') }}</span>
</label>
</div>
<div class="setting-item">
<label for="use_new_layout_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="use_new_layout_toggle" @if(session.use_new_layout===true) checked @endif>
<span>{{ t('settings.modern_layout') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.modern_layout_hint') }}</small>
</div>
@if(!session.use_new_layout)
<div class="setting-item" style="margin-top: 15px;">
<label for="alternative_infobox_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="alternative_infobox_toggle" @if(session.use_alternative_infobox===true) checked @endif>
<span>{{ t('settings.alternative_infobox') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_infobox_hint') }}</small>
</div>
@endif
@if(!session.use_new_layout)
<div class="setting-item" style="margin-top: 15px;">
<label for="alternative_steuerung_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="alternative_steuerung_toggle" @if(session.use_alternative_steuerung===true) checked @endif>
<span>{{ t('settings.alternative_steuerung') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_steuerung_hint') }}</small>
</div>
@endif
<div class="setting-item" style="margin-bottom: 15px;"> <!-- 1. Appearance & Theme -->
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;"> <fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<input type="checkbox" id="wheel_nav_toggle"> <legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-palette"></i> {{ t('settings.appearance_section') }}</legend>
<span>{{ t('settings.scroll_nav') }}</span>
</label> <div class="setting-item" style="margin-top: 10px;">
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small> <label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label>
</div> <div style="display: flex; align-items: center; gap: 10px;">
<div class="setting-item" style="margin-bottom: 15px;"> <select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;">
<label for="image_expand_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;"> @each(themes as t)
<input type="checkbox" id="image_expand_toggle"> <option value="{{ t }}" @if(theme===t) selected @endif>{{ t }}</option>
<span>{{ t('settings.image_expand_on_click') || 'Expand images inline on click' }}</span> @endeach
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.image_expand_on_click_hint') || 'Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.' }}</small>
</div>
<div class="setting-item" style="margin-bottom: 15px;">
<label for="f_for_fullscreen_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="f_for_fullscreen_toggle">
<span>{{ t('settings.f_for_fullscreen') || 'F is for Fullscreen' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.f_for_fullscreen_hint') || 'Pressing F will toggle fullscreen in the video player instead of favoriting the post.' }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="disable_autoplay_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
<span>{{ t('settings.disable_autoplay') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping===true) checked @endif>
<span>{{ t('settings.disable_swiping') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="favorites_private_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="favorites_private_toggle" @if(session.favorites_private===true) checked @endif>
<span>{{ t('settings.favorites_private') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.favorites_private_hint') }}</small>
<div class="sub-setting-item" style="margin-left: 25px; margin-top: 10px;">
<label for="hide_fav_badge_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_fav_badge_toggle" @if(session.hide_fav_badge===true) checked @endif>
<span>{{ t('settings.hide_fav_badge') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
</div>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
<span>{{ t('settings.enable_bg_blur') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !==false) checked @endif>
<span>{{ t('settings.render_emojis') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="embed_youtube_in_comments_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
<span>{{ t('settings.embed_yt') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
</div>
@if(show_koepfe)
<div class="setting-item" style="margin-top: 15px;">
<label for="hide_koepfe_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_koepfe_toggle" @if(session.hide_koepfe===true) checked @endif>
<span>{{ t('settings.hide_koepfe') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_koepfe_hint') }}</small>
</div>
@endif
@if(allow_language_change)
<div class="setting-item" style="margin-top: 15px;">
<label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
<select id="language_select" class="input" style="padding: 6px 10px; max-width: 220px;">
<option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
<option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}
</option>
</select> </select>
<br><small class="text-muted">{{ t('settings.language_hint') }}</small> </div>
</div> </div>
@endif <div class="setting-item" style="margin-top: 18px;">
<div class="setting-item" style="margin-top: 15px;">
<label for="comment_display_mode_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.comment_display_mode') }}</label>
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 220px;" @if(session.force_comment_display_mode) disabled @endif>
<option value="0" @if(session.comment_display_mode==0) selected @endif>{{ t('settings.comment_display_tree') }}</option>
<option value="1" @if(session.comment_display_mode==1) selected @endif>{{ t('settings.comment_display_linear') }}</option>
</select>
<br><small class="text-muted">
@if(session.force_comment_display_mode)
<strong>{{ t('settings.forced_mode_notice') || 'This setting is managed by an administrator.' }}</strong>
@else
{{ t('settings.comment_display_mode_hint') }}
@endif
</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="hide_item_ratings_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_item_ratings_toggle">
<span>{{ t('settings.hide_item_ratings') || 'Hide item ratings' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_item_ratings_hint') || 'Don\'t show SFW/NSFW rating indicators on main page items' }}</small>
</div>
</fieldset>
<fieldset
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.content_preferences_section') }}</legend>
<div class="setting-item">
<label for="blur_nsfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_nsfw_toggle">
<span>{{ t('settings.blur_nsfw') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfw_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_nsfl_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_nsfl_toggle">
<span>{{ t('settings.blur_nsfl') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfl_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_sfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_sfw_toggle">
<span>{{ t('settings.blur_sfw') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_sfw_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_untagged_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_untagged_toggle">
<span>{{ t('settings.blur_untagged') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_untagged_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px; border-top: 1px dashed var(--nav-border-color); padding-top: 15px;">
<label for="blur_detail_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_detail_toggle">
<span>{{ t('settings.blur_detail') || 'Click to reveal item on detail page' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_detail_hint') || 'Require clicking to reveal blurred media on the post detail page instead of showing it immediately.' }}</small>
</div>
</fieldset>
<fieldset
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.notifications_section') }}</legend>
<div class="setting-item">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-receive-system-notifications" @if(session.receive_system_notifications !==false) checked @endif>
<span>{{ t('settings.receive_system_notifications') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_system_notifications_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-receive-user-notifications" @if(session.receive_user_notifications !==false) checked @endif>
<span>{{ t('settings.receive_user_notifications') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_user_notifications_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-do-not-disturb" @if(session.do_not_disturb===true) checked @endif>
<span>{{ t('settings.do_not_disturb') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.do_not_disturb_hint') }}</small>
</div>
</fieldset>
<fieldset
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.appearance_section') }}</legend>
<div class="setting-item" style="margin-top: 20px;">
<label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label> <label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label>
<div style="display: flex; align-items: center; gap: 10px;"> <div style="display: flex; align-items: center; gap: 10px;">
<select id="website_font_select" class="input" style="flex: 1; max-width: 300px;"> <select id="website_font_select" class="input" style="flex: 1; max-width: 300px;">
@@ -483,22 +220,281 @@
</select> </select>
</div> </div>
</div> </div>
<div class="setting-item" style="margin-top: 20px;">
<label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label> @if(allow_language_change)
<div style="display: flex; align-items: center; gap: 10px;"> <div class="setting-item" style="margin-top: 18px;">
<select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;"> <label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
@each(themes as t) <select id="language_select" class="input" style="padding: 6px 10px; max-width: 300px;">
<option value="{{ t }}" @if(theme===t) selected @endif>{{ t }}</option> <option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
@endeach <option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}</option>
</select> </select>
<small class="text-muted" style="display: block; margin-top: 4px;">{{ t('settings.language_hint') }}</small>
</div> </div>
@endif
<div class="setting-item" style="margin-top: 18px;">
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
<span>{{ t('settings.enable_bg_blur') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="show_motd_toggle" @if(session.show_motd !==false) checked @endif>
<span>{{ t('settings.show_motd') }}</span>
</label>
</div> </div>
</fieldset> </fieldset>
<!-- 2. Interface & Layout -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-desktop"></i> {{ t('settings.ui_section') }}</legend>
<div class="setting-item">
<label for="use_new_layout_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="use_new_layout_toggle" @if(session.use_new_layout===true) checked @endif>
<span>{{ t('settings.modern_layout') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.modern_layout_hint') }}</small>
</div>
@if(!session.use_new_layout)
<div class="setting-item" style="margin-top: 15px;">
<label for="alternative_infobox_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="alternative_infobox_toggle" @if(session.use_alternative_infobox===true) checked @endif>
<span>{{ t('settings.alternative_infobox') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_infobox_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="alternative_steuerung_toggle"
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="alternative_steuerung_toggle" @if(session.use_alternative_steuerung===true) checked @endif>
<span>{{ t('settings.alternative_steuerung') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_steuerung_hint') }}</small>
</div>
@endif
@if(show_koepfe)
<div class="setting-item" style="margin-top: 15px;">
<label for="hide_koepfe_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_koepfe_toggle" @if(session.hide_koepfe===true) checked @endif>
<span>{{ t('settings.hide_koepfe') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_koepfe_hint') }}</small>
</div>
@endif
</fieldset>
<!-- 3. Playback & Navigation Controls -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-play"></i> Playback & Navigation</legend>
<div class="setting-item">
<label for="disable_autoplay_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
<span>{{ t('settings.disable_autoplay') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping===true) checked @endif>
<span>{{ t('settings.disable_swiping') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="wheel_nav_toggle">
<span>{{ t('settings.scroll_nav') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="image_expand_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="image_expand_toggle">
<span>{{ t('settings.image_expand_on_click') || 'Expand images inline on click' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.image_expand_on_click_hint') || 'Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.' }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="f_for_fullscreen_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="f_for_fullscreen_toggle">
<span>{{ t('settings.f_for_fullscreen') || 'F is for Fullscreen' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.f_for_fullscreen_hint') || 'Pressing F will toggle fullscreen in the video player instead of favoriting the post.' }}</small>
</div>
</fieldset>
<!-- 4. Comments & Discussion -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-comments"></i> Comments & Discussion</legend>
<div class="setting-item">
<label for="comment_display_mode_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.comment_display_mode') }}</label>
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 300px;" @if(session.force_comment_display_mode) disabled @endif>
<option value="0" @if(session.comment_display_mode==0) selected @endif>{{ t('settings.comment_display_tree') }}</option>
<option value="1" @if(session.comment_display_mode==1) selected @endif>{{ t('settings.comment_display_linear') }}</option>
</select>
<small class="text-muted" style="display: block; margin-top: 4px;">
@if(session.force_comment_display_mode)
<strong>{{ t('settings.forced_mode_notice') || 'This setting is managed by an administrator.' }}</strong>
@else
{{ t('settings.comment_display_mode_hint') }}
@endif
</small>
</div>
<div class="setting-item" style="margin-top: 18px;">
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !==false) checked @endif>
<span>{{ t('settings.render_emojis') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="embed_youtube_in_comments_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
<span>{{ t('settings.embed_yt') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
</div>
</fieldset>
<!-- 5. Content Preferences & Blurring -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-eye-slash"></i> {{ t('settings.content_preferences_section') }}</legend>
<div class="setting-item">
<label for="blur_sfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_sfw_toggle">
<span>{{ t('settings.blur_sfw') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_sfw_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_nsfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_nsfw_toggle">
<span>{{ t('settings.blur_nsfw') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfw_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_nsfl_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_nsfl_toggle">
<span>{{ t('settings.blur_nsfl') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfl_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_untagged_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_untagged_toggle">
<span>{{ t('settings.blur_untagged') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_untagged_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="blur_detail_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="blur_detail_toggle">
<span>{{ t('settings.blur_detail') || 'Click to reveal item on detail page' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_detail_hint') || 'Require clicking to reveal blurred media on the post detail page instead of showing it immediately.' }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="hide_item_ratings_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_item_ratings_toggle">
<span>{{ t('settings.hide_item_ratings') || 'Hide item ratings' }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_item_ratings_hint') || 'Don\'t show SFW/NSFW rating indicators on main page items' }}</small>
</div>
</fieldset>
<!-- 6. Privacy & Upload Defaults -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-user-shield"></i> Privacy & Defaults</legend>
<div class="setting-item">
<label for="favorites_private_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="favorites_private_toggle" @if(session.favorites_private===true) checked @endif>
<span>{{ t('settings.favorites_private') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.favorites_private_hint') }}</small>
<div class="sub-setting-item" style="margin-left: 25px; margin-top: 10px;">
<label for="hide_fav_badge_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_fav_badge_toggle" @if(session.hide_fav_badge===true) checked @endif>
<span>{{ t('settings.hide_fav_badge') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
</div>
</div>
@if(allow_user_upload_visibility !== false)
<div class="setting-item" style="margin-top: 18px;">
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
<span>{{ t('settings.default_upload_visibility') }}</span>
</label>
<select id="default_upload_visibility_select" class="form-control" style="width: 220px; padding: 6px 10px; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--nav-border-color); border-radius: 4px;">
<option value="0" @if(session.default_upload_visibility === 0 || (!session.default_upload_visibility && session.default_upload_visibility !== 0)) selected @endif>{{ t('visibility.public') }}</option>
<option value="1" @if(session.default_upload_visibility === 1) selected @endif>{{ t('visibility.unlisted') }}</option>
<option value="2" @if(session.default_upload_visibility === 2) selected @endif>{{ t('visibility.private') }}</option>
</select>
<small class="text-muted" style="display: block; margin-top: 5px;">{{ t('settings.default_upload_visibility_hint') }}</small>
</div>
@endif
</fieldset>
<!-- 7. Notifications -->
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-bell"></i> {{ t('settings.notifications_section') }}</legend>
<div class="setting-item">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-receive-system-notifications" @if(session.receive_system_notifications !==false) checked @endif>
<span>{{ t('settings.receive_system_notifications') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_system_notifications_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-receive-user-notifications" @if(session.receive_user_notifications !==false) checked @endif>
<span>{{ t('settings.receive_user_notifications') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_user_notifications_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="chk-do-not-disturb" @if(session.do_not_disturb===true) checked @endif>
<span>{{ t('settings.do_not_disturb') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.do_not_disturb_hint') }}</small>
</div>
</fieldset>
<!-- 8. Flash / SWF Settings (if enabled) -->
@if(enable_swf) @if(enable_swf)
<fieldset <fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;"> <legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-bolt"></i> {{ t('settings.flash_section') }}</legend>
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.flash_section') }}</legend>
<div class="setting-item"> <div class="setting-item">
<label for="ruffle_background_toggle" <label for="ruffle_background_toggle"
@@ -513,10 +509,11 @@
@endif @endif
</div> </div>
@if(enable_data_export) @if(enable_data_export)
<!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ --> <!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ -->
<h2 id="export">{{ t('settings.export_data_title') || 'Export Data' }}</h2> <h2 id="export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') || 'Export Data' }}</h2>
<div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;"> <div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p>{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}</p> <p>{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}</p>
<div class="setting-item" style="margin-bottom: 15px;"> <div class="setting-item" style="margin-bottom: 15px;">
@@ -555,10 +552,11 @@
</button> </button>
</div> </div>
@endif @endif
<!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ --> <!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ -->
<h2 id="account">{{ t('settings.account') }}</h2> <h2 id="account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</h2>
<div class="account-settings-wrapper" <div class="account-settings-wrapper"
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;"> style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<table class="table account-info-table" <table class="table account-info-table"
style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;"> style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;">
<tbody> <tbody>
@@ -584,7 +582,7 @@
<div class="account-actions-grid" <div class="account-actions-grid"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;"> style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
<div class="password-change-section" style="display: flex; flex-direction: column; gap: 15px;"> <div class="password-change-section" style="display: flex; flex-direction: column; gap: 15px;">
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.change_password') }}</h4> <h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-key"></i> {{ t('settings.change_password') }}</h4>
<form id="password-change-form" style="display: flex; flex-direction: column; gap: 10px;"> <form id="password-change-form" style="display: flex; flex-direction: column; gap: 10px;">
<input type="password" id="current_password" class="input" <input type="password" id="current_password" class="input"
placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password"> placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
@@ -599,7 +597,7 @@
</div> </div>
<div class="email-update-section" style="display: flex; flex-direction: column; gap: 15px;"> <div class="email-update-section" style="display: flex; flex-direction: column; gap: 15px;">
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.update_email') }}</h4> <h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-envelope"></i> {{ t('settings.update_email') }}</h4>
<form id="email-update-form" style="display: flex; flex-direction: column; gap: 10px;"> <form id="email-update-form" style="display: flex; flex-direction: column; gap: 10px;">
<input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}" <input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}"
value="{{ email }}" required> value="{{ email }}" required>
@@ -614,10 +612,11 @@
</div> </div>
</div> </div>
</div> </div>
@if(matrix_enabled || telegram_enabled) @if(matrix_enabled || telegram_enabled)
<!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ --> <!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ -->
<h2 id="linked">{{ t('settings.linked_accounts') }}</h2> <h2 id="linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</h2>
<div class="linked-accounts-wrapper"> <div class="linked-accounts-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p>{{ t('settings.matrix_link_desc') }}</p> <p>{{ t('settings.matrix_link_desc') }}</p>
<div id="linked-accounts-container" style="margin-bottom: 1rem;"> <div id="linked-accounts-container" style="margin-bottom: 1rem;">
@@ -653,9 +652,9 @@
@if(enable_user_api_keys) @if(enable_user_api_keys)
<!-- ═══════════════════════════════ API KEY ═══════════════════════════════ --> <!-- ═══════════════════════════════ API KEY ═══════════════════════════════ -->
<h2 id="apikey">Upload API Key</h2> <h2 id="apikey"><i class="fa-solid fa-key"></i> Upload API Key</h2>
<div id="api-key-section" class="account-settings-wrapper" <div id="api-key-section" class="account-settings-wrapper"
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;"> style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p style="color: var(--text-muted); margin-bottom: 15px;"> <p style="color: var(--text-muted); margin-bottom: 15px;">
Use this key to upload files via external tools or scripts like ShareX. Use this key to upload files via external tools or scripts like ShareX.
The key grants <strong>upload access only</strong> to your account. The key grants <strong>upload access only</strong> to your account.
@@ -689,9 +688,9 @@
@if(enable_user_invites) @if(enable_user_invites)
<!-- ═══════════════════════════════ INVITES ═══════════════════════════════ --> <!-- ═══════════════════════════════ INVITES ═══════════════════════════════ -->
<h2 id="invites">{{ t('invites.section_title') }}</h2> <h2 id="invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</h2>
<div id="invite-section" class="account-settings-wrapper" <div id="invite-section" class="account-settings-wrapper"
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;"> style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
<p id="invite-section-desc" style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p> <p id="invite-section-desc" style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p>
@@ -803,8 +802,6 @@
<script> <script>
(function() { (function() {
// Read all translated strings from server-rendered data attributes.
// Attributes must be lowercase kebab-case: data-foo-bar → dataset.fooBar
const ds = document.getElementById('invite-i18n')?.dataset || {}; const ds = document.getElementById('invite-i18n')?.dataset || {};
const T = (key, vars) => { const T = (key, vars) => {
let str = ds[key] || key; let str = ds[key] || key;
@@ -839,7 +836,6 @@
} }
if (data.is_admin) { if (data.is_admin) {
// Swap description and hide criteria/slots UI for admins
const desc = document.getElementById('invite-section-desc'); const desc = document.getElementById('invite-section-desc');
if (desc) desc.textContent = T('adminDesc'); if (desc) desc.textContent = T('adminDesc');
const grid = document.getElementById('invite-criteria-grid'); const grid = document.getElementById('invite-criteria-grid');
@@ -847,7 +843,6 @@
if (grid) grid.style.display = 'none'; if (grid) grid.style.display = 'none';
if (statusLine) statusLine.style.display = 'none'; if (statusLine) statusLine.style.display = 'none';
} else { } else {
// Update criteria grid
const criteriaMap = { const criteriaMap = {
'ic-uploads': data.criteria.uploads, 'ic-uploads': data.criteria.uploads,
'ic-age': data.criteria.age_days, 'ic-age': data.criteria.age_days,
@@ -865,19 +860,15 @@
}); });
} }
// Eligibility badge
const badge = document.getElementById('invite-eligible-badge'); const badge = document.getElementById('invite-eligible-badge');
badge.textContent = data.eligible ? T('eligible') : T('notEligible'); badge.textContent = data.eligible ? T('eligible') : T('notEligible');
badge.style.color = data.eligible ? '#51cf66' : '#ff6b6b'; badge.style.color = data.eligible ? '#51cf66' : '#ff6b6b';
// Slots info
document.getElementById('invite-slots-info').textContent = document.getElementById('invite-slots-info').textContent =
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total }); T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
// Generate button state
genBtn.disabled = !data.eligible || (!data.is_admin && data.slots_available <= 0); genBtn.disabled = !data.eligible || (!data.is_admin && data.slots_available <= 0);
// Token list
if (!data.tokens || data.tokens.length === 0) { if (!data.tokens || data.tokens.length === 0) {
tokenList.innerHTML = '<span class="text-muted" style="font-size:0.9em;">' + T('noInvites') + '</span>'; tokenList.innerHTML = '<span class="text-muted" style="font-size:0.9em;">' + T('noInvites') + '</span>';
} else { } else {

View File

@@ -1,47 +1,133 @@
<div id="excluded-tags-overlay" style="display: none;"> <div id="excluded-tags-overlay" style="display: none;">
<div id="excluded-tags-close">&times;</div> <div class="filter-modal-container search-container" id="filter-modal-box">
<div class="search-container"> <div class="filter-modal-header">
<div id="nav_excluded_tags_list"></div> <h3 class="filter-modal-title"><i class="fa-solid fa-sliders"></i> {{ t('filter.title') || 'Content Filters' }}</h3>
<div id="excluded-tags-close" title="Close Filters">&times;</div>
</div>
<div class="filter-modal-body">
<!-- Section 1: View Layout -->
<div class="modal-filter-group">
<label class="modal-filter-label"><i class="fa-solid fa-table-cells"></i> {{ t('filter.layout') || 'Layout' }}</label>
<div class="filter-btn-group layout-group">
<button class="filter-btn layout-btn {{ (!layout || layout == 'grid') ? 'active' : '' }}" data-layout="grid" title="Classic Grid Layout">
<i class="fa-solid fa-border-all"></i> <span class="btn-text">Grid</span>
</button>
<button class="filter-btn layout-btn {{ (layout == 'masonry') ? 'active' : '' }}" data-layout="masonry" title="Fluid Masonry Layout">
<i class="fa-solid fa-cubes"></i> <span class="btn-text">Masonry</span>
</button>
<button class="filter-btn layout-btn {{ (layout == 'stream') ? 'active' : '' }}" data-layout="stream" title="Vertical Stream / Feed Layout">
<i class="fa-solid fa-list-ul"></i> <span class="btn-text">Stream</span>
</button>
</div>
</div>
<!-- Section 2: Sorting & Timeframe -->
<div class="modal-filter-group">
<label class="modal-filter-label"><i class="fa-solid fa-arrow-down-short-wide"></i> {{ t('filter.sort') || 'Sort' }}</label>
<div class="filter-btn-group sort-group">
<button class="filter-btn sort-btn {{ (!sort || sort == 'newest') ? 'active' : '' }}" data-sort="newest" title="Newest First">
<i class="fa-solid fa-clock"></i> Newest
</button>
<button class="filter-btn sort-btn {{ (sort == 'oldest') ? 'active' : '' }}" data-sort="oldest" title="Oldest First">
<i class="fa-solid fa-clock-rotate-left"></i> Oldest
</button>
<button class="filter-btn sort-btn {{ (sort == 'popular' || sort == 'top' || sort == 'xd') ? 'active' : '' }}" data-sort="popular" title="Highest xD Score">
<i class="fa-solid fa-fire"></i> Top xD
</button>
<button class="filter-btn sort-btn {{ (sort == 'comments') ? 'active' : '' }}" data-sort="comments" title="Most Discussed">
<i class="fa-solid fa-comments"></i> Most Comments
</button>
</div>
<div class="filter-timeframe-subgroup {{ (sort == 'popular' || sort == 'top' || sort == 'xd' || sort == 'comments') ? 'visible' : '' }}" id="filter-timeframe-section" style="margin-top: 8px;">
<div class="filter-btn-group timeframe-group">
<button class="filter-btn timeframe-btn {{ (!timeframe || timeframe == 'all') ? 'active' : '' }}" data-timeframe="all">All</button>
<button class="filter-btn timeframe-btn {{ (timeframe == 'day') ? 'active' : '' }}" data-timeframe="day">24h</button>
<button class="filter-btn timeframe-btn {{ (timeframe == 'week') ? 'active' : '' }}" data-timeframe="week">Week</button>
<button class="filter-btn timeframe-btn {{ (timeframe == 'month') ? 'active' : '' }}" data-timeframe="month">Month</button>
<button class="filter-btn timeframe-btn {{ (timeframe == 'year') ? 'active' : '' }}" data-timeframe="year">Year</button>
</div>
</div>
</div>
<!-- Section 3: Rating Selection -->
<div class="modal-filter-group">
<label class="modal-filter-label"><i class="fa-solid fa-shield-halved"></i> {{ t('filter.rating') || 'Rating' }}</label>
<div class="rating-selector filter-toggles rating-group" id="rating-selector">
<button class="rating-toggle-btn rating-sfw {{ rating_sfw ? 'active' : '' }}" data-rating="sfw" id="rating-btn-sfw" title="Safe For Work">
<i class="fa-solid fa-circle-check"></i> SFW
</button>
<button class="rating-toggle-btn rating-nsfw {{ rating_nsfw ? 'active' : '' }}" data-rating="nsfw" id="rating-btn-nsfw" title="Not Safe For Work">
<i class="fa-solid fa-triangle-exclamation"></i> NSFW
</button>
@if(enable_nsfl)
<button class="rating-toggle-btn rating-nsfl {{ rating_nsfl ? 'active' : '' }}" data-rating="nsfl" id="rating-btn-nsfl" title="Not Safe For Life">
<i class="fa-solid fa-skull"></i> NSFL
</button>
@endif
@if(session && (session.admin || session.is_moderator))
<button class="rating-toggle-btn rating-untagged {{ rating_untagged ? 'active' : '' }}" data-rating="untagged" id="rating-btn-untagged" title="Untagged">
<i class="fa-solid fa-question"></i> Untagged
</button>
@endif
<button class="rating-toggle-btn rating-toggle-all {{ (!rating_sfw && !rating_nsfw && !rating_nsfl && !rating_untagged) || (rating_sfw && rating_nsfw && rating_untagged) ? 'active' : '' }}" id="rating-btn-all" data-rating="" title="Show All Ratings">
<i class="fa-solid fa-border-all"></i> ALL
</button>
</div>
</div>
<!-- Section 4: Media Types -->
<div class="modal-filter-group">
<label class="modal-filter-label"><i class="fa-solid fa-photo-film"></i> {{ t('filter.media_types') || 'Media Types' }}</label>
<div class="filter-toggles mime-group">
<button class="filter-toggle-pill mime-pill {{ mime_image ? 'active' : '' }}" data-mime="image" title="Images">
<i class="fa-regular fa-image"></i> Image
</button>
<button class="filter-toggle-pill mime-pill {{ mime_video ? 'active' : '' }}" data-mime="video" title="Videos">
<i class="fa-solid fa-film"></i> Video
</button>
<button class="filter-toggle-pill mime-pill {{ mime_audio ? 'active' : '' }}" data-mime="audio" title="Audio / Music">
<i class="fa-solid fa-music"></i> Audio
</button>
@if(enable_swf)
<button class="filter-toggle-pill mime-pill {{ mime_flash ? 'active' : '' }}" data-mime="flash" title="Flash Animations / Games">
<i class="fa-solid fa-bolt"></i> Flash
</button>
@endif
</div>
</div>
<!-- Section 5: Attributes & Flags -->
<div class="modal-filter-group">
<label class="modal-filter-label"><i class="fa-solid fa-tags"></i> {{ t('filter.attributes') || 'Attributes' }}</label>
<div class="filter-toggles">
<button class="filter-toggle-pill {{ (oc == '1' || oc == 'true') ? 'active' : '' }}" id="toggle-oc-btn" data-toggle="oc" title="Original Content Only">
<i class="fa-solid fa-certificate"></i> OC Only
</button>
<button class="filter-toggle-pill {{ (hasComments == '1' || hasComments == 'true') ? 'active' : '' }}" id="toggle-comments-btn" data-toggle="has_comments" title="Posts with Comments Only">
<i class="fa-solid fa-comment-dots"></i> Discussed
</button>
<button id="nav-shuffle-btn" class="filter-toggle-pill shuffle-btn" title="Random Mode — shuffle items">
<i class="fa-solid fa-shuffle"></i> Random Mode
</button>
</div>
</div>
<!-- Section 6: Excluded Tags Blacklist -->
<div class="modal-filter-group">
<label class="modal-filter-label"><i class="fa-solid fa-ban"></i> {{ t('filter.excluded_tags') || 'Excluded Tags' }}</label>
<div id="nav_excluded_tags_list" style="margin-bottom: 8px;"></div>
<div class="nav-exclude"> <div class="nav-exclude">
<input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') }}" autocomplete="off" enterkeyhint="enter"> <input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') || 'Type a tag to exclude...' }}" autocomplete="off" enterkeyhint="enter">
<div id="nav_exclude_suggestions" class="tag-suggestions"></div> <div id="nav_exclude_suggestions" class="tag-suggestions"></div>
</div> </div>
<!-- modes -->
<div class="mode-filter">
<button id="nav-shuffle-btn" class="shuffle-btn" title="Random Mode — shuffle all items"><span class="shuffle-icon">( - _ - )</span> {{ t('filter.random_mode') }}</button>
<div class="rating-selector" id="rating-selector">
<button class="rating-toggle-btn" data-rating="sfw" id="rating-btn-sfw" title="SFW">SFW</button>
<button class="rating-toggle-btn" data-rating="nsfw" id="rating-btn-nsfw" title="NSFW">NSFW</button>
@if(enable_nsfl)
<button class="rating-toggle-btn" data-rating="nsfl" id="rating-btn-nsfl" title="NSFL">NSFL</button>
@endif
@if(session && session.admin || session && session.is_moderator)
<button class="rating-toggle-btn" data-rating="untagged" id="rating-btn-untagged" title="Untagged">UNT</button>
@endif
<button class="rating-toggle-btn rating-toggle-all" id="rating-btn-all" title="Show All Ratings">ALL</button>
</div> </div>
</div>
<!-- mimes -->
@if(show_mime_picker)
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu">
@if(scroller_mime_cats && scroller_mime_cats.includes('video'))
<label class="nav-mime-item"><input type="checkbox" value="video" id="mime-video">{{ t('filter.video') }}</label>
@endif
@if(scroller_mime_cats && scroller_mime_cats.includes('audio'))
<label class="nav-mime-item"><input type="checkbox" value="audio" id="mime-audio">{{ t('filter.audio') }}</label>
@endif
@if(scroller_mime_cats && scroller_mime_cats.includes('image'))
<label class="nav-mime-item"><input type="checkbox" value="image" id="mime-image">{{ t('filter.image') }}</label>
@endif
@if(enable_swf)
<label class="nav-mime-item"><input type="checkbox" value="flash" id="mime-flash">{{ t('filter.flash') }}</label>
@endif
</div>
@endif
@if(session && enable_xd_score) @if(session && enable_xd_score)
<!-- xD score filter --> <!-- Section 7: xD Score Filter -->
<div class="nav-xd-filter"> <div class="modal-filter-group nav-xd-filter">
<label class="nav-xd-label">{{ t('filter.min_xd_score') }}</label> <label class="modal-filter-label"><i class="fa-solid fa-gauge-high"></i> {{ t('filter.min_xd_score') }}</label>
<div class="nav-xd-controls"> <div class="nav-xd-controls">
<input type="range" id="nav_min_xd_score" min="0" max="100" step="1" value="{{ session.min_xd_score || 0 }}" class="xd-slider" style="display:none;" aria-hidden="true"> <input type="range" id="nav_min_xd_score" min="0" max="100" step="1" value="{{ session.min_xd_score || 0 }}" class="xd-slider" style="display:none;" aria-hidden="true">
<div id="nav_xd_slider_container" class="nav-xd-custom-slider"></div> <div id="nav_xd_slider_container" class="nav-xd-custom-slider"></div>
@@ -53,4 +139,15 @@
</div> </div>
@endif @endif
</div> </div>
<!-- Modal Footer Actions -->
<div class="filter-modal-footer">
<button class="filter-reset-btn" id="filter-reset-btn" title="Reset All Filters to Default">
<i class="fa-solid fa-rotate-left"></i> Reset All
</button>
<button class="filter-apply-btn" id="filter-modal-done-btn">
<i class="fa-solid fa-check"></i> Done
</button>
</div>
</div>
</div> </div>

View File

@@ -0,0 +1 @@
<!-- Filter bar has been integrated into excluded-tags-modal.html -->

View File

@@ -379,6 +379,9 @@
enable_nsfl: @if(enable_nsfl) true @else false @endif, enable_nsfl: @if(enable_nsfl) true @else false @endif,
user: @if(session) "{{ session.user }}" @else null @endif, user: @if(session) "{{ session.user }}" @else null @endif,
display_name: @if(session && session.display_name) "{!! session.display_name !!}" @else null @endif, display_name: @if(session && session.display_name) "{!! session.display_name !!}" @else null @endif,
username_color: @if(session && session.username_color) "{!! session.username_color !!}" @else null @endif,
id: @if(session && session.id) {{ session.id }} @else null @endif,
user_id: @if(session && session.id) {{ session.id }} @else null @endif,
is_admin: @if(session && session.admin) true @else false @endif, is_admin: @if(session && session.admin) true @else false @endif,
is_moderator: @if(session && session.is_moderator) true @else false @endif, is_moderator: @if(session && session.is_moderator) true @else false @endif,
enable_swf: @if(enable_swf) true @else false @endif, enable_swf: @if(enable_swf) true @else false @endif,
@@ -391,9 +394,14 @@
embed_youtube_in_comments: @if(session && session.embed_youtube_in_comments !== false) true @else false @endif, embed_youtube_in_comments: @if(session && session.embed_youtube_in_comments !== false) true @else false @endif,
avatar: @if(session && session.avatar) {{ session.avatar }} @else null @endif, avatar: @if(session && session.avatar) {{ session.avatar }} @else null @endif,
avatar_file: @if(session && session.avatar_file) "{{ session.avatar_file }}" @else null @endif, avatar_file: @if(session && session.avatar_file) "{{ session.avatar_file }}" @else null @endif,
banner_file: @if(session && session.banner_file) "{{ session.banner_file }}" @else null @endif,
banner_position: @if(session && session.banner_position) "{{ session.banner_position }}" @else null @endif,
banner_size: @if(session && session.banner_size) "{{ session.banner_size }}" @else null @endif,
banner_repeat: @if(session && session.banner_repeat) "{{ session.banner_repeat }}" @else null @endif,
receive_system_notifications: @if(session)@if(session.receive_system_notifications !== false) true @else false @endif@else true @endif, receive_system_notifications: @if(session)@if(session.receive_system_notifications !== false) true @else false @endif@else true @endif,
receive_user_notifications: @if(session)@if(session.receive_user_notifications !== false) true @else false @endif@else true @endif, receive_user_notifications: @if(session)@if(session.receive_user_notifications !== false) true @else false @endif@else true @endif,
do_not_disturb: @if(session && session.do_not_disturb) true @else false @endif, do_not_disturb: @if(session && session.do_not_disturb) true @else false @endif,
comment_banner_enabled: @if(comment_banner_enabled) true @else false @endif,
comment_display_mode: {{ comment_display_mode }}, comment_display_mode: {{ comment_display_mode }},
comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }},
development: @if(development) true @else false @endif, development: @if(development) true @else false @endif,

View File

@@ -2,7 +2,7 @@
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif"> <html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
<head> <head>
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ item.id }}</title>@else<title>{{ domain }}</title>@endif @if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</title>@else<title>{{ domain }}</title>@endif
<link rel="manifest" href="/manifest.json"> <link rel="manifest" href="/manifest.json">
<meta name="theme-color" content="#0096ff"> <meta name="theme-color" content="#0096ff">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -23,6 +23,8 @@
} }
@endif @endif
:root { :root {
--comment-banner-display: @if(comment_banner_enabled) block @else none @endif;
--comment-banner-max-height: @if(typeof comment_banner_max_height !== 'undefined'){{ comment_banner_max_height }}px@else 300px @endif;
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif'); --favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
@if(session && session.font) @if(session && session.font)
--font: 'CustomUserFont', monospace !important; --font: 'CustomUserFont', monospace !important;
@@ -64,7 +66,7 @@
@endif @endif
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}"> <link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
@endif @endif
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }};</script> <script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif;</script>
@if(!private_society || session) @if(!private_society || session)
<script src="/s/js/marked.min.js" defer></script> <script src="/s/js/marked.min.js" defer></script>
<script src="/s/js/comments.js?v={{ ts }}" defer></script> <script src="/s/js/comments.js?v={{ ts }}" defer></script>
@@ -72,18 +74,18 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
@if(typeof item !== 'undefined') @if(typeof item !== 'undefined')
<link rel="canonical" href="https://{{ domain }}/{{ item.id }}" /> <link rel="canonical" href="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
<meta property="og:site_name" content="{{ domain }}" /> <meta property="og:site_name" content="{{ domain }}" />
<meta property="og:title" content="{{ item.id }}" /> <meta property="og:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" /> <meta property="og:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" /> <meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
<meta name="description" content="{{ site_description }}" /> <meta name="description" content="{{ site_description }}" />
<meta property="og:description" content="{{ site_description }}" /> <meta property="og:description" content="{{ site_description }}" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="twitter:card" content="summary" /> <meta property="twitter:card" content="summary" />
<meta property="twitter:title" content="{{ item.id }}" /> <meta property="twitter:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" /> <meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
<meta property="twitter:url" content="https://{{ domain }}/{{ item.id }}" /> <meta property="twitter:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
@else @else
<meta property="og:site_name" content="{{ domain }}" /> <meta property="og:site_name" content="{{ domain }}" />
<meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" /> <meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" />

View File

@@ -0,0 +1,184 @@
<div id="info-modal" class="modal-overlay" style="display: none;">
<div class="modal-content infobox-modal-content">
<!-- Header -->
<div class="infobox-modal-header">
<div class="infobox-header-title">
<div class="infobox-header-text">
<h3>{{ t('info_modal.title') || 'Post & File Details' }}</h3>
<span class="infobox-sub-heading">ID: <code>{{ item.id }}</code></span>
</div>
</div>
<button type="button" class="infobox-close-icon-btn" id="info-modal-close-icon" title="{{ t('common.close') || 'Close' }}">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<!-- Body -->
<div class="infobox-modal-body">
<!-- Section 1: Post Details -->
<div class="infobox-section">
<div class="infobox-section-header">
<i class="fa-solid fa-sliders"></i>
<span>Post Configuration</span>
</div>
<div class="infobox-cards-grid">
@if(enable_item_title)
<div class="infobox-card full-width">
<label class="infobox-card-label"><i class="fa-solid fa-heading"></i> Title</label>
@if(can_manage_item)
<div class="info-title-edit-wrap">
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
<button type="button" id="info-title-save" class="info-title-save-btn" title="Save Title"><i class="fa-solid fa-check"></i></button>
</div>
<span id="info-title-status" class="info-title-status" style="display:none"></span>
@else
<div class="infobox-card-value">{!! item.title || '<span class="text-muted">No title set</span>' !!}</div>
@endif
</div>
@endif
<div class="infobox-card">
<label class="infobox-card-label"><i class="fa-solid fa-eye"></i> Visibility</label>
<div class="infobox-card-value-row">
<span id="info-visibility-label" class="visibility-badge vis-{{ item.visibility || 0 }}">
@if(item.visibility === 2)
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
@elseif(item.visibility === 1)
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
@else
<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public
@endif
</span>
@if(can_manage_item)
<button type="button" id="info-visibility-edit-btn" class="infobox-action-btn" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}">
<i class="fa-solid fa-pen"></i> Edit
</button>
@endif
</div>
</div>
@if(enable_expiring_uploads !== false)
<div class="infobox-card">
<label class="infobox-card-label"><i class="fa-solid fa-hourglass-half"></i> Expiration</label>
<div class="infobox-expiry-content">
<span id="info-expiry-label" class="expiry-status-label" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
@if(item.expires_at)
<i class="fa-solid fa-clock" style="color: #ffaa00;"></i> {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
@else
<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Permanent
@endif
</span>
@if(can_manage_item)
<div class="info-expiry-edit-controls">
<select id="info-expiry-select" class="infobox-select" data-item-id="{{ item.id }}">
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent</option>
<option value="30minutes">30 Minutes</option>
<option value="1hour">1 Hour</option>
<option value="24hours">24 Hours</option>
<option value="1week">1 Week</option>
<option value="1month">1 Month</option>
</select>
<button type="button" id="info-set-expiry-btn" class="infobox-action-btn" data-item-id="{{ item.id }}" title="Save Expiration">
<i class="fa-solid fa-check"></i>
</button>
</div>
@endif
</div>
</div>
@endif
</div>
</div>
<!-- Section 2: Technical File Specifications -->
<div class="infobox-section">
<div class="infobox-section-header">
<i class="fa-solid fa-file-code"></i>
<span>Technical Specifications</span>
</div>
<div class="infobox-cards-grid">
<div class="infobox-card">
<label class="infobox-card-label"><i class="fa-solid fa-hard-drive"></i> File Size</label>
<div class="infobox-card-value font-mono">{{ item.size }}</div>
</div>
@if(item_has_dimensions)
<div class="infobox-card">
<label class="infobox-card-label"><i class="fa-solid fa-ruler-combined"></i> Dimensions</label>
<div class="infobox-card-value font-mono">{{ item.width }} × {{ item.height }} px</div>
</div>
@endif
<div class="infobox-card">
<label class="infobox-card-label"><i class="fa-solid fa-file-lines"></i> MIME Type</label>
<div class="infobox-card-value"><code class="mime-pill">{{ item.mime }}</code></div>
</div>
<div class="infobox-card">
<label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label>
<div class="infobox-card-value">
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill">
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
</a>
</div>
</div>
@if(item.src && item.src.short)
<div class="infobox-card full-width">
<label class="infobox-card-label"><i class="fa-solid fa-link"></i> Source URL</label>
<div class="infobox-card-value">
<a href="{{ item.src.long }}" target="_blank" class="infobox-source-link">
<i class="fa-solid fa-globe"></i> {{ item.src.short }}
</a>
</div>
</div>
@endif
@if(item.checksum)
<div class="infobox-card full-width">
<div class="infobox-card-label-row">
<label class="infobox-card-label"><i class="fa-solid fa-fingerprint"></i> SHA-256 Hash</label>
<button type="button" class="copy-hash-btn" id="info-copy-hash-btn" data-hash="{{ item.checksum.split('_bypass_')[0] }}" title="Copy SHA-256 Hash">
<i class="fa-solid fa-copy"></i> Copy
</button>
</div>
<div class="info-hash-codeblock font-mono"><code>{{ item.checksum.split('_bypass_')[0] }}</code></div>
</div>
@endif
</div>
</div>
<!-- Section 3: Reposts & Duplicates (if present) -->
@if(item.show_repost_row)
<div class="infobox-section">
<div class="infobox-section-header">
<i class="fa-solid fa-copy"></i>
<span>Similar & Duplicate Uploads</span>
</div>
<div class="infobox-reposts-list">
@each(item.reposts as rp)
<a href="/{{ rp.slug || rp.id }}" class="repost-pill {{ rp.match_type === 'phash' ? 'phash' : 'exact' }}" tooltip="{{ rp.match_type === 'phash' ? 'Visually similar (perceptual hash)' : 'Exact duplicate (checksum)' }}" flow="up">
<i class="fa-solid {{ rp.match_type === 'phash' ? 'fa-clone' : 'fa-copy' }}"></i>
<span>{{ rp.match_type === 'phash' ? '~#' : '#' }}{{ rp.slug || rp.id }}</span>
</a>
@endeach
</div>
</div>
@endif
</div>
<!-- Footer -->
<div class="infobox-modal-footer">
@if(can_manage_item)
<button type="button" class="btn-infobox-action" id="info-rethumb-btn" data-item-id="{{ item.id }}">
<i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail
</button>
@endif
<button type="button" class="btn-infobox-close" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
</div>
</div>
</div>

View File

@@ -1,5 +1,42 @@
@if(layout == 'stream')
<!-- Stream / Vertical Feed Layout -->
@each(items as item) @each(items as item)
<a href="{{ link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}"> <div class="stream-card {{ item.is_pinned ? 'is-pinned' : '' }} {{ item.has_notification ? 'has-notif' : '' }}" data-id="{{ item.id }}">
<div class="stream-card-header">
<a href="/user/{{ item.username.toLowerCase() }}" class="stream-author">
<span class="stream-username" @if(item.author_color)style="color: {{ item.author_color }}"@endif>{!! item.display_name || item.username !!}</span>
</a>
<div class="stream-meta">
@if(item.is_pinned)
<span class="badge-pin"><i class="fa-solid fa-thumbtack"></i> Pinned</span>
@endif
@if(item.is_oc)
<span class="badge-oc">OC</span>
@endif
@if(enable_xd_score && item.xd_tier > 0)
<span class="thumb-xd-indicator xd-tier-{{ item.xd_tier }}">xD: {{ item.xd_score }}</span>
@endif
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-permalink"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
</div>
</div>
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-media-wrapper">
<img src="/t/{{ item.id }}.webp" alt="Media thumbnail" class="stream-media-img" loading="lazy">
<span class="stream-media-badge">{{ item.mime.split('/')[1].replace('youtube', 'YT').replace('x-shockwave-flash', 'FLASH').toUpperCase() }}</span>
</a>
<div class="stream-card-footer">
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-action-btn">
<i class="fa-regular fa-comments"></i> View Post & Comments
</a>
</div>
</div>
@endeach
@else
<!-- Grid & Masonry Layout (Default) -->
@each(items as item)
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
<div class="thumb-indicators"> <div class="thumb-indicators">
@if(item.is_pinned) @if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i> <i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@@ -14,3 +51,4 @@
<p></p> <p></p>
</a> </a>
@endeach @endeach
@endif

View File

@@ -43,11 +43,11 @@
<div class="nav-user-dropdown"> <div class="nav-user-dropdown">
<button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle"> <button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle">
@if(session.avatar_file) @if(session.avatar_file)
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'"> <img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
@elseif(session.avatar && session.avatar > 0) @elseif(session.avatar && session.avatar > 0)
<img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" onerror="this.src='/a/default.png'"> <img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
@else @else
<img class="nav-avatar-img" src="/a/default.png"> <img class="nav-avatar-img" src="/a/default.png" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
@endif @endif
<span class="nav-avatar-name" id="nav-display-name" @if(session.username_color)style="color: {{ session.username_color }}"@endif>{!! session.display_name || session.user !!}</span> <span class="nav-avatar-name" id="nav-display-name" @if(session.username_color)style="color: {{ session.username_color }}"@endif>{!! session.display_name || session.user !!}</span>
<span class="nav-avatar-caret"></span> <span class="nav-avatar-caret"></span>
@@ -166,7 +166,7 @@
@if(custom_brand_image) @if(custom_brand_image)
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: auto;"> <img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: auto;">
@else @else
<span class="f0ck">{{ custom_navbar_brand_text || domain.split('.')[0] }}</span> <span class="f0ck">{{ custom_navbar_brand_text || domain }}</span>
@endif @endif
</a> </a>

View File

@@ -1,12 +1,12 @@
@each(notifications as n) @each(notifications as n)
@if(n.type === 'approve') @if(n.type === 'approve')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/>
</div> </div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div> <div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_id) }}</div> <div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_slug || n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div> <div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div> </div>
</a> </a>
@@ -17,7 +17,7 @@
</div> </div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div> <div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div>
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_id) }}</div> <div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_slug || n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div> <div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div> </div>
</a> </a>
@@ -33,46 +33,46 @@
</div> </div>
</a> </a>
@elseif(n.type === 'deny') @elseif(n.type === 'deny')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div> </div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div> <div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
<div class="notif-msg"> <div class="notif-msg">
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_id) }}</strong> <strong>{{ t('notifications.upload_denied').replace('{id}', n.item_slug || n.item_id) }}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div> <div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
</div> </div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div> <div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div> </div>
</a> </a>
@elseif(n.type === 'item_deleted') @elseif(n.type === 'item_deleted')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
</div> </div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div> <div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
<div class="notif-msg"> <div class="notif-msg">
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_id) }}</strong> <strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_slug || n.item_id) }}</strong>
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div> <div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
</div> </div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div> <div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div> </div>
</a> </a>
@elseif(n.type === 'upload_comment') @elseif(n.type === 'upload_comment')
<a href="/{{ n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}"> <a href="/{{ n.item_slug || n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'"> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'">
</div> </div>
<div class="notif-content"> <div class="notif-content">
<div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div> <div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div>
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_id) }}</div> <div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_slug || n.item_id) }}</div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div> <div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
</div> </div>
</a> </a>
@elseif(n.type === 'upload_success') @elseif(n.type === 'upload_success')
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}"> <a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
</div> </div>
@@ -83,7 +83,7 @@
</div> </div>
</a> </a>
@elseif(n.type === 'upload_error') @elseif(n.type === 'upload_error')
<a href="{{ n.item_id ? '/' + n.item_id : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}"> <a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
@if(n.item_id) @if(n.item_id)
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
@@ -116,7 +116,7 @@
</div> </div>
</div> </div>
@else @else
<a href="/{{ n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}"> <a href="/{{ n.item_slug || n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
@if(n.item_id) @if(n.item_id)
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}"> <div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" /> <img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
@@ -125,9 +125,9 @@
<div class="notif-content"> <div class="notif-content">
<div class="notif-user"><strong @if(n.username_color) style="color: {{ n.username_color }}" @endif>{!! n.from_user !!}</strong></div> <div class="notif-user"><strong @if(n.username_color) style="color: {{ n.username_color }}" @endif>{!! n.from_user !!}</strong></div>
<div class="notif-msg"> <div class="notif-msg">
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_id) }} @if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_slug || n.item_id) }}
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_id) }} @elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_slug || n.item_id) }}
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_id) }} @elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_slug || n.item_id) }}
@endif @endif
</div> </div>
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div> <div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>

View File

@@ -1,6 +1,6 @@
@each(items as item) @each(items as item)
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}"> <div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
<a href="/{{ item.id }}" class="sub-link"> <a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="sub-link">
<div class="thumb-indicators"> <div class="thumb-indicators">
@if(item.is_pinned) @if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i> <i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@@ -8,7 +8,7 @@
</div> </div>
<img src="{{ item.thumb }}" loading="lazy" /> <img src="{{ item.thumb }}" loading="lazy" />
<div class="sub-info"> <div class="sub-info">
<span class="sub-id">#{{ item.id }}</span> <span class="sub-id">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</span>
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span> <span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
</div> </div>
</a> </a>

View File

@@ -79,6 +79,41 @@
</div> </div>
</div> </div>
@if(enable_private_uploads !== false && allow_user_upload_visibility !== false)
<div class="form-section global-visibility-section">
<label>{{ t('upload.visibility') || 'Visibility' }}</label>
<div class="visibility-options rating-options">
<label class="rating-option">
<input type="radio" name="visibility" value="0" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 0) ? 'checked' : '' }}>
<span class="rating-label sfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-globe"></i> {{ t('common.public') || 'Public' }}</span>
</label>
<label class="rating-option">
<input type="radio" name="visibility" value="1" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 1) ? 'checked' : '' }}>
<span class="rating-label nsfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-link"></i> {{ t('common.unlisted') || 'Unlisted' }}</span>
</label>
<label class="rating-option">
<input type="radio" name="visibility" value="2" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 2) ? 'checked' : '' }}>
<span class="rating-label nsfl" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-lock"></i> {{ t('common.private') || 'Private' }}</span>
</label>
</div>
</div>
@endif
@if(enable_expiring_uploads !== false)
<div class="form-section global-expiry-section">
<label><i class="fa-solid fa-clock"></i> Expiration / Self-Destruct</label>
<select name="expiry" class="expiry-select" style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 6px; cursor: pointer; font-size: 0.95em;">
<option value="permanent" selected>Permanent (Never expires)</option>
<option value="30minutes">30 Minutes</option>
<option value="1hour">1 Hour</option>
<option value="24hours">24 Hours</option>
<option value="1week">1 Week</option>
<option value="1month">1 Month</option>
</select>
</div>
@endif
<div class="form-section global-tag-section"> <div class="form-section global-tag-section">
<label> <label>

View File

@@ -123,7 +123,7 @@
@if(count.f0cks) @if(count.f0cks)
<div class="posts no-infinite-scroll"> <div class="posts no-infinite-scroll">
@each(f0cks.items as item) @each(f0cks.items as item)
<a href="{{ f0cks.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp"> <a href="{{ f0cks.link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<div class="thumb-indicators"> <div class="thumb-indicators">
@if(item.is_pinned) @if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i> <i class="fa-solid fa-thumbtack pin-indicator anim"></i>
@@ -156,7 +156,7 @@
@if(count.favs) @if(count.favs)
<div class="posts no-infinite-scroll"> <div class="posts no-infinite-scroll">
@each(favs.items as item) @each(favs.items as item)
<a href="{{ favs.link.main }}{{ item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp"> <a href="{{ favs.link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp">
<div class="thumb-indicators"> <div class="thumb-indicators">
@if(item.is_pinned) @if(item.is_pinned)
<i class="fa-solid fa-thumbtack pin-indicator anim"></i> <i class="fa-solid fa-thumbtack pin-indicator anim"></i>