Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a6e0f44f3 | |||
| 1eef246bc1 | |||
| 64508b555a | |||
| 9377f05454 | |||
| 162a6bc0fc | |||
| 2fdbb1276a | |||
| dac84a6328 | |||
| 794e21c66f | |||
| 11a4535ca9 | |||
| 3359e9f0d8 | |||
| 5c25182f6a | |||
| c644f7fa2a | |||
| e793b50348 | |||
| 53417063e4 | |||
| d6e273a065 | |||
| 0b2ece9447 | |||
| 63ea313742 | |||
| 619a6f9557 | |||
| 39646aa8c5 | |||
| 3e4f8c80b2 | |||
| d700ed1aa5 | |||
| 8b5da35341 | |||
| 3d1579eed9 | |||
| ef44808210 | |||
| 5ebc3adff9 | |||
| fcf55b0156 | |||
| febf0f9ae5 | |||
| 6fb5cc5c35 | |||
| 972371e7c8 | |||
| 7dbd7fe270 | |||
| 57d41913d1 | |||
| d872e7bae3 | |||
| 4a2bd0bd9a | |||
| 82298681af | |||
| 22db479bc2 | |||
| 9ec07e8e6c | |||
| 402a043b57 | |||
| 9f07dae200 | |||
| 23cbc689fb | |||
| b710f606ec | |||
| c92306867f | |||
| 97604bda70 | |||
| 3f7958f869 | |||
| ebd7855f15 | |||
| fb3cbc76ec | |||
| 2865a2d4e7 | |||
| f074d82bf6 | |||
| 07a874ec99 | |||
| e894795fe0 | |||
| 2e53e7e173 | |||
| 8cdd4fa69a | |||
| 66386213a1 | |||
| 06dd14712d | |||
| 3e8c7c03ee | |||
| cb1b91c4c9 | |||
| 665924c84e | |||
| 22dc1cd25b | |||
| a8a093450f | |||
| 3e20394c85 | |||
| f1611be50a | |||
| 2f9e0f19e4 | |||
| ff735f49fa | |||
| 6e01e6e36f | |||
| 40ce0ccbb1 | |||
| b9c7e24757 | |||
| 9ae1d55521 | |||
| 98ee76f61c | |||
| b10ddac6f3 | |||
| 3179d15b42 | |||
| 6dd70a0fc2 | |||
| 6ab0505e7e | |||
| 162f1b40d0 | |||
| 9d2b0137da | |||
| e179eb1955 | |||
| a80e6dab96 | |||
| c22ae5bde0 | |||
| c9f5dc02fa | |||
| 4fd15ec338 | |||
| feb408338a |
11
build.bash
11
build.bash
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
0
f0ckm-data/koepfe/.gitkeep
Normal file
0
f0ckm-data/koepfe/.gitkeep
Normal file
3
migrations/add_expiring_uploads.sql
Normal file
3
migrations/add_expiring_uploads.sql
Normal 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;
|
||||||
38
migrations/add_private_unlisted_uploads.sql
Normal file
38
migrations/add_private_unlisted_uploads.sql
Normal 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
2
migrations/fix_null_visibility.sql
Normal file
2
migrations/fix_null_visibility.sql
Normal 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
12
package-lock.json
generated
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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';
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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'} »</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'} »</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">
|
||||||
|
|||||||
@@ -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)}"]`);
|
||||||
|
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
|
||||||
if (row) {
|
if (row) {
|
||||||
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
|
|
||||||
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,39 +2846,14 @@ 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 {
|
|
||||||
window.location.href = '/';
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
restoreBtn();
|
restoreBtn();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
529
scripts/import.mjs
Normal 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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -38,6 +38,16 @@ export default new class {
|
|||||||
.replace(/'/g, "'");
|
.replace(/'/g, "'");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
generateSlug(length = 11) {
|
||||||
|
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||||
|
let slug = '';
|
||||||
|
const bytes = crypto.randomBytes(length);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
slug += chars[bytes[i] % chars.length];
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
@@ -669,11 +824,6 @@ 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
|
||||||
@@ -681,11 +831,11 @@ export default {
|
|||||||
|
|
||||||
// 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()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 rows = {
|
const actitem = item[0];
|
||||||
...item[0],
|
const session = req.session;
|
||||||
...{
|
const isOwnerOrAdmin = session && (
|
||||||
next: next[0]?.id ?? null,
|
session.admin ||
|
||||||
prev: prev[0]?.id ?? null
|
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 = {
|
||||||
|
...actitem,
|
||||||
|
next: next[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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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 }) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,65 +156,69 @@ export default (router, tpl) => {
|
|||||||
console.error('[MOD APPROVE] Failed to notify user:', err);
|
console.error('[MOD APPROVE] Failed to notify user:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Push to Discord Webhook (Direct)
|
const isPublic = (f0ck[0].visibility || 0) === 0;
|
||||||
try {
|
|
||||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
|
||||||
if (discordClient && discordClient.webhook_url) {
|
|
||||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
|
||||||
const payload = JSON.stringify({ content: message });
|
|
||||||
const url = new URL(discordClient.webhook_url);
|
|
||||||
const options = {
|
|
||||||
hostname: url.hostname,
|
|
||||||
path: url.pathname + url.search,
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Content-Length': Buffer.byteLength(payload)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const reqDiscord = https.request(options, (resDiscord) => {
|
|
||||||
if (resDiscord.statusCode >= 400) {
|
|
||||||
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
reqDiscord.on('error', (err) => {
|
|
||||||
console.error('[MOD APPROVE] Webhook failed:', err);
|
|
||||||
});
|
|
||||||
reqDiscord.write(payload);
|
|
||||||
reqDiscord.end();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[MOD APPROVE] Discord Webhook error:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push to Matrix Channel
|
if (isPublic) {
|
||||||
try {
|
// Push to Discord Webhook (Direct)
|
||||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
try {
|
||||||
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
|
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||||
const clients = await Promise.all(router.self.bot.clients);
|
if (discordClient && discordClient.webhook_url) {
|
||||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
|
||||||
if (matrixWrapper?.client) {
|
|
||||||
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
const payload = JSON.stringify({ content: message });
|
||||||
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
|
const url = new URL(discordClient.webhook_url);
|
||||||
|
const options = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(payload)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const reqDiscord = https.request(options, (resDiscord) => {
|
||||||
|
if (resDiscord.statusCode >= 400) {
|
||||||
|
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reqDiscord.on('error', (err) => {
|
||||||
|
console.error('[MOD APPROVE] Webhook failed:', err);
|
||||||
|
});
|
||||||
|
reqDiscord.write(payload);
|
||||||
|
reqDiscord.end();
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MOD APPROVE] Discord Webhook error:', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('[MOD APPROVE] Matrix notification error:', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast new_item event for live grid updates
|
// Push to Matrix Channel
|
||||||
try {
|
try {
|
||||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||||
id: id,
|
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
|
||||||
dest: f0ck[0].dest,
|
const clients = await Promise.all(router.self.bot.clients);
|
||||||
mime: f0ck[0].mime,
|
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||||
username: f0ck[0].username,
|
if (matrixWrapper?.client) {
|
||||||
tag_id: f0ck[0].tag_id,
|
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
|
||||||
is_oc: !!f0ck[0].is_oc
|
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||||
})})`;
|
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
|
||||||
} catch (err) {
|
}
|
||||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MOD APPROVE] Matrix notification error:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast new_item event for live grid updates
|
||||||
|
try {
|
||||||
|
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||||
|
id: id,
|
||||||
|
dest: f0ck[0].dest,
|
||||||
|
mime: f0ck[0].mime,
|
||||||
|
username: f0ck[0].username,
|
||||||
|
tag_id: f0ck[0].tag_id,
|
||||||
|
is_oc: !!f0ck[0].is_oc
|
||||||
|
})})`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,17 +135,34 @@ 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) {
|
||||||
client.send({ type: 'activity', data });
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Activity broadcast error:', e);
|
console.error('Activity broadcast error:', e);
|
||||||
@@ -210,9 +228,13 @@ 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) {
|
||||||
client.send({ type: 'new_item', data });
|
const isOwner = client.username && data.username && client.username.toLowerCase() === data.username.toLowerCase();
|
||||||
|
if (itemVis === 0 || isOwner) {
|
||||||
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 {
|
||||||
`)[0];
|
userOptions = (await db`
|
||||||
|
select avatar_file, banner_file, banner_position, banner_size from user_options where user_id = ${+req.session.id}
|
||||||
|
`)[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`
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
161
src/index.mjs
161
src/index.mjs
@@ -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) {
|
||||||
await updateHallsCache();
|
try {
|
||||||
|
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
|
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||||
? false
|
? data.user_alternative_infobox
|
||||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
: (cfg.websrv.user_alternative_infobox !== false));
|
||||||
? data.user_alternative_infobox
|
|
||||||
: (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
|
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||||
? false
|
? data.user_alternative_steuerung
|
||||||
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
: (cfg.websrv.user_alternative_steuerung !== false));
|
||||||
? data.user_alternative_steuerung
|
|
||||||
: (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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowedMimes.includes(mime)) {
|
|
||||||
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,29 +974,31 @@ 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)
|
||||||
try {
|
if (targetVisibility === 0) {
|
||||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
try {
|
||||||
if (discordClient && discordClient.webhook_url) {
|
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||||
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
|
if (discordClient && discordClient.webhook_url) {
|
||||||
const payload = JSON.stringify({ content: message });
|
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
|
||||||
const url = new URL(discordClient.webhook_url);
|
const payload = JSON.stringify({ content: message });
|
||||||
const options = {
|
const url = new URL(discordClient.webhook_url);
|
||||||
hostname: url.hostname,
|
const options = {
|
||||||
path: url.pathname + url.search,
|
hostname: url.hostname,
|
||||||
method: 'POST',
|
path: url.pathname + url.search,
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Content-Type': 'application/json',
|
headers: {
|
||||||
'Content-Length': Buffer.byteLength(payload)
|
'Content-Type': 'application/json',
|
||||||
}
|
'Content-Length': Buffer.byteLength(payload)
|
||||||
};
|
}
|
||||||
const reqDiscord = https.request(options, (resDiscord) => { });
|
};
|
||||||
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
|
const reqDiscord = https.request(options, (resDiscord) => { });
|
||||||
reqDiscord.write(payload);
|
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
|
||||||
reqDiscord.end();
|
reqDiscord.write(payload);
|
||||||
|
reqDiscord.end();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
|
||||||
}
|
}
|
||||||
} catch (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)
|
||||||
@@ -713,27 +1011,31 @@ 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)
|
||||||
try {
|
if (!manualApproval && targetVisibility === 0) {
|
||||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
try {
|
||||||
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||||
const clients = await Promise.all(self.bot.clients);
|
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||||
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
const clients = await Promise.all(self.bot.clients);
|
||||||
if (matrixWrapper?.client) {
|
const matrixWrapper = clients.find(c => c.type === 'matrix');
|
||||||
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
|
if (matrixWrapper?.client) {
|
||||||
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
|
||||||
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
|
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
|
||||||
|
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[UPLOAD] Matrix notification error:', err);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
console.error('[UPLOAD] Matrix notification error:', err);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Staff Notifications
|
// Staff Notifications
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) <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) <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>
|
|
||||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@elseif(enable_item_title && item.title)
|
|
||||||
<tr>
|
|
||||||
<th>Title</th>
|
|
||||||
<td>{!! item.title !!}</td>
|
|
||||||
</tr>
|
|
||||||
@endif
|
|
||||||
<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;">
|
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
||||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
<form id="visibility-form" onsubmit="return false;">
|
||||||
|
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
||||||
|
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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) <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) <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>
|
|
||||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
@elseif(enable_item_title && item.title)
|
|
||||||
<tr>
|
|
||||||
<th>Title</th>
|
|
||||||
<td>{!! item.title !!}</td>
|
|
||||||
</tr>
|
|
||||||
@endif
|
|
||||||
<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;">
|
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
||||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
<form id="visibility-form" onsubmit="return false;">
|
||||||
|
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
||||||
|
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||||
|
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
||||||
|
<div>
|
||||||
|
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
||||||
|
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
<div class="avatar-settings-wrapper">
|
|
||||||
<div class="avatar-preview-container">
|
|
||||||
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
|
|
||||||
@if(avatar_file)
|
|
||||||
<a href="/user/{!! session.user !!}" target="_blank">
|
|
||||||
<img id="avatar-preview" class="avatar-preview-img" src="/a/{{ avatar_file }}">
|
|
||||||
</a>
|
|
||||||
@elseif(session.avatar && session.avatar > 0)
|
|
||||||
<a href="/user/{!! session.user !!}" target="_blank">
|
|
||||||
<img id="avatar-preview" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp">
|
|
||||||
</a>
|
|
||||||
@else
|
|
||||||
<div id="avatar-preview" class="avatar-preview-img avatar-placeholder">?</div>
|
|
||||||
@endif
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="avatar-upload-section">
|
<p class="text-muted" style="margin-bottom: 16px; font-size: 0.9em; display: flex; align-items: center; gap: 8px;">
|
||||||
<p class="avatar-hint">{{ t('settings.avatar_hint') }}</p>
|
<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.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="avatar-upload-wrapper">
|
<div class="direct-edit-profile-card-wrapper" style="position: relative; max-width: 750px; margin-bottom: 30px;">
|
||||||
<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;">
|
<!-- Interactive User Card -->
|
||||||
<div class="avatar-progress-bar">
|
<div class="user-infobox-block editable-profile-card" id="live-profile-preview-box"
|
||||||
<div class="avatar-progress-fill" id="avatar-progress-fill"></div>
|
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>
|
|
||||||
<span class="avatar-progress-text" id="avatar-progress-text">0%</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="avatar-upload-actions">
|
<!-- Avatar with Hover Overlay -->
|
||||||
<button type="button" id="avatar-upload-btn" class="button" disabled>{{ t('settings.upload_btn') }}</button>
|
<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)
|
||||||
<button type="button" id="avatar-remove-btn" class="button button-danger">{{ t('settings.remove_custom') }}</button>
|
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/{{ avatar_file }}" />
|
||||||
|
@elseif(session.avatar && session.avatar > 0)
|
||||||
|
<img id="live-preview-avatar" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp" />
|
||||||
|
@else
|
||||||
|
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/default.png" style="border-color: #444;" />
|
||||||
@endif
|
@endif
|
||||||
</div>
|
<div class="avatar-hover-overlay">
|
||||||
<div id="avatar-upload-status" class="avatar-status"></div>
|
<i class="fa-solid fa-camera"></i>
|
||||||
</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>
|
</a>
|
||||||
@else
|
@if(avatar_file)
|
||||||
<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>
|
<button type="button" class="avatar-quick-remove" id="avatar-remove-btn" title="Remove custom avatar">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="avatar-upload-section">
|
<div class="user-infobox-info">
|
||||||
<p class="avatar-hint">{{ t('settings.banner_hint') }}</p>
|
<div class="user-infobox-header">
|
||||||
<div class="avatar-upload-wrapper">
|
<div class="user-infobox-username-container" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||||
<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>
|
<!-- Editable Display Name -->
|
||||||
<span id="banner-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
|
<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>
|
||||||
|
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="avatar-progress-wrapper" id="banner-progress-wrapper" style="display: none;">
|
<div class="user-infobox-body">
|
||||||
<div class="avatar-progress-bar">
|
|
||||||
<div class="avatar-progress-fill" id="banner-progress-fill"></div>
|
<!-- 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 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">
|
||||||
|
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
|
||||||
</div>
|
</div>
|
||||||
<span class="avatar-progress-text" id="banner-progress-text">0%</span>
|
|
||||||
</div>
|
</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>
|
||||||
</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;">
|
<!-- Hidden Backing Elements for JavaScript compatibility -->
|
||||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.display_name') }}</legend>
|
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
|
||||||
<div class="setting-item">
|
<span id="avatar-filename" style="display: none;"></span>
|
||||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
<button type="button" id="avatar-upload-btn" style="display: none;"></button>
|
||||||
<input type="text" id="display_name_input" class="input"
|
<div id="avatar-progress-wrapper" style="display: none;">
|
||||||
placeholder="{{ t('settings.display_name_placeholder') }}" value="{!! session.display_name || '' !!}"
|
<div id="avatar-progress-fill"></div>
|
||||||
maxlength="32" style="max-width: 200px; height: 30px; font-size: 0.9em;">
|
<span id="avatar-progress-text"></span>
|
||||||
<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>
|
</div>
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
|
||||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Live Profile Preview</legend>
|
<span id="banner-filename" style="display: none;"></span>
|
||||||
<div class="setting-item" style="max-width: 750px;">
|
<button type="button" id="banner-upload-btn" style="display: none;"></button>
|
||||||
<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 id="banner-progress-wrapper" style="display: none;">
|
||||||
<div class="user-infobox-avatar">
|
<div id="banner-progress-fill"></div>
|
||||||
<a href="#">
|
<span id="banner-progress-text"></span>
|
||||||
@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-header">
|
|
||||||
<div class="user-infobox-username-container">
|
|
||||||
<a id="live-preview-username" href="#" class="user-infobox-username">{!! session.display_name || session.user !!}</a>
|
|
||||||
</div>
|
|
||||||
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
|
|
||||||
</div>
|
|
||||||
<div class="user-infobox-body">
|
|
||||||
<div class="user-infobox-description">
|
|
||||||
{!! session.description || 'Welcome to my profile! This is a live preview.' !!}
|
|
||||||
</div>
|
|
||||||
<div class="user-infobox-actions">
|
|
||||||
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
|
||||||
|
<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>
|
</select>
|
||||||
<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>
|
</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>
|
|
||||||
<br><small class="text-muted">{{ t('settings.language_hint') }}</small>
|
|
||||||
</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>
|
||||||
</select>
|
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
|
||||||
</div>
|
<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>
|
||||||
|
<small class="text-muted" style="display: block; margin-top: 4px;">{{ t('settings.language_hint') }}</small>
|
||||||
|
</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 {
|
||||||
|
|||||||
@@ -1,56 +1,153 @@
|
|||||||
<div id="excluded-tags-overlay" style="display: none;">
|
<div id="excluded-tags-overlay" style="display: none;">
|
||||||
<div id="excluded-tags-close">×</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 class="nav-exclude">
|
<div id="excluded-tags-close" title="Close Filters">×</div>
|
||||||
<input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') }}" autocomplete="off" enterkeyhint="enter">
|
|
||||||
<div id="nav_exclude_suggestions" class="tag-suggestions"></div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- modes -->
|
|
||||||
<div class="mode-filter">
|
<div class="filter-modal-body">
|
||||||
<button id="nav-shuffle-btn" class="shuffle-btn" title="Random Mode — shuffle all items"><span class="shuffle-icon">( - _ - )</span> {{ t('filter.random_mode') }}</button>
|
<!-- Section 1: View Layout -->
|
||||||
<div class="rating-selector" id="rating-selector">
|
<div class="modal-filter-group">
|
||||||
<button class="rating-toggle-btn" data-rating="sfw" id="rating-btn-sfw" title="SFW">SFW</button>
|
<label class="modal-filter-label"><i class="fa-solid fa-table-cells"></i> {{ t('filter.layout') || 'Layout' }}</label>
|
||||||
<button class="rating-toggle-btn" data-rating="nsfw" id="rating-btn-nsfw" title="NSFW">NSFW</button>
|
<div class="filter-btn-group layout-group">
|
||||||
@if(enable_nsfl)
|
<button class="filter-btn layout-btn {{ (!layout || layout == 'grid') ? 'active' : '' }}" data-layout="grid" title="Classic Grid Layout">
|
||||||
<button class="rating-toggle-btn" data-rating="nsfl" id="rating-btn-nsfl" title="NSFL">NSFL</button>
|
<i class="fa-solid fa-border-all"></i> <span class="btn-text">Grid</span>
|
||||||
@endif
|
</button>
|
||||||
@if(session && session.admin || session && session.is_moderator)
|
<button class="filter-btn layout-btn {{ (layout == 'masonry') ? 'active' : '' }}" data-layout="masonry" title="Fluid Masonry Layout">
|
||||||
<button class="rating-toggle-btn" data-rating="untagged" id="rating-btn-untagged" title="Untagged">UNT</button>
|
<i class="fa-solid fa-cubes"></i> <span class="btn-text">Masonry</span>
|
||||||
@endif
|
</button>
|
||||||
<button class="rating-toggle-btn rating-toggle-all" id="rating-btn-all" title="Show All Ratings">ALL</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>
|
</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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session && enable_xd_score)
|
||||||
|
<!-- Section 7: xD Score Filter -->
|
||||||
|
<div class="modal-filter-group nav-xd-filter">
|
||||||
|
<label class="modal-filter-label"><i class="fa-solid fa-gauge-high"></i> {{ t('filter.min_xd_score') }}</label>
|
||||||
|
<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">
|
||||||
|
<div id="nav_xd_slider_container" class="nav-xd-custom-slider"></div>
|
||||||
|
<span id="nav_xd_val" class="xd-slider-val">{{ session.min_xd_score || 0 }}</span>
|
||||||
|
<span id="nav_xd_tier_label" class="xd-score-badge" style="display:none; font-size: 0.75em;"></span>
|
||||||
|
</div>
|
||||||
|
<button id="nav_xd_save_btn" class="mode-btn" style="margin-top: 6px; width: 100%;">{{ t('filter.apply_filter') }}</button>
|
||||||
|
<div id="nav_xd_status" style="font-size: 0.8em; color: #9f0; min-height: 1em; margin-top: 3px;"></div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
<!-- mimes -->
|
|
||||||
@if(show_mime_picker)
|
<!-- Modal Footer Actions -->
|
||||||
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu">
|
<div class="filter-modal-footer">
|
||||||
@if(scroller_mime_cats && scroller_mime_cats.includes('video'))
|
<button class="filter-reset-btn" id="filter-reset-btn" title="Reset All Filters to Default">
|
||||||
<label class="nav-mime-item"><input type="checkbox" value="video" id="mime-video">{{ t('filter.video') }}</label>
|
<i class="fa-solid fa-rotate-left"></i> Reset All
|
||||||
@endif
|
</button>
|
||||||
@if(scroller_mime_cats && scroller_mime_cats.includes('audio'))
|
<button class="filter-apply-btn" id="filter-modal-done-btn">
|
||||||
<label class="nav-mime-item"><input type="checkbox" value="audio" id="mime-audio">{{ t('filter.audio') }}</label>
|
<i class="fa-solid fa-check"></i> Done
|
||||||
@endif
|
</button>
|
||||||
@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)
|
|
||||||
<!-- xD score filter -->
|
|
||||||
<div class="nav-xd-filter">
|
|
||||||
<label class="nav-xd-label">{{ t('filter.min_xd_score') }}</label>
|
|
||||||
<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">
|
|
||||||
<div id="nav_xd_slider_container" class="nav-xd-custom-slider"></div>
|
|
||||||
<span id="nav_xd_val" class="xd-slider-val">{{ session.min_xd_score || 0 }}</span>
|
|
||||||
<span id="nav_xd_tier_label" class="xd-score-badge" style="display:none; font-size: 0.75em;"></span>
|
|
||||||
</div>
|
|
||||||
<button id="nav_xd_save_btn" class="mode-btn" style="margin-top: 6px; width: 100%;">{{ t('filter.apply_filter') }}</button>
|
|
||||||
<div id="nav_xd_status" style="font-size: 0.8em; color: #9f0; min-height: 1em; margin-top: 3px;"></div>
|
|
||||||
</div>
|
</div>
|
||||||
@endif
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
1
views/snippets/filter-bar.html
Normal file
1
views/snippets/filter-bar.html
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<!-- Filter bar has been integrated into excluded-tags-modal.html -->
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
184
views/snippets/info-modal.html
Normal file
184
views/snippets/info-modal.html
Normal 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>
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user