Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8357d011e3 | |||
| 9ccebc6d95 | |||
| 3927085e1f | |||
| d5c294c940 | |||
| eee7c899ab | |||
| 6e8ae2f7cb | |||
| 90ef78bbf3 | |||
| 2eae7f93e5 | |||
| 2758f2a72e | |||
| c0c32ddfe4 | |||
| a6e2689909 | |||
| d1dd088bc6 | |||
| 1bb3dcff21 | |||
| fc6749cd11 | |||
| 4d685dff06 | |||
| b0053a8de8 | |||
| 280020c19f | |||
| 0d3819c113 | |||
| 34d2b62909 | |||
| 18c898c910 | |||
| cbfbcb04bf | |||
| d2ce6f412d | |||
| ec4db7a22b | |||
| c0d70baf83 | |||
| 122f57eacc | |||
| 07de9d4488 |
11
build.bash
11
build.bash
@@ -16,7 +16,7 @@ fi
|
||||
# Parse arguments
|
||||
TARGET="master"
|
||||
CACHE_FLAG=""
|
||||
BRANCH_TARGET=""
|
||||
CUSTOM_BRANCH=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
@@ -27,8 +27,8 @@ for arg in "$@"; do
|
||||
CACHE_FLAG="--no-cache"
|
||||
;;
|
||||
*)
|
||||
if [ -z "$BRANCH_TARGET" ]; then
|
||||
BRANCH_TARGET=$arg
|
||||
if [ -z "$CUSTOM_BRANCH" ]; then
|
||||
CUSTOM_BRANCH=$arg
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -43,7 +43,10 @@ fi
|
||||
# Set Image name and branch defaults
|
||||
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) ---"
|
||||
BRANCH_TARGET="master"
|
||||
elif [ "$TARGET" == "stg" ]; then
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
client_max_body_size 10000M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
|
||||
@@ -29,11 +29,6 @@
|
||||
],
|
||||
"enable_pdf": 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,
|
||||
"allowedMimes": [
|
||||
"audio",
|
||||
@@ -129,7 +124,6 @@
|
||||
"user_alternative_infobox": false,
|
||||
"user_banner_enabled": true,
|
||||
"user_alternative_steuerung": false,
|
||||
"expose_repost_links_to_guests": false,
|
||||
"enable_swf": false,
|
||||
"swf_thumb": "/s/img/swf.png",
|
||||
"enable_archive": true,
|
||||
|
||||
@@ -31,7 +31,6 @@ services:
|
||||
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
|
||||
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
|
||||
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
|
||||
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||
|
||||
environment:
|
||||
@@ -103,17 +102,21 @@ services:
|
||||
f0ckm-nginx:
|
||||
image: nginxproxy/nginx-proxy:latest
|
||||
container_name: f0ckm-nginx
|
||||
network_mode: "host"
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
environment:
|
||||
- ENABLE_IPV6=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- vhost:/etc/nginx/vhost.d:rw
|
||||
- html:/usr/share/nginx/html:rw
|
||||
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: unless-stopped
|
||||
|
||||
acme-companion:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- 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;
|
||||
@@ -1,38 +0,0 @@
|
||||
-- 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;
|
||||
@@ -114,7 +114,6 @@ DROP INDEX IF EXISTS public.idx_items_username;
|
||||
DROP INDEX IF EXISTS public.idx_items_pinned_id;
|
||||
DROP INDEX IF EXISTS public.idx_items_is_purged;
|
||||
DROP INDEX IF EXISTS public.idx_items_is_deleted;
|
||||
DROP INDEX IF EXISTS public.idx_items_slug;
|
||||
DROP INDEX IF EXISTS public.idx_items_active_id;
|
||||
DROP INDEX IF EXISTS public.idx_global_chat_created_at;
|
||||
DROP INDEX IF EXISTS public.idx_discord_queue_sent;
|
||||
@@ -911,18 +910,12 @@ CREATE TABLE public.items (
|
||||
original_filename text,
|
||||
title text,
|
||||
width integer,
|
||||
height integer,
|
||||
visibility smallint DEFAULT 0 NOT NULL,
|
||||
slug character varying(16)
|
||||
height integer
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.items OWNER TO f0ckm;
|
||||
|
||||
COMMENT ON COLUMN public.items.visibility IS '0=public, 1=unlisted, 2=private';
|
||||
COMMENT ON COLUMN public.items.slug IS 'Unique unguessable 11-character URL slug for direct access';
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN items.src; Type: COMMENT; Schema: public; Owner: f0ckm
|
||||
--
|
||||
@@ -1507,23 +1500,12 @@ CREATE TABLE public.user_options (
|
||||
comment_display_mode integer DEFAULT 1,
|
||||
force_comment_display_mode integer DEFAULT 0,
|
||||
favorites_private boolean DEFAULT false,
|
||||
hide_fav_badge boolean DEFAULT false,
|
||||
default_upload_visibility smallint DEFAULT 0,
|
||||
banner_file character varying(255) DEFAULT NULL::character varying,
|
||||
banner_position character varying(50) DEFAULT 'center'::character varying,
|
||||
banner_size character varying(50) DEFAULT 'cover'::character varying
|
||||
hide_fav_badge boolean DEFAULT false
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.user_options OWNER TO f0ckm;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
|
||||
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/';
|
||||
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
|
||||
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner';
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: COLUMN user_options.avatar_file; Type: COMMENT; Schema: public; Owner: f0ckm
|
||||
--
|
||||
@@ -2182,13 +2164,6 @@ CREATE INDEX idx_global_chat_created_at ON public.global_chat USING btree (creat
|
||||
CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_items_slug; Type: INDEX; Schema: public; Owner: f0ckm
|
||||
--
|
||||
|
||||
CREATE UNIQUE INDEX idx_items_slug ON public.items USING btree (slug);
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_items_is_deleted; Type: INDEX; Schema: public; Owner: f0ckm
|
||||
--
|
||||
@@ -2971,9 +2946,4 @@ ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
|
||||
|
||||
-- User Banners
|
||||
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
||||
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
|
||||
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
|
||||
|
||||
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
-- 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"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -492,9 +492,9 @@
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
||||
357
public/s/css/98.css
Normal file
357
public/s/css/98.css
Normal file
@@ -0,0 +1,357 @@
|
||||
git pu/* 98.css framework - Scoped exclusively for f0ck98 theme */
|
||||
|
||||
@font-face {
|
||||
font-family: "Pixelated MS Sans Serif";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url("/s/fonts/ms_sans_serif.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Pixelated MS Sans Serif";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url("/s/fonts/ms_sans_serif_bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] {
|
||||
--w98-silver: #c0c0c0;
|
||||
--w98-teal: #008080;
|
||||
--w98-navy: #000080;
|
||||
--w98-navy-gradient: linear-gradient(90deg, navy, #1084d0);
|
||||
--w98-dark-shadow: #0a0a0a;
|
||||
--w98-light-highlight: #ffffff;
|
||||
--w98-mid-gray: grey;
|
||||
--w98-light-gray: #dfdfdf;
|
||||
|
||||
color: #000;
|
||||
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
background-color: var(--w98-silver) !important;
|
||||
background: var(--w98-silver) !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] body {
|
||||
color: #000;
|
||||
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar,
|
||||
html[theme="f0ck98"] .window,
|
||||
html[theme="f0ck98"] button,
|
||||
html[theme="f0ck98"] input,
|
||||
html[theme="f0ck98"] label,
|
||||
html[theme="f0ck98"] legend,
|
||||
html[theme="f0ck98"] li[role=tab],
|
||||
html[theme="f0ck98"] option,
|
||||
html[theme="f0ck98"] select,
|
||||
html[theme="f0ck98"] table,
|
||||
html[theme="f0ck98"] textarea,
|
||||
html[theme="f0ck98"] ul.tree-view {
|
||||
-webkit-font-smoothing: none;
|
||||
font-family: "Pixelated MS Sans Serif", Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] u {
|
||||
border-bottom: 0.5px solid #222;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Windows 98 Buttons */
|
||||
html[theme="f0ck98"] button,
|
||||
html[theme="f0ck98"] input[type=reset],
|
||||
html[theme="f0ck98"] input[type=submit],
|
||||
html[theme="f0ck98"] .btn {
|
||||
background: silver;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
box-sizing: border-box;
|
||||
color: #000;
|
||||
min-height: 23px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] button.default,
|
||||
html[theme="f0ck98"] input[type=reset].default,
|
||||
html[theme="f0ck98"] input[type=submit].default,
|
||||
html[theme="f0ck98"] .btn.default {
|
||||
box-shadow: inset -2px -2px #0a0a0a, inset 1px 1px #0a0a0a, inset 2px 2px #fff, inset -3px -3px grey, inset 3px 3px #dfdfdf;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .vertical-bar {
|
||||
background: silver;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
height: 20px;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] button:not(:disabled):active,
|
||||
html[theme="f0ck98"] input[type=reset]:not(:disabled):active,
|
||||
html[theme="f0ck98"] input[type=submit]:not(:disabled):active,
|
||||
html[theme="f0ck98"] .btn:not(:disabled):active {
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] button.default:not(:disabled):active,
|
||||
html[theme="f0ck98"] input[type=reset].default:not(:disabled):active,
|
||||
html[theme="f0ck98"] input[type=submit].default:not(:disabled):active {
|
||||
box-shadow: inset 2px 2px #0a0a0a, inset -1px -1px #0a0a0a, inset -2px -2px #fff, inset 3px 3px grey, inset -3px -3px #dfdfdf;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] button:focus,
|
||||
html[theme="f0ck98"] input[type=reset]:focus,
|
||||
html[theme="f0ck98"] input[type=submit]:focus,
|
||||
html[theme="f0ck98"] .btn:focus {
|
||||
outline: 1px dotted #000;
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] :disabled,
|
||||
html[theme="f0ck98"] :disabled+label,
|
||||
html[theme="f0ck98"] input[readonly],
|
||||
html[theme="f0ck98"] input[readonly]+label {
|
||||
color: grey;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] :disabled+label,
|
||||
html[theme="f0ck98"] button:disabled,
|
||||
html[theme="f0ck98"] input[type=reset]:disabled,
|
||||
html[theme="f0ck98"] input[type=submit]:disabled,
|
||||
html[theme="f0ck98"] .btn:disabled {
|
||||
text-shadow: 1px 1px 0 #fff;
|
||||
}
|
||||
|
||||
/* Window Shell & Title Bars */
|
||||
html[theme="f0ck98"] .window {
|
||||
background: silver;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #dfdfdf, inset -2px -2px grey, inset 2px 2px #fff;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar {
|
||||
align-items: center;
|
||||
background: linear-gradient(90deg, navy, #1084d0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 2px 3px 3px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar.inactive {
|
||||
background: linear-gradient(90deg, grey, #b5b5b5);
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-text {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin-right: 24px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-controls {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-controls button {
|
||||
display: block;
|
||||
min-height: 14px;
|
||||
min-width: 16px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-controls button:active {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-controls button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-controls button[aria-label=Minimize],
|
||||
html[theme="f0ck98"] .title-bar-controls button[aria-label].minimize {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='6' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill='%23000' d='M0 0h6v2H0z'/%3E%3C/svg%3E");
|
||||
background-position: bottom 3px left 4px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-controls button[aria-label=Maximize],
|
||||
html[theme="f0ck98"] .title-bar-controls button[aria-label].maximize {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='9' height='9' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M9 0H0v9h9V0zM8 2H1v6h7V2z' fill='%23000'/%3E%3C/svg%3E");
|
||||
background-position: top 2px left 3px;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .title-bar-controls button[aria-label=Close],
|
||||
html[theme="f0ck98"] .title-bar-controls button[aria-label].close {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='8' height='7' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h2v1h1v1h2V1h1V0h2v1H7v1H6v1H5v1h1v1h1v1h1v1H6V6H5V5H3v1H2v1H0V6h1V5h1V4h1V3H2V2H1V1H0V0z' fill='%23000'/%3E%3C/svg%3E");
|
||||
background-position: top 3px left 4px;
|
||||
background-repeat: no-repeat;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* Status Bar */
|
||||
html[theme="f0ck98"] .status-bar {
|
||||
gap: 1px;
|
||||
display: flex;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .status-bar-field {
|
||||
box-shadow: inset -1px -1px #dfdfdf, inset 1px 1px grey;
|
||||
flex-grow: 1;
|
||||
margin: 0;
|
||||
padding: 2px 3px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .window-body {
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] fieldset {
|
||||
border-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='5' height='5' fill='gray' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h5v5H0V2h2v1h1V2H0' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M0 0h4v4H0V1h1v2h2V1H0'/%3E%3C/svg%3E") 2;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
padding-block-start: 8px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] legend {
|
||||
background: silver;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .field-row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] [class^=field-row]+[class^=field-row] {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .field-row>*+* {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] label {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Form Inputs & Textarea */
|
||||
html[theme="f0ck98"] input[type=email],
|
||||
html[theme="f0ck98"] input[type=number],
|
||||
html[theme="f0ck98"] input[type=password],
|
||||
html[theme="f0ck98"] input[type=search],
|
||||
html[theme="f0ck98"] input[type=tel],
|
||||
html[theme="f0ck98"] input[type=text],
|
||||
html[theme="f0ck98"] input[type=url],
|
||||
html[theme="f0ck98"] select,
|
||||
html[theme="f0ck98"] textarea {
|
||||
background-color: #fff;
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a;
|
||||
box-sizing: border-box;
|
||||
padding: 3px 4px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] input[type=email]:disabled,
|
||||
html[theme="f0ck98"] input[type=number]:disabled,
|
||||
html[theme="f0ck98"] input[type=password]:disabled,
|
||||
html[theme="f0ck98"] input[type=search]:disabled,
|
||||
html[theme="f0ck98"] input[type=tel]:disabled,
|
||||
html[theme="f0ck98"] input[type=text]:disabled,
|
||||
html[theme="f0ck98"] input[type=url]:disabled,
|
||||
html[theme="f0ck98"] textarea:disabled {
|
||||
background-color: silver;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E");
|
||||
background-position: top 2px right 2px;
|
||||
background-repeat: no-repeat;
|
||||
border-radius: 0;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] input[type=email]:focus,
|
||||
html[theme="f0ck98"] input[type=number]:focus,
|
||||
html[theme="f0ck98"] input[type=password]:focus,
|
||||
html[theme="f0ck98"] input[type=search]:focus,
|
||||
html[theme="f0ck98"] input[type=tel]:focus,
|
||||
html[theme="f0ck98"] input[type=text]:focus,
|
||||
html[theme="f0ck98"] input[type=url]:focus,
|
||||
html[theme="f0ck98"] select:focus,
|
||||
html[theme="f0ck98"] textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] a {
|
||||
color: #000080;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] a:focus {
|
||||
outline: 1px dotted #000080;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .sunken-panel {
|
||||
background-color: #fff;
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Scoped Scrollbars for f0ck98 */
|
||||
html[theme="f0ck98"] ::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 17px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ::-webkit-scrollbar-corner {
|
||||
background: #dfdfdf;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ::-webkit-scrollbar-track {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='2' height='2' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M1 0H0v1h1v1h1V1H1V0z' fill='silver'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 0H1v1H0v1h1V1h1V0z' fill='%23fff'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ::-webkit-scrollbar-thumb {
|
||||
background-color: #dfdfdf;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ::-webkit-scrollbar-button:vertical:start {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8 6H7v1H6v1H5v1H4v1h7V9h-1V8H9V7H8V6z' fill='%23000'/%3E%3C/svg%3E");
|
||||
height: 17px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ::-webkit-scrollbar-button:vertical:end {
|
||||
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='16' height='17' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 0H0v16h1V1h14V0z' fill='%23DFDFDF'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M2 1H1v14h1V2h12V1H2z' fill='%23fff'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 17H0v-1h15V0h1v17z' fill='%23000'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M15 1h-1v14H1v1h14V1z' fill='gray'/%3E%3Cpath fill='silver' d='M2 2h12v13H2z'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M11 6H4v1h1v1h1v1h1v1h1V9h1V8h1V7h1V6z' fill='%23000'/%3E%3C/svg%3E");
|
||||
height: 17px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .sidebar-activity .comment-content.has-overflow:not(.expanded) .comment-content-inner::after {
|
||||
background: linear-gradient(to bottom, transparent, rgb(192, 192, 192));
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .item-sidebar-right, .index-sidebar-right, .global-sidebar-right {
|
||||
box-shadow: none!important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .sidebar-activity .read-more-btn, .sidebar-activity .see-less-btn {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .modal-content p {
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .drop-zone {
|
||||
border: 2px dashed rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
@@ -4028,11 +4028,338 @@ html[theme="f0ck95d"] .admin-search button {
|
||||
color: black;
|
||||
}
|
||||
|
||||
html[theme="f0ck95d"] .embed-responsive.embed-responsive-16by9 {
|
||||
html[theme="f0ck98"] .embed-responsive.embed-responsive-16by9 {
|
||||
background: black;
|
||||
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
f0ck98 Theme - Windows 98 Authentic Retro
|
||||
========================================== */
|
||||
|
||||
html[theme="f0ck98"] {
|
||||
--accent: #000080;
|
||||
--bg: silver;
|
||||
--black: #000000;
|
||||
--white: #ffffff;
|
||||
--gray: silver;
|
||||
--nav-bg: silver;
|
||||
--nav-brand-border: outset 2px silver;
|
||||
--nav-brand-bg: silver;
|
||||
--navigation-links-bg: silver;
|
||||
--navigation-links-background-linear-gradient: none;
|
||||
--navigation-links-border-color: #808080;
|
||||
--navigation-links-box-shadow: none;
|
||||
--nav-link-background-linear-gradient: none;
|
||||
--nav-link-box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
--nav-link-hover-bg: #000080;
|
||||
--nav-border-color: #808080;
|
||||
--dropdown-bg: silver;
|
||||
--dropdown-item-hover: #000080;
|
||||
--nav-brand-font: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
--font: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
--pagination-background: silver;
|
||||
--pagination-box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
--pagination-anchor-box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
--pagination-background-hover: #000080;
|
||||
--pagination-border-color: #808080;
|
||||
--metadata-bg: silver;
|
||||
--badge-bg: silver;
|
||||
--posts-meta-bg: rgba(192, 192, 192, 0.95);
|
||||
--badge-sfw: #008080;
|
||||
--badge-nsfw: #a72828;
|
||||
--badge-nsfl: #660000;
|
||||
--badge-tag: #808080;
|
||||
--scrollbar-color: silver;
|
||||
--scroller-bg: silver;
|
||||
--footbar-color: #000000;
|
||||
--loading-indicator-color: #000000;
|
||||
--img-border-width: 0;
|
||||
--img-border-color: #808080;
|
||||
--bg-gradient: none;
|
||||
--motd-bg: silver;
|
||||
--comment-bg: #ffffff;
|
||||
background-color: silver !important;
|
||||
background: silver !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] body {
|
||||
color: #000000;
|
||||
font-family: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] a {
|
||||
color: #000080;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] a:hover {
|
||||
color: #1084d0;
|
||||
}
|
||||
|
||||
/* Header & Titlebar */
|
||||
html[theme="f0ck98"] ._204863 {
|
||||
background: linear-gradient(90deg, #000080, #1084d0);
|
||||
color: #ffffff;
|
||||
width: 100%;
|
||||
border: 2px outset silver;
|
||||
line-height: 1.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 2px 4px;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ._204863 .location {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ._204863 .location::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-image: var(--favicon-url, url('/s/img/favicon.gif'));
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] ._204863::after {
|
||||
content: " _ ❐ ✕ ";
|
||||
font-family: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: silver;
|
||||
color: #000000;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
padding: 0 4px;
|
||||
height: 14px;
|
||||
line-height: 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
letter-spacing: 1px;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] #tags .badge>a:first-child {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .item_title {
|
||||
background: silver;
|
||||
color: #000000;
|
||||
border-left: 2px outset silver;
|
||||
border-right: 2px outset silver;
|
||||
box-shadow: inset -1px -1px #dfdfdf, inset 1px 1px grey;
|
||||
font-family: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: normal;
|
||||
padding: 3px 8px;
|
||||
-webkit-font-smoothing: none;
|
||||
}
|
||||
|
||||
/* Navbar */
|
||||
html[theme="f0ck98"] .nav,
|
||||
html[theme="f0ck98"] .navbar {
|
||||
background: silver !important;
|
||||
border-bottom: 2px outset #fff;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #dfdfdf, inset -2px -2px grey, inset 2px 2px #fff;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .nav-link {
|
||||
color: #000000 !important;
|
||||
background: silver;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .nav-link:hover,
|
||||
html[theme="f0ck98"] .nav-link.active {
|
||||
background: #000080 !important;
|
||||
color: #ffffff !important;
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
|
||||
}
|
||||
|
||||
/* Sidebars & Window Containers */
|
||||
html[theme="f0ck98"] .global-sidebar-left,
|
||||
html[theme="f0ck98"] .global-sidebar-right,
|
||||
html[theme="f0ck98"] .about,
|
||||
html[theme="f0ck98"] .modal-content,
|
||||
html[theme="f0ck98"] .card {
|
||||
background: silver;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #dfdfdf, inset -2px -2px grey, inset 2px 2px #fff;
|
||||
color: #000000;
|
||||
padding: 6px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
/* Item Grid Tiles */
|
||||
html[theme="f0ck98"] div.posts > a:not(.notif-item) {
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
background-color: silver;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] div.posts > a:not(.notif-item):hover,
|
||||
html[theme="f0ck98"] div.posts > a:not(.notif-item):focus-visible {
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] div.posts > a:not(.notif-item)::after {
|
||||
color: #000000;
|
||||
background: silver;
|
||||
border-top: 1px solid grey;
|
||||
font-family: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Comments Section */
|
||||
html[theme="f0ck98"] .comment-item,
|
||||
html[theme="f0ck98"] .comments-container {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px grey, inset -2px -2px #dfdfdf, inset 2px 2px #0a0a0a;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .comment-author {
|
||||
color: #000080 !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .comment-content {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .comment-children {
|
||||
border-left: 1px dotted grey;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .comment-actions button,
|
||||
html[theme="f0ck98"] .report-comment-btn,
|
||||
html[theme="f0ck98"] .reply-btn,
|
||||
html[theme="f0ck98"] .quote-btn {
|
||||
color: #000000 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .comment-actions button svg,
|
||||
html[theme="f0ck98"] .report-comment-btn svg,
|
||||
html[theme="f0ck98"] .reply-btn i,
|
||||
html[theme="f0ck98"] .quote-btn i,
|
||||
html[theme="f0ck98"] .report-comment-btn i {
|
||||
color: #000000 !important;
|
||||
fill: #000000 !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .report-comment-btn:hover,
|
||||
html[theme="f0ck98"] .report-comment-btn:hover svg,
|
||||
html[theme="f0ck98"] .report-comment-btn:hover i {
|
||||
color: #a72828 !important;
|
||||
fill: #a72828 !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .reply-btn:hover,
|
||||
html[theme="f0ck98"] .quote-btn:hover,
|
||||
html[theme="f0ck98"] .reply-btn:hover i,
|
||||
html[theme="f0ck98"] .quote-btn:hover i {
|
||||
color: #000080 !important;
|
||||
}
|
||||
|
||||
/* Navigation Controls (.steuerung) */
|
||||
html[theme="f0ck98"] .steuerung a,
|
||||
html[theme="f0ck98"] .steuerung button,
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon a,
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon button {
|
||||
background: silver !important;
|
||||
color: #000000 !important;
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf !important;
|
||||
box-sizing: border-box !important;
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
min-height: 23px !important;
|
||||
min-width: 28px !important;
|
||||
width: auto !important;
|
||||
padding: 2px 8px !important;
|
||||
text-decoration: none !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .steuerung a:active,
|
||||
html[theme="f0ck98"] .steuerung button:active,
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon a:active,
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon button:active {
|
||||
box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf, inset 2px 2px grey !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .steuerung a:hover,
|
||||
html[theme="f0ck98"] .steuerung button:hover,
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon a:hover,
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon button:hover {
|
||||
color: #000080 !important;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon .fa-solid {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon a:hover .fa-solid,
|
||||
html[theme="f0ck98"] .steuerung.steuerung-icon button:hover .fa-solid {
|
||||
color: #000080;
|
||||
}
|
||||
|
||||
/* Badges & Tags */
|
||||
html[theme="f0ck98"] .badge,
|
||||
html[theme="f0ck98"] .badge-light,
|
||||
html[theme="f0ck98"] .badge-dark,
|
||||
html[theme="f0ck98"] .badge-success,
|
||||
html[theme="f0ck98"] .badge-danger {
|
||||
background: silver;
|
||||
color: #000000;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey, inset 2px 2px #dfdfdf;
|
||||
border-radius: 0;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
/* Dropdown Menu */
|
||||
html[theme="f0ck98"] .dropdown-menu {
|
||||
background: silver;
|
||||
box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #dfdfdf, inset -2px -2px grey, inset 2px 2px #fff;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .dropdown-item {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
html[theme="f0ck98"] .dropdown-item:hover {
|
||||
background: #000080;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Footer / Status Bar */
|
||||
html[theme="f0ck98"] footer,
|
||||
html[theme="f0ck98"] .footbar {
|
||||
background: silver;
|
||||
color: #000000;
|
||||
box-shadow: inset -1px -1px #dfdfdf, inset 1px 1px grey;
|
||||
padding: 3px 6px;
|
||||
font-family: 'Pixelated MS Sans Serif', Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* fullscreen */
|
||||
html[res="fullscreen"] ._204863 {
|
||||
border-top: none;
|
||||
|
||||
@@ -362,8 +362,6 @@
|
||||
}
|
||||
|
||||
.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-tag-section {
|
||||
display: none !important;
|
||||
@@ -421,26 +419,6 @@
|
||||
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 {
|
||||
@@ -684,32 +662,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
|
||||
background: rgba(81, 207, 102, 0.2);
|
||||
border-color: #51cf66;
|
||||
color: #51cf66;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
|
||||
background: rgba(255, 187, 51, 0.2);
|
||||
border-color: #ffbb33;
|
||||
color: #ffbb33;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
|
||||
background: rgba(255, 68, 68, 0.2);
|
||||
border-color: #ff4444;
|
||||
color: #ff4444;
|
||||
box-shadow: none;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
|
||||
/* Tags */
|
||||
.tag-input-container {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
BIN
public/s/fonts/ms_sans_serif.woff2
Normal file
BIN
public/s/fonts/ms_sans_serif.woff2
Normal file
Binary file not shown.
BIN
public/s/fonts/ms_sans_serif_bold.woff2
Normal file
BIN
public/s/fonts/ms_sans_serif_bold.woff2
Normal file
Binary file not shown.
@@ -1,24 +1,18 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context
|
||||
const getContext = () => {
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
const favoEl = document.querySelector("#a_favo");
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const usernameEl = document.querySelector("a#a_username");
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
postid: +idLink.innerText,
|
||||
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
|
||||
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
|
||||
poster: (usernameEl?.dataset?.username || '').trim() || null,
|
||||
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1140,7 +1140,6 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
quoteComment(id, openerEl, body) {
|
||||
if (!window.f0ckSession || !window.f0ckSession.logged_in) return;
|
||||
// If no reply input open anywhere, open the local one
|
||||
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
||||
let isNew = false;
|
||||
@@ -2406,50 +2405,10 @@ class CommentSystem {
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
// Global click listener for comment interaction (popups & expanding truncated comments in previews)
|
||||
// Global click listener to close popups (useful for mobile dismissal)
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target;
|
||||
|
||||
// Load full comment (expand truncated)
|
||||
const loadFullBtn = target.closest('.load-full-comment-btn');
|
||||
if (loadFullBtn) {
|
||||
const contentEl = loadFullBtn.closest('.comment-content');
|
||||
if (contentEl) {
|
||||
if (contentEl.querySelector('.collapse-comment-btn')) return;
|
||||
const commentEl = contentEl.closest('.comment');
|
||||
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
|
||||
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||
const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less';
|
||||
contentEl.insertAdjacentHTML('beforeend',
|
||||
`<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');
|
||||
const isLink = e.target.closest('.comment-context-link');
|
||||
const isPopup = e.target.closest('.comment-preview-popup');
|
||||
|
||||
if (!isLink && !isPopup) {
|
||||
this.closePreviewsAboveLevel(-1);
|
||||
@@ -2864,10 +2823,7 @@ class CommentSystem {
|
||||
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);
|
||||
const fullContent = contentEl.dataset.raw;
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
|
||||
// Append "see less" button after full content
|
||||
@@ -2875,7 +2831,6 @@ class CommentSystem {
|
||||
contentEl.insertAdjacentHTML('beforeend',
|
||||
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
|
||||
);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -2886,13 +2841,9 @@ class CommentSystem {
|
||||
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);
|
||||
const fullContent = contentEl.dataset.raw;
|
||||
if (fullContent) {
|
||||
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
|
||||
CommentSystem.playEmojiVideos(contentEl);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -3213,9 +3164,6 @@ class CommentSystem {
|
||||
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
|
||||
const el = target.closest('.comment-permalink, .comment-time');
|
||||
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 commentEl = el.closest('[id^="c"]');
|
||||
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
|
||||
|
||||
@@ -965,7 +965,7 @@ window.cancelAnimFrame = (function () {
|
||||
'login-modal', 'register-modal', 'forgot-modal', 'reset-modal',
|
||||
'report-modal', 'halls-modal', 'metadata-modal', 'warning-modal',
|
||||
'shortcuts-modal', 'upload-drag-modal', 'excluded-tags-overlay',
|
||||
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal', 'visibility-modal'
|
||||
'content-warning-modal', 'gchat-img-modal', 'image-modal', 'info-modal'
|
||||
];
|
||||
modalIds.forEach(id => {
|
||||
// Don't close the filter modal during a background mime-filter reload
|
||||
@@ -2442,8 +2442,8 @@ window.cancelAnimFrame = (function () {
|
||||
const isUpload = pathname.match(/\/upload\/?(?:$|\?)/);
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const isItem = !pathname.match(/\/p\//) && (
|
||||
pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||
pathname.match(/^\/\d+/) ||
|
||||
(parts.length >= 3 && (parts[0] === 'tag' || parts[0] === 'user' || parts[0] === 'h') && /^\d+$/.test(parts[parts.length - 1]))
|
||||
);
|
||||
const isMessages = !!pathname.match(/^\/messages(\/|$)/);
|
||||
const isAbyss = !!pathname.match(/^\/abyss(\/|$|\?|#)/) || pathname === '/abyss';
|
||||
@@ -3356,19 +3356,19 @@ window.cancelAnimFrame = (function () {
|
||||
// Extract item ID from URL. Use the last numeric segment to avoid matching context IDs (like tag/1/...)
|
||||
// Split path, filter numeric, pop last.
|
||||
const pathSegments = new URL(url, window.location.origin).pathname.split('/');
|
||||
const keySegments = pathSegments.filter(s => /^\d+$/.test(s) || /^[a-zA-Z0-9_-]{11}$/.test(s));
|
||||
const numericSegments = pathSegments.filter(s => /^\d+$/.test(s));
|
||||
|
||||
// Clear and Hide navbar pagination for Item View (Never show grid pagination on item view)
|
||||
// document.querySelectorAll('.pagination-wrapper').forEach(el => el.innerHTML = ''); // Don't destroy content (cached grid needs it)
|
||||
document.querySelectorAll('.pagination-container-fluid').forEach(el => el.style.display = 'none');
|
||||
|
||||
if (keySegments.length === 0) {
|
||||
console.warn("loadItemAjax: No ID or slug match found in URL", url);
|
||||
if (numericSegments.length === 0) {
|
||||
console.warn("loadItemAjax: No ID match found in URL", url);
|
||||
// fallback for weird/external links
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
const itemid = keySegments.pop();
|
||||
const itemid = numericSegments.pop();
|
||||
|
||||
// Extract context from Target URL first
|
||||
let tag = null, user = null, isFavs = false, mime = null, hall = null, userHall = null, userHallOwner = null, tagger = null;
|
||||
@@ -3551,19 +3551,18 @@ window.cancelAnimFrame = (function () {
|
||||
}
|
||||
|
||||
const _hash = new URL(url, window.location.origin).hash;
|
||||
const itemKey = _cachedItem.slug || itemid;
|
||||
let _pushUrl = `/${itemKey}`;
|
||||
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
|
||||
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`; }
|
||||
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemKey}`;
|
||||
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemKey}`;
|
||||
let _pushUrl = `/${itemid}`;
|
||||
if (userHall && userHallOwner) _pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
||||
else if (user) { _pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`; if (isFavs) _pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`; }
|
||||
else if (tag) _pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g,',').replace(/%20/g,' ')}/${itemid}`;
|
||||
else if (hall) _pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g,' ')}/${itemid}`;
|
||||
if (tagger && tag) _pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
||||
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
|
||||
if (mime) _pushUrl = _pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
||||
if (_hash) _pushUrl += _hash;
|
||||
|
||||
if (!options.keepMedia && !options.skipPush) history.pushState({}, '', _pushUrl);
|
||||
|
||||
document.title = `${window.f0ckDomain} - ${itemKey}`;
|
||||
document.title = `${window.f0ckDomain} - ${itemid}`;
|
||||
if (navbar) navbar.classList.remove('pbwork');
|
||||
|
||||
if (!options.keepMedia) {
|
||||
@@ -3588,14 +3587,14 @@ window.cancelAnimFrame = (function () {
|
||||
.then(r => r.ok ? r.text() : null)
|
||||
.then(freshText => {
|
||||
if (!freshText) return;
|
||||
try { JSON.parse(freshText); } catch(_) {}
|
||||
// Re-parse to get html string
|
||||
let freshHtml = freshText;
|
||||
let freshSlug = null;
|
||||
try {
|
||||
const d = JSON.parse(freshText);
|
||||
if (d && typeof d.html === 'string') freshHtml = d.html;
|
||||
if (d && (d.slug || d.item?.slug)) freshSlug = d.slug || d.item?.slug;
|
||||
} catch(_) {}
|
||||
itemCacheMap.set(_itemCacheKey, { html: freshHtml, slug: freshSlug, ts: Date.now() });
|
||||
itemCacheMap.set(_itemCacheKey, { html: freshHtml, ts: Date.now() });
|
||||
window.f0ckDebug('[itemCache] Background revalidation complete for', _itemCacheKey);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -3620,7 +3619,7 @@ window.cancelAnimFrame = (function () {
|
||||
- Total Network: ${(tBody - tStart).toFixed(2)}ms
|
||||
- Content Size: ${(rawText.length / 1024).toFixed(2)} KB`);
|
||||
|
||||
let html, paginationHtml, responseSlug = null;
|
||||
let html, paginationHtml;
|
||||
|
||||
try {
|
||||
// Optimistically try to parse as JSON first
|
||||
@@ -3633,7 +3632,6 @@ window.cancelAnimFrame = (function () {
|
||||
if (data && typeof data.html === 'string') {
|
||||
html = data.html;
|
||||
paginationHtml = data.pagination;
|
||||
responseSlug = data.slug || data.item?.slug || null;
|
||||
} else {
|
||||
html = rawText;
|
||||
}
|
||||
@@ -3644,7 +3642,7 @@ window.cancelAnimFrame = (function () {
|
||||
|
||||
// ── Store in item cache (stale-while-revalidate) ───────────────────────
|
||||
if (html && !options.skipCache) {
|
||||
itemCacheMap.set(_itemCacheKey, { html, slug: responseSlug, ts: Date.now() });
|
||||
itemCacheMap.set(_itemCacheKey, { html, ts: Date.now() });
|
||||
if (itemCacheMap.size > ITEM_CACHE_MAX) {
|
||||
// Evict oldest entry
|
||||
itemCacheMap.delete(itemCacheMap.keys().next().value);
|
||||
@@ -3751,25 +3749,26 @@ window.cancelAnimFrame = (function () {
|
||||
// Construct proper History URL (Context Aware)
|
||||
// If we inherited context, we should reflect it in the URL
|
||||
const hash = new URL(url, window.location.origin).hash;
|
||||
const itemKey = responseSlug || itemid;
|
||||
let pushUrl = `/${itemKey}`;
|
||||
let pushUrl = `/${itemid}`;
|
||||
// Logic from ajax.mjs context reconstruction:
|
||||
if (userHall && userHallOwner) {
|
||||
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemKey}`;
|
||||
pushUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
||||
} else if (user) {
|
||||
pushUrl = `/user/${encodeURIComponent(user)}/${itemKey}`;
|
||||
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemKey}`;
|
||||
pushUrl = `/user/${encodeURIComponent(user)}/${itemid}`;
|
||||
if (isFavs) pushUrl = `/user/${encodeURIComponent(user)}/favs/${itemid}`;
|
||||
}
|
||||
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemKey}`;
|
||||
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemKey}`;
|
||||
else if (tag) pushUrl = `/tag/${encodeURIComponent(tag).replace(/%2C/g, ',').replace(/%20/g, ' ')}/${itemid}`;
|
||||
else if (hall) pushUrl = `/h/${encodeURIComponent(hall).replace(/%20/g, ' ')}/${itemid}`;
|
||||
// Append tagger filter so item nav stays in tagger context
|
||||
if (tagger && tag) pushUrl += `?tagger=${encodeURIComponent(tagger)}`;
|
||||
|
||||
if (mime) {
|
||||
// If it already has itemKey at the end, insert mime before it
|
||||
pushUrl = pushUrl.replace(new RegExp(`/${itemKey}$`), `/${mime}/${itemKey}`);
|
||||
// If it already has itemid at the end, insert mime before it
|
||||
pushUrl = pushUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Re-append hash if present
|
||||
if (hash) pushUrl += hash;
|
||||
|
||||
@@ -3789,7 +3788,8 @@ window.cancelAnimFrame = (function () {
|
||||
if (window.initVisualizer) window.initVisualizer();
|
||||
}
|
||||
// Try to extract ID from response if possible or just use itemid
|
||||
document.title = `${window.f0ckDomain} - ${itemKey}`;
|
||||
document.title = `${window.f0ckDomain} - ${itemid}`;
|
||||
if (navbar) navbar.classList.remove("pbwork");
|
||||
window.f0ckDebug("AJAX load complete");
|
||||
|
||||
// Notify extensions — also triggers CommentSystem init which renders comments
|
||||
@@ -3821,7 +3821,6 @@ window.cancelAnimFrame = (function () {
|
||||
console.error("AJAX load failed:", err);
|
||||
} finally {
|
||||
isNavigating = false;
|
||||
if (navbar) navbar.classList.remove("pbwork");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4000,16 +3999,15 @@ window.cancelAnimFrame = (function () {
|
||||
fetch(randomUrl)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.success && data.items && (data.items.slug || data.items.id)) {
|
||||
const targetKey = data.items.slug || data.items.id;
|
||||
if (data.success && data.items && data.items.id) {
|
||||
// Navigate in the same context (user hall, favs, tag, etc.)
|
||||
if (wUserHall && wUserHallOwner) {
|
||||
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${targetKey}`, true);
|
||||
loadItemAjax(`/user/${encodeURIComponent(wUserHallOwner)}/hall/${encodeURIComponent(wUserHall)}/${data.items.id}`, true);
|
||||
} else if (wFavsUser) {
|
||||
// Preserve /user/:name/favs/:id context so next/prev arrows stay within favs
|
||||
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${targetKey}`, true);
|
||||
loadItemAjax(`/user/${encodeURIComponent(wFavsUser)}/favs/${data.items.id}`, true);
|
||||
} else {
|
||||
loadItemAjax(`/${targetKey}`, true);
|
||||
loadItemAjax(`/${data.items.id}`, true);
|
||||
}
|
||||
} else {
|
||||
window.location.href = link.href;
|
||||
@@ -4081,7 +4079,7 @@ window.cancelAnimFrame = (function () {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
|
||||
if (!isSpecialLink && (pathname === '/' || pathname.startsWith('/halls') || pathname.startsWith('/h/') || pathname.startsWith('/notifications') || pathname.startsWith('/tag') || pathname.startsWith('/user/') || pathname.match(/^\/(image|video|audio)/) || pathname.match(/\/p\/\d+/) || pathname.match(/^\/\d+/) || pathname.match(/^\/(about|rules|terms|upload|subscriptions|stats|docs|settings|admin|mod|ranking|messages|meme|memes)/) || pathname.startsWith('/abyss'))) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
|
||||
@@ -4094,10 +4092,9 @@ window.cancelAnimFrame = (function () {
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const isItemLink = !pathname.match(/\/p\//) && (
|
||||
pathname.match(/^\/\d+/) ||
|
||||
pathname.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
|
||||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1])) ||
|
||||
(parts.length >= 3 && parts[0] === 'h' && /^\d+$/.test(parts[parts.length - 1]))
|
||||
);
|
||||
if (isItemLink) {
|
||||
// Links inside comment bodies or MOTD should not inherit tag/hall/user context
|
||||
@@ -4165,25 +4162,6 @@ window.cancelAnimFrame = (function () {
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
} else if (target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn')) {
|
||||
e.preventDefault();
|
||||
const visTrigger = target.closest('#a_visibility') || target.closest('#info-visibility-edit-btn');
|
||||
const id = visTrigger.dataset.itemId || document.getElementById('visibility-item-id')?.value;
|
||||
const visBtnMain = document.getElementById('a_visibility');
|
||||
const rawVis = visTrigger.dataset.visibility ?? visBtnMain?.dataset.visibility ?? '0';
|
||||
const currentVis = parseInt(rawVis, 10);
|
||||
const modal = document.getElementById('visibility-modal');
|
||||
if (modal) {
|
||||
const inputId = document.getElementById('visibility-item-id');
|
||||
if (inputId && id) inputId.value = id;
|
||||
const radios = modal.querySelectorAll('input[name="visibility"]');
|
||||
radios.forEach(r => {
|
||||
r.checked = (parseInt(r.value, 10) === currentVis);
|
||||
});
|
||||
modal.style.display = 'flex';
|
||||
document.body.classList.add('modal-open');
|
||||
}
|
||||
|
||||
} else if (target.closest('#a_rethumb')) {
|
||||
e.preventDefault();
|
||||
const reBtn = target.closest('#a_rethumb');
|
||||
@@ -4552,10 +4530,8 @@ window.cancelAnimFrame = (function () {
|
||||
const parts = p.split('/').filter(Boolean);
|
||||
const isItem = !p.match(/\/p\//) && (
|
||||
p.match(/^\/\d+/) ||
|
||||
p.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) ||
|
||||
(parts.length >= 3 && parts[0] === 'tag' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||
(parts.length >= 3 && parts[0] === 'user' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1]))) ||
|
||||
(parts.length >= 3 && parts[0] === 'h' && (/^\d+$/.test(parts[parts.length - 1]) || /^[a-zA-Z0-9_-]{11}$/.test(parts[parts.length - 1])))
|
||||
(parts.length >= 3 && parts[0] === 'tag' && /^\d+$/.test(parts[parts.length - 1])) ||
|
||||
(parts.length >= 3 && parts[0] === 'user' && /^\d+$/.test(parts[parts.length - 1]))
|
||||
);
|
||||
const isSpecial = p.startsWith('/notifications') || p.startsWith('/tags') || p.startsWith('/user/') || p.startsWith('/subscriptions') || p.startsWith('/ranking');
|
||||
const isGridLike = url.match(/\/p\/\d+/) || url.match(/[?&]page=\d+/) || p === '/';
|
||||
@@ -8127,7 +8103,7 @@ class NotificationSystem {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.loadItemAjax === 'function' && (href.match(/^\/\d+/) || href.match(/^\/[a-zA-Z0-9_-]{11}(?:[?#]|$)/) || href.match(/\/(?:user|tag|h)\/.*?\/(?:\d+|[a-zA-Z0-9_-]{11})/))) {
|
||||
if (typeof window.loadItemAjax === 'function' && href.match(/^\/\d+/)) {
|
||||
window.loadItemAjax(href, false);
|
||||
} else if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(href, true);
|
||||
@@ -8413,8 +8389,7 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
renderHistoryItem(n) {
|
||||
const itemKey = n.item_slug || n.slug || n.item_id;
|
||||
let link = `/${itemKey}`;
|
||||
let link = `/${n.item_id}`;
|
||||
let msg = '';
|
||||
let user = n.from_display_name || n.from_user || 'System';
|
||||
|
||||
@@ -8423,14 +8398,14 @@ class NotificationSystem {
|
||||
const isDeleted = n.type === 'item_deleted';
|
||||
const label = isDeleted ? (i18n.notif_upload_deleted || 'A moderator deleted your upload') : (i18n.notif_upload_denied || 'Your Upload was denied');
|
||||
const userLabel = isDeleted ? (i18n.notif_moderation || 'Moderation') : (i18n.notif_system || 'System');
|
||||
const itemLink = `/${itemKey}`;
|
||||
const itemLink = `/${n.item_id}`;
|
||||
return `
|
||||
<a href="${itemLink}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/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 class="notif-content">
|
||||
<div class="notif-user"><strong>${userLabel}</strong></div>
|
||||
<div class="notif-msg">
|
||||
<strong>${label} #${itemKey}</strong>
|
||||
<strong>${label} #${n.item_id}</strong>
|
||||
<div class="notif-reason">${i18n.notif_reason_label || 'Reason:'} ${n.reason || (i18n.notif_no_reason || 'No reason provided')}</div>
|
||||
</div>
|
||||
<div class="notif-time">${new Date(n.created_at).toLocaleString()}</div>
|
||||
@@ -8450,7 +8425,7 @@ class NotificationSystem {
|
||||
if (n.data?.msg) msg += ` <br><span style="color: #ff6060; font-size: 0.9em;">${n.data.msg}</span>`;
|
||||
if (n.data?.url) msg += ` <br><small style="opacity: 0.6; word-break: break-all;">${n.data.url}</small>`;
|
||||
user = (window.f0ckI18n && window.f0ckI18n.notif_system) || 'System';
|
||||
link = n.item_id ? `/${itemKey}` : '#';
|
||||
link = n.item_id ? `/${n.item_id}` : '#';
|
||||
} else if (n.type === 'admin_pending') {
|
||||
link = '/mod/approve';
|
||||
user = (window.f0ckI18n && window.f0ckI18n.notif_admin) || 'Admin';
|
||||
@@ -8466,7 +8441,7 @@ class NotificationSystem {
|
||||
if (n.reason) msg += `<br><div class="notif-reason" style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${n.reason}</div>`;
|
||||
} else {
|
||||
// Comment notification
|
||||
link = `/${itemKey}#c${n.comment_id || n.reference_id}`;
|
||||
link = `/${n.item_id}#c${n.comment_id || n.reference_id}`;
|
||||
if (n.type === 'comment_reply') msg = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
|
||||
else if (n.type === 'subscription') msg = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
|
||||
else if (n.type === 'mention') msg = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
|
||||
@@ -8507,9 +8482,8 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
renderItem(n) {
|
||||
const itemKey = n.item_slug || n.slug || n.item_id;
|
||||
if (n.type === 'approve') {
|
||||
const link = `/${itemKey}`;
|
||||
const link = `/${n.item_id}`;
|
||||
return `
|
||||
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img 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>
|
||||
@@ -8524,7 +8498,7 @@ class NotificationSystem {
|
||||
}
|
||||
|
||||
if (n.type === 'upload_success') {
|
||||
const link = `/${itemKey}`;
|
||||
const link = `/${n.item_id}`;
|
||||
return `
|
||||
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none';"></div>
|
||||
@@ -8543,7 +8517,7 @@ class NotificationSystem {
|
||||
const errMsg = n.data?.msg || '';
|
||||
const errDisplay = errMsg ? `<div style="color: #ffbaba; font-size: 0.85em; margin-top: 2px;">${errMsg}</div>` : '';
|
||||
const urlDisplay = url ? `<div style="font-size: 0.8em; opacity: 0.7; margin-top: 4px; word-break: break-all; max-height: 3.2em; overflow: hidden;">${url}</div>` : '';
|
||||
const link = n.item_id ? `/${itemKey}` : '#';
|
||||
const link = n.item_id ? `/${n.item_id}` : '#';
|
||||
return `
|
||||
<a href="${link}" class="notif-item ${n.is_read ? '' : 'unread'}" data-id="${n.id}">
|
||||
<div class="notif-content">
|
||||
@@ -8567,7 +8541,7 @@ class NotificationSystem {
|
||||
<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/mod/deleted/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 class="notif-content">
|
||||
<div>
|
||||
<strong>${label} #${itemKey}</strong>
|
||||
<strong>${label} #${n.item_id}</strong>
|
||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 3px;">${(window.f0ckI18n && window.f0ckI18n.notif_click_reason) || 'Click to see reason'}</div>
|
||||
</div>
|
||||
<small class="notif-time">${new Date(n.created_at).toLocaleString()}</small>
|
||||
@@ -8626,10 +8600,10 @@ class NotificationSystem {
|
||||
if (n.type === 'comment_reply') typeText = (window.f0ckI18n && window.f0ckI18n.notif_replied) || 'replied to you';
|
||||
else if (n.type === 'subscription') typeText = (window.f0ckI18n && window.f0ckI18n.notif_subscribed) || 'commented in a thread you follow';
|
||||
else if (n.type === 'mention') typeText = (window.f0ckI18n && window.f0ckI18n.notif_mentioned) || 'highlighted you';
|
||||
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${itemKey}`;
|
||||
else if (n.type === 'upload_comment') typeText = `${(window.f0ckI18n && window.f0ckI18n.notif_commented) || 'commented on your upload'} #${n.item_id}`;
|
||||
|
||||
const cid = n.comment_id || n.reference_id;
|
||||
const link = `/${itemKey}#c${cid}`;
|
||||
const link = `/${n.item_id}#c${cid}`;
|
||||
|
||||
const thumb = n.item_id ? `<div class="notif-thumb"${n.item_mode ? ` data-mode="${n.item_mode}"` : ''}><img src="/t/${n.item_id}.webp" alt="thumb" onerror="this.style.display='none'"></div>` : '';
|
||||
return `
|
||||
@@ -8794,11 +8768,10 @@ class NotificationSystem {
|
||||
// Build DOM nodes imperatively (avoids Sanitizer stripping inline border-color)
|
||||
const fragment = document.createDocumentFragment();
|
||||
data.favs.forEach(fav => {
|
||||
const isSelf = window.f0ckSession && window.f0ckSession.user && (window.f0ckSession.user === fav.user);
|
||||
if (fav.hide_fav_badge && !isSelf) {
|
||||
if (fav.hide_fav_badge) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'ghost-fav';
|
||||
a.setAttribute('tooltip', '?');
|
||||
a.setAttribute('tooltip', 'Ghost Fav');
|
||||
a.setAttribute('flow', 'up');
|
||||
a.style.cursor = 'default';
|
||||
|
||||
@@ -8911,14 +8884,12 @@ class NotificationSystem {
|
||||
|
||||
if (!isOwnUpload) return; // Silently drop as before for other users' uploads
|
||||
|
||||
const itemKey = data.slug || data.id;
|
||||
|
||||
// Don't add duplicates
|
||||
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
||||
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
||||
|
||||
// Determine the link prefix from existing items
|
||||
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
|
||||
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
|
||||
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
|
||||
|
||||
// Build filter-reason label
|
||||
const i18n = window.f0ckI18n || {};
|
||||
@@ -8933,7 +8904,7 @@ class NotificationSystem {
|
||||
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
|
||||
|
||||
const ghost = document.createElement('a');
|
||||
ghost.href = `${linkBase}${itemKey}`;
|
||||
ghost.href = `${linkBase}${data.id}`;
|
||||
ghost.className = 'thumb lazy-thumb filtered-upload-ghost loaded';
|
||||
ghost.dataset.file = data.dest;
|
||||
ghost.dataset.mime = data.mime;
|
||||
@@ -8964,21 +8935,19 @@ class NotificationSystem {
|
||||
}
|
||||
}
|
||||
|
||||
const itemKey = data.slug || data.id;
|
||||
|
||||
// Don't add duplicates
|
||||
if (grid.querySelector(`a[href$="/${itemKey}"]`) || grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
||||
if (grid.querySelector(`a[href$="/${data.id}"]`)) return;
|
||||
|
||||
// Determine the link prefix from existing items
|
||||
const firstThumb = grid.querySelector('a.thumb, a.lazy-thumb');
|
||||
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/(\d+|[a-zA-Z0-9_-]{11})$/, '') : '/';
|
||||
const linkBase = firstThumb ? firstThumb.getAttribute('href').replace(/\d+$/, '') : '/';
|
||||
|
||||
// Respect mode filter
|
||||
const nsflId = window.f0ckSession?.nsfl_tag_id;
|
||||
const mode = data.tag_id ? (data.tag_id === 1 ? 'sfw' : (data.tag_id === 2 ? 'nsfw' : (data.tag_id == nsflId ? 'nsfl' : 'null'))) : 'null';
|
||||
|
||||
const thumb = document.createElement('a');
|
||||
thumb.href = `${linkBase}${itemKey}`;
|
||||
thumb.href = `${linkBase}${data.id}`;
|
||||
thumb.className = 'thumb lazy-thumb';
|
||||
thumb.dataset.file = data.dest;
|
||||
thumb.dataset.mime = data.mime;
|
||||
@@ -9160,11 +9129,10 @@ class NotificationSystem {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
itemPreview = `
|
||||
<div class="item-preview" style="margin-top: 10px; display: flex; align-items: center; gap: 10px; background: rgba(0,0,0,0.2); padding: 5px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05);">
|
||||
<a href="/${itemKey}">${mediaHtml}</a>
|
||||
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context »</a>
|
||||
<a href="/${c.item_id}">${mediaHtml}</a>
|
||||
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">View Context »</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -11334,83 +11302,6 @@ document.addEventListener('click', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const visCancelBtn = e.target.closest('#visibility-modal-cancel');
|
||||
if (visCancelBtn || e.target.id === 'visibility-modal') {
|
||||
const modal = document.getElementById('visibility-modal');
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const visSaveBtn = e.target.closest('#visibility-modal-save');
|
||||
if (visSaveBtn) {
|
||||
e.preventDefault();
|
||||
const modal = document.getElementById('visibility-modal');
|
||||
const inputId = document.getElementById('visibility-item-id');
|
||||
const selectedRadio = modal ? modal.querySelector('input[name="visibility"]:checked') : null;
|
||||
if (!inputId || !selectedRadio) return;
|
||||
|
||||
const id = inputId.value;
|
||||
const nextVis = parseInt(selectedRadio.value, 10);
|
||||
|
||||
visSaveBtn.disabled = true;
|
||||
const origText = visSaveBtn.textContent;
|
||||
visSaveBtn.textContent = 'Saving...';
|
||||
|
||||
fetch('/api/v2/item/visibility', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams({ postid: id, id: id, visibility: nextVis })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
visSaveBtn.disabled = false;
|
||||
visSaveBtn.textContent = origText;
|
||||
if (data.success) {
|
||||
const visVal = parseInt(data.visibility, 10);
|
||||
const titles = ['Public', 'Unlisted', 'Private'];
|
||||
const icons = ['fa-globe', 'fa-link', 'fa-lock'];
|
||||
const colors = ['var(--color-success, #00C851)', 'var(--color-warning, #ffbb33)', 'var(--color-danger, #ff4444)'];
|
||||
|
||||
const visBtn = document.getElementById('a_visibility');
|
||||
if (visBtn) {
|
||||
visBtn.dataset.visibility = visVal;
|
||||
visBtn.setAttribute('title', `Visibility: ${titles[visVal]} (Click to change)`);
|
||||
}
|
||||
|
||||
const infoVisBtn = document.getElementById('info-visibility-edit-btn');
|
||||
if (infoVisBtn) {
|
||||
infoVisBtn.dataset.visibility = visVal;
|
||||
}
|
||||
|
||||
const infoVisLabel = document.getElementById('info-visibility-label');
|
||||
if (infoVisLabel) {
|
||||
infoVisLabel.innerHTML = `<i class="fa-solid ${icons[visVal]}" style="color: ${colors[visVal]};"></i> ${titles[visVal]}`;
|
||||
}
|
||||
|
||||
if (modal) {
|
||||
modal.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
if (window.flashMessage) window.flashMessage(`VISIBILITY SET TO ${titles[visVal].toUpperCase()}`);
|
||||
} else {
|
||||
if (window.flashError) window.flashError(data.msg || 'Failed to update visibility');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
visSaveBtn.disabled = false;
|
||||
visSaveBtn.textContent = origText;
|
||||
console.error('Error changing visibility:', err);
|
||||
if (window.flashError) window.flashError('Network error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Title save button
|
||||
const saveBtn = e.target.closest('#info-title-save');
|
||||
if (saveBtn) {
|
||||
@@ -11480,125 +11371,6 @@ document.addEventListener('click', (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Info Modal: Regenerate Thumbnail button
|
||||
const rethumbBtn = e.target.closest('#info-rethumb-btn');
|
||||
if (rethumbBtn) {
|
||||
e.preventDefault();
|
||||
const itemId = rethumbBtn.dataset.itemId || document.getElementById('info-title-input')?.dataset.itemId;
|
||||
if (!itemId) return;
|
||||
|
||||
rethumbBtn.disabled = true;
|
||||
const origText = rethumbBtn.innerHTML;
|
||||
rethumbBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Regenerating...';
|
||||
|
||||
const csrf = window.f0ckSession?.csrf_token;
|
||||
fetch(`/api/v2/items/${itemId}/rethumb`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf
|
||||
}
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
rethumbBtn.disabled = false;
|
||||
rethumbBtn.innerHTML = origText;
|
||||
if (data.success) {
|
||||
if (window.flashMessage) window.flashMessage('THUMBNAIL REGENERATED');
|
||||
else if (window.showFlash) window.showFlash('Thumbnail regenerated');
|
||||
// Cache-bust thumbnails on page
|
||||
const timestamp = Date.now();
|
||||
document.querySelectorAll(`img[src*="/t/${itemId}.webp"]`).forEach(img => {
|
||||
const url = new URL(img.src, window.location.origin);
|
||||
url.searchParams.set('t', timestamp);
|
||||
img.src = url.toString();
|
||||
});
|
||||
document.querySelectorAll(`.thumb[data-bg*="/t/${itemId}.webp"]`).forEach(thumb => {
|
||||
const bg = thumb.dataset.bg;
|
||||
if (bg) {
|
||||
const url = new URL(bg, window.location.origin);
|
||||
url.searchParams.set('t', timestamp);
|
||||
thumb.style.setProperty('--thumb-bg', `url('${url.toString()}')`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (window.flashError) window.flashError(data.msg || 'Regeneration failed');
|
||||
else alert(data.msg || 'Regeneration failed');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
rethumbBtn.disabled = false;
|
||||
rethumbBtn.innerHTML = origText;
|
||||
console.error('Error regenerating thumbnail:', err);
|
||||
if (window.flashError) window.flashError('Network error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Info Modal: Set Expiry button
|
||||
const setExpiryBtn = e.target.closest('#info-set-expiry-btn');
|
||||
if (setExpiryBtn) {
|
||||
e.preventDefault();
|
||||
const select = document.getElementById('info-expiry-select');
|
||||
const itemId = setExpiryBtn.dataset.itemId || select?.dataset.itemId || document.getElementById('info-title-input')?.dataset.itemId;
|
||||
if (!itemId || !select) return;
|
||||
|
||||
const selectedExpiry = select.value;
|
||||
|
||||
setExpiryBtn.disabled = true;
|
||||
select.disabled = true;
|
||||
const origText = setExpiryBtn.innerHTML;
|
||||
setExpiryBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
||||
|
||||
const csrf = window.f0ckSession?.csrf_token;
|
||||
fetch(`/api/v2/items/${itemId}/expiry`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrf
|
||||
},
|
||||
body: JSON.stringify({ expiry: selectedExpiry })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
setExpiryBtn.disabled = false;
|
||||
select.disabled = false;
|
||||
setExpiryBtn.innerHTML = origText;
|
||||
if (data.success) {
|
||||
const label = document.getElementById('info-expiry-label');
|
||||
if (label) {
|
||||
if (data.expires_at) {
|
||||
label.innerHTML = `<i class="fa-solid fa-clock"></i> ${data.expires_in || ('in ' + new Date(data.expires_at * 1000).toLocaleString())}`;
|
||||
label.setAttribute('title', new Date(data.expires_at * 1000).toLocaleString());
|
||||
label.style.color = '#ffaa00';
|
||||
} else {
|
||||
label.innerHTML = '<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Never';
|
||||
label.removeAttribute('title');
|
||||
label.style.color = '';
|
||||
}
|
||||
}
|
||||
if (data.purged) {
|
||||
if (window.flashMessage) window.flashMessage('ITEM EXPIRED AND PURGED');
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1200);
|
||||
} else {
|
||||
if (window.flashMessage) window.flashMessage('EXPIRATION UPDATED');
|
||||
}
|
||||
} else {
|
||||
if (window.flashError) window.flashError(data.msg || 'Failed to update expiry');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
setExpiryBtn.disabled = false;
|
||||
select.disabled = false;
|
||||
setExpiryBtn.innerHTML = origText;
|
||||
console.error('Error setting expiry:', err);
|
||||
if (window.flashError) window.flashError('Network error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Close when clicking outside modal content
|
||||
const infoModal = document.getElementById('info-modal');
|
||||
if (infoModal && e.target === infoModal) {
|
||||
|
||||
@@ -1181,35 +1181,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
const defaultUploadVisSelect = document.getElementById('default_upload_visibility_select');
|
||||
if (defaultUploadVisSelect) {
|
||||
defaultUploadVisSelect.addEventListener('change', async function() {
|
||||
const vis = parseInt(defaultUploadVisSelect.value, 10);
|
||||
try {
|
||||
const res = await fetch('/api/v2/settings/default_upload_visibility', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
|
||||
},
|
||||
body: new URLSearchParams({ default_upload_visibility: vis })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showStatus('Default upload visibility updated!', 'success');
|
||||
if (window.f0ckSession) {
|
||||
window.f0ckSession.default_upload_visibility = vis;
|
||||
}
|
||||
} else {
|
||||
alert(data.msg || 'Error saving preference');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Update Default Upload Visibility error:', err);
|
||||
alert('Connection error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// New Dual Column Layout Toggle
|
||||
const layoutToggle = document.getElementById('use_new_layout_toggle');
|
||||
if (layoutToggle) {
|
||||
|
||||
@@ -479,9 +479,8 @@
|
||||
};
|
||||
|
||||
const renderActivityItem = (c) => {
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
const rawContent = c.content || c.body || '';
|
||||
let displayContent = renderCommentContent(rawContent, c.id, itemKey);
|
||||
let displayContent = renderCommentContent(rawContent, c.id, c.item_id);
|
||||
|
||||
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
|
||||
|
||||
@@ -498,7 +497,7 @@
|
||||
}
|
||||
|
||||
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
|
||||
|
||||
// Build avatar URL — same priority as the rest of the app
|
||||
let avatarSrc = '/a/default.png';
|
||||
@@ -543,8 +542,8 @@
|
||||
|
||||
itemPreview = `
|
||||
<div class="item-preview">
|
||||
<a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</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>
|
||||
<a href="/${c.item_id}" 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>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||
const themes = window.f0ckThemes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'f0ck98', 'iced', 'orange', 'p1nk', '4d'];
|
||||
const defaultTheme = window.f0ckDefaultTheme || (window.f0ckSession && window.f0ckSession.default_theme) || themes[0] || 'amoled';
|
||||
|
||||
const setTheme = (theme) => {
|
||||
|
||||
@@ -908,7 +908,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
lines.forEach(url => {
|
||||
if (!selectedFiles.some(item => item.type === 'url' && item.url === url)) {
|
||||
selectedFiles.push({ type: 'url', url, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'url', url, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
}
|
||||
});
|
||||
urlInput.value = '';
|
||||
@@ -931,7 +931,7 @@ window.initUploadForm = (selector) => {
|
||||
const val = urlInput.value.trim();
|
||||
if (!val || !/^https?:\/\//i.test(val)) return;
|
||||
if (!selectedFiles.some(item => item.type === 'url' && item.url === val)) {
|
||||
selectedFiles.push({ type: 'url', url: val, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'url', url: val, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
}
|
||||
urlInput.value = '';
|
||||
if (urlBadge) urlBadge.style.display = 'none';
|
||||
@@ -1177,7 +1177,7 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
|
||||
if (isShitpost) {
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
} else {
|
||||
selectedFiles.push(file); // Legacy single file mode uses raw File
|
||||
}
|
||||
@@ -1490,8 +1490,6 @@ window.initUploadForm = (selector) => {
|
||||
infoRow.className = 'file-meta-row-small';
|
||||
|
||||
let ratingSwitch = '';
|
||||
let visibilitySwitch = '';
|
||||
let expirySwitch = '';
|
||||
let tagsUI = '';
|
||||
let ocUI = '';
|
||||
let commentUI = '';
|
||||
@@ -1519,56 +1517,6 @@ window.initUploadForm = (selector) => {
|
||||
</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 minTagsHint = shitpostMinTags > 0 ? ` (min ${shitpostMinTags})` : '';
|
||||
tagsUI = `
|
||||
@@ -1612,37 +1560,19 @@ window.initUploadForm = (selector) => {
|
||||
</div>
|
||||
${titleUI}
|
||||
${ratingSwitch}
|
||||
${visibilitySwitch}
|
||||
${expirySwitch}
|
||||
${tagsUI}
|
||||
${commentUI}
|
||||
`;
|
||||
|
||||
if (isShitpost) {
|
||||
// Handle Rating
|
||||
infoRow.querySelectorAll('.item-rating-container:not(.item-visibility-container) input').forEach(radio => {
|
||||
infoRow.querySelectorAll('.item-rating-option input').forEach(radio => {
|
||||
radio.onchange = () => {
|
||||
item.rating = radio.value;
|
||||
updateSubmitButton();
|
||||
};
|
||||
});
|
||||
|
||||
// Handle Visibility
|
||||
infoRow.querySelectorAll('.item-visibility-container input').forEach(radio => {
|
||||
radio.onchange = () => {
|
||||
item.visibility = radio.value;
|
||||
};
|
||||
});
|
||||
|
||||
// Handle Expiry
|
||||
const expirySelect = infoRow.querySelector('.item-expiry-select');
|
||||
if (expirySelect) {
|
||||
expirySelect.onchange = () => {
|
||||
item.expiry = expirySelect.value;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Handle Comment
|
||||
const commentInput = infoRow.querySelector('.item-comment-input');
|
||||
const emojiTrigger = infoRow.querySelector('.item-emoji-trigger');
|
||||
@@ -2506,11 +2436,6 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -2520,9 +2445,7 @@ window.initUploadForm = (selector) => {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
rating: globalRatingEl ? globalRatingEl.value : 'sfw',
|
||||
visibility: visibilityVal,
|
||||
expiry: expiryVal,
|
||||
rating: globalRatingEl.value,
|
||||
tags: tags.join(','),
|
||||
comment: comment,
|
||||
is_oc: isOc,
|
||||
@@ -2629,13 +2552,9 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; 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 file = !isUrlItem ? (isShitpost ? item.file : item) : null;
|
||||
const fileRating = isShitpost ? item.rating : (globalRatingEl ? globalRatingEl.value : 'sfw');
|
||||
const fileVisibility = isShitpost ? (item.visibility || globalVisEl?.value || '0') : (globalVisEl?.value || '0');
|
||||
const fileExpiry = isShitpost ? (item.expiry || globalExpiryEl?.value || 'permanent') : (globalExpiryEl?.value || 'permanent');
|
||||
const fileTags = isShitpost ? item.tags : tags;
|
||||
const fileComment = isShitpost ? item.comment : comment;
|
||||
const fileTitle = isShitpost ? (item.title || '') : titleVal;
|
||||
@@ -2652,8 +2571,6 @@ window.initUploadForm = (selector) => {
|
||||
formData.append('file', file);
|
||||
}
|
||||
formData.append('rating', fileRating);
|
||||
formData.append('visibility', fileVisibility);
|
||||
formData.append('expiry', fileExpiry);
|
||||
formData.append('tags', fileTags.join(','));
|
||||
formData.append('is_oc', (isShitpost ? item.is_oc : isOc) ? 'true' : 'false');
|
||||
if (isShitpost) formData.append('is_shitpost', 'true');
|
||||
@@ -2705,8 +2622,6 @@ window.initUploadForm = (selector) => {
|
||||
xhr.send(JSON.stringify({
|
||||
url: item.url,
|
||||
rating: fileRating,
|
||||
visibility: fileVisibility,
|
||||
expiry: fileExpiry,
|
||||
tags: fileTags.join(','),
|
||||
is_oc: (isShitpost ? item.is_oc : isOc),
|
||||
comment: fileComment,
|
||||
@@ -2823,14 +2738,17 @@ window.initUploadForm = (selector) => {
|
||||
// Skip redirect if every item was a background URL job
|
||||
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (!allPending) {
|
||||
const targetUrl = (lastData && (lastData.visibility > 0 || lastData.redirect || lastData.slug))
|
||||
? (lastData.redirect || `/${lastData.slug || lastData.itemid}`)
|
||||
: '/';
|
||||
// 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(targetUrl, true, { bypassCache: true });
|
||||
if (targetUrl === '/') injectNewItem();
|
||||
await window.loadPageAjax('/', true, { bypassCache: true });
|
||||
injectNewItem();
|
||||
} else {
|
||||
window.location.href = targetUrl;
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context from the DOM
|
||||
const getContext = () => {
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
const favoEl = document.querySelector("#a_favo");
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
postid: +idLink.innerText,
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -410,12 +410,11 @@ if (!window.UserCommentSystem) {
|
||||
}
|
||||
|
||||
renderComment(c) {
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
const timeAgo = this.timeAgo(c.created_at);
|
||||
const fullDate = new Date(c.created_at).toISOString();
|
||||
const content = this.renderCommentContent(c.content, itemKey);
|
||||
const content = this.renderCommentContent(c.content, c.item_id);
|
||||
|
||||
return `<div class="comment" id="c${c.id}"><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>`;
|
||||
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>`;
|
||||
}
|
||||
|
||||
startLiveTimestamps() {
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const cssDir = path.join(process.cwd(), 'public/s/css');
|
||||
const files = ['f0ckm.css', 'v0ck.css'];
|
||||
const files = ['f0ckm.css', 'v0ck.css', '98.css'];
|
||||
const outputFile = path.join(cssDir, 'bundle.css');
|
||||
|
||||
let combined = '';
|
||||
|
||||
@@ -38,16 +38,6 @@ export default new class {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
generateSlug(length = 11) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||
let slug = '';
|
||||
const bytes = crypto.randomBytes(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
slug += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||
};
|
||||
@@ -63,29 +53,6 @@ export default new class {
|
||||
const timeStr = t(unitKey, { n: interval });
|
||||
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) {
|
||||
return crypto.createHash('md5').update(str).digest("hex");
|
||||
};
|
||||
|
||||
@@ -204,46 +204,3 @@ export async function moveToDeleted(dest, deletedId) {
|
||||
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,8 +169,6 @@
|
||||
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
|
||||
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
|
||||
"default_upload_visibility": "Standard Upload-Sichtbarkeit",
|
||||
"default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.",
|
||||
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
|
||||
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
@@ -801,12 +799,6 @@
|
||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||
"slot_refreshed": "Slot erneuert",
|
||||
"admin_desc": "Du bist Admin, leg los.",
|
||||
"visibility": {
|
||||
"public": "Öffentlich",
|
||||
"unlisted": "Nicht gelistet",
|
||||
"private": "Privat",
|
||||
"change_visibility": "Sichtbarkeit ändern"
|
||||
}
|
||||
"admin_desc": "Du bist Admin, leg los."
|
||||
}
|
||||
}
|
||||
@@ -169,8 +169,6 @@
|
||||
"favorites_private_hint": "Only you and administrators can view your favorites list.",
|
||||
"hide_fav_badge": "Hide Favorite Badge Avatar",
|
||||
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
|
||||
"default_upload_visibility": "Default Upload Visibility",
|
||||
"default_upload_visibility_hint": "Set the default visibility level for your new uploads.",
|
||||
"image_expand_on_click": "Expand images inline on click",
|
||||
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
|
||||
"enable_bg_blur": "Enable Background blur",
|
||||
@@ -803,12 +801,6 @@
|
||||
"delete_confirm": "Delete this invite token?",
|
||||
"slot_refreshes_on": "slot refreshes on {date}",
|
||||
"slot_refreshed": "slot refreshed",
|
||||
"admin_desc": "You are an admin, go ahead.",
|
||||
"visibility": {
|
||||
"public": "Public",
|
||||
"unlisted": "Unlisted",
|
||||
"private": "Private",
|
||||
"change_visibility": "Change Visibility"
|
||||
}
|
||||
"admin_desc": "You are an admin, go ahead."
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,12 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import { updateHallsCache } from "../halls_cache.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
import fs from "fs";
|
||||
import url from "url";
|
||||
|
||||
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 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;
|
||||
}
|
||||
};
|
||||
const getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(" or ") : null;
|
||||
|
||||
// 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);
|
||||
@@ -263,22 +246,7 @@ export default {
|
||||
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 };
|
||||
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
let baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
if (!session) {
|
||||
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||
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))"
|
||||
: "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))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
|
||||
let tagFilter = db``;
|
||||
@@ -334,9 +302,6 @@ export default {
|
||||
userHallFilter = db`and items.id in (select uha.item_id from user_halls_assign uha where uha.hall_id = ${userHallObj.id})`;
|
||||
}
|
||||
|
||||
const isOwnerOrAdmin = (session && user && typeof user === 'string' && session.user && session.user.toLowerCase() === user.toLowerCase()) || (session && (session.admin || session.is_moderator));
|
||||
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce(items.visibility, 0) = 0`;
|
||||
|
||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
|
||||
let total = getCachedCount(cacheKey);
|
||||
|
||||
@@ -348,7 +313,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -391,7 +355,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -422,8 +385,6 @@ export default {
|
||||
const rows = (await db`
|
||||
select
|
||||
items.id,
|
||||
items.slug,
|
||||
items.visibility,
|
||||
items.mime,
|
||||
items.dest,
|
||||
items.username as username,
|
||||
@@ -560,17 +521,7 @@ export default {
|
||||
if (uhData.length) userHallObj = uhData[0];
|
||||
}
|
||||
const mime = (rawMime ?? "");
|
||||
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 itemid = rawItemid ? +rawItemid : null;
|
||||
const mimeParts = (mime || "").split(',').filter(m => ['video', 'audio', 'image', 'flash', 'pdf'].includes(m));
|
||||
const mimeSQL = mimeParts.length > 0
|
||||
? db`and (${mimeParts.map(m => m === 'flash'
|
||||
@@ -584,27 +535,18 @@ export default {
|
||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||
const isStrict = strictParams.length > 0;
|
||||
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid: rawIdOrSlug, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall, mime, itemid, strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
|
||||
|
||||
const effMode = Number(mode ?? 0);
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
let baseMode = multiRatingSQL ?? lib.getMode(effMode);
|
||||
if (!session) {
|
||||
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||
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))"
|
||||
: "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))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
const modequery = multiRatingSQL ?? lib.getMode(effMode);
|
||||
|
||||
if (itemid === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
const modequery = baseMode;
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -646,9 +588,6 @@ export default {
|
||||
return db`
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
|
||||
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${hallFilter}
|
||||
@@ -662,9 +601,11 @@ export default {
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
|
||||
|
||||
// 1. Fetch the main item
|
||||
// We only apply the active check and global NSFW filter (for guests) here.
|
||||
// We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible).
|
||||
const items = await db`
|
||||
select distinct on (items.id)
|
||||
items.*,
|
||||
@@ -688,48 +629,15 @@ export default {
|
||||
left join "user_options" uo on uo.user_id = author_u.id
|
||||
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
|
||||
where
|
||||
${itemLookup} and
|
||||
items.id = ${itemid} and
|
||||
items.active = true
|
||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const actitem = items[0];
|
||||
|
||||
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) {
|
||||
if (actitem && user_id) {
|
||||
db`
|
||||
insert into user_video_views (user_id, video_id, view_count, last_viewed)
|
||||
values (${user_id}, ${itemid}, 1, now())
|
||||
@@ -738,12 +646,15 @@ export default {
|
||||
last_viewed = now()
|
||||
`.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 (!session && getGlobalfilter() && (actitem.visibility || 0) === 0) {
|
||||
const filteredItem = await db`
|
||||
select 1 from tags_assign where item_id = ${itemid} and (${db.unsafe(getGlobalfilter())}) limit 1
|
||||
|
||||
if (!actitem) {
|
||||
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
|
||||
if (!session && getGlobalfilter()) {
|
||||
const unfilteredItem = await db`
|
||||
select id from items where id = ${itemid} and active = true limit 1
|
||||
`;
|
||||
if (filteredItem.length > 0) {
|
||||
if (unfilteredItem[0]) {
|
||||
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -759,17 +670,22 @@ export default {
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Sorry, this post is currently not visible."
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
|
||||
// Optimized: Use tags_assign driver for SFW (0) and NSFW (1) modes
|
||||
|
||||
// Determine the effective mode for optimization check (similar to Random)
|
||||
const nsfl_id = cfg.nsfl_tag_id || 3;
|
||||
const useTagsDriver = !!session && (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
const useTagsDriver = (effMode === 0 || effMode === 1 || effMode === 4) && !fav && !tag && !user && !hall;
|
||||
|
||||
const baseQuery = (whereClause, orderBy, limit = 1) => {
|
||||
return db`
|
||||
select items.id, items.slug
|
||||
select items.id
|
||||
from items
|
||||
left join tags_assign on tags_assign.item_id = items.id
|
||||
left join tags on tags.id = tags_assign.tag_id
|
||||
@@ -780,7 +696,7 @@ export default {
|
||||
where
|
||||
${buildConditions()}
|
||||
${whereClause}
|
||||
group by items.id, items.slug
|
||||
group by items.id
|
||||
${orderBy}
|
||||
limit ${limit}
|
||||
`;
|
||||
@@ -795,7 +711,7 @@ export default {
|
||||
const checkFilter = !session && nsfpIds.length > 0;
|
||||
|
||||
const query = db`
|
||||
SELECT ta.item_id as id, items.slug
|
||||
SELECT ta.item_id as id
|
||||
FROM tags_assign ta
|
||||
INNER JOIN items ON items.id = ta.item_id
|
||||
${checkFilter
|
||||
@@ -804,7 +720,6 @@ export default {
|
||||
}
|
||||
WHERE ${useTagIdOpt ? db`ta.tag_id = ${tagId}` : db`${db.unsafe(modequery)}`}
|
||||
AND items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${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``}
|
||||
@@ -866,24 +781,24 @@ export default {
|
||||
if (actitem.checksum && actitem.checksum.includes('_bypass_')) {
|
||||
const baseChecksum = actitem.checksum.split('_bypass_')[0];
|
||||
const repostRows = await db`
|
||||
SELECT id, slug, username, stamp FROM items
|
||||
SELECT id, username, stamp FROM items
|
||||
WHERE active = true
|
||||
AND id != ${itemid}
|
||||
AND (checksum = ${baseChecksum} OR checksum LIKE ${baseChecksum + '_bypass_%'})
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
} else if (actitem.checksum) {
|
||||
// Even without bypass, check if other bypass-entries exist with this same hash
|
||||
const baseChecksum = actitem.checksum;
|
||||
const repostRows = await db`
|
||||
SELECT id, slug, username, stamp FROM items
|
||||
SELECT id, username, stamp FROM items
|
||||
WHERE active = true
|
||||
AND id != ${itemid}
|
||||
AND checksum LIKE ${baseChecksum + '_bypass_%'}
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
repostItems = repostRows.map(r => ({ id: r.id, slug: r.slug, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
repostItems = repostRows.map(r => ({ id: r.id, username: r.username, stamp: r.stamp, match_type: 'checksum' }));
|
||||
}
|
||||
|
||||
// Also find visually-similar items via phash, merging with checksum results
|
||||
@@ -893,7 +808,7 @@ export default {
|
||||
const existingIds = new Set(repostItems.map(r => r.id));
|
||||
for (const pm of phashMatches) {
|
||||
if (!existingIds.has(pm.id)) {
|
||||
repostItems.push({ id: pm.id, slug: pm.slug, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||
repostItems.push({ id: pm.id, username: pm.username, stamp: pm.stamp, match_type: 'phash' });
|
||||
existingIds.add(pm.id);
|
||||
}
|
||||
}
|
||||
@@ -916,29 +831,6 @@ export default {
|
||||
const isNsfw = tags.some(t => t.id == 2);
|
||||
const isSfw = tags.some(t => t.id == 1);
|
||||
const isTagged = tags.length > 0;
|
||||
// Guest rating & untagged restriction check for public uploads (visibility === 0)
|
||||
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
|
||||
let guestBlocked = false;
|
||||
if (!isTagged && !cfg.websrv.public_untagged) guestBlocked = true;
|
||||
else if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
|
||||
else if (isNsfl) guestBlocked = true;
|
||||
|
||||
if (guestBlocked) {
|
||||
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`
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Mode-mismatch visibility check:
|
||||
// Only enforce for members (session users) with an explicit mode preference.
|
||||
@@ -951,7 +843,7 @@ export default {
|
||||
else if (userMode === 4 && (!cfg.enable_nsfl || !isNsfl)) modeBlocked = true; // NSFL mode, item is not NSFL
|
||||
else if (userMode === 2 && isTagged) modeBlocked = true; // Untagged mode, item has tags
|
||||
|
||||
if (modeBlocked && !isOwnerOrAdmin && actitem.visibility !== 1) {
|
||||
if (modeBlocked) {
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -968,11 +860,6 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
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 = {
|
||||
success: true,
|
||||
user: {
|
||||
@@ -984,8 +871,6 @@ export default {
|
||||
},
|
||||
item: {
|
||||
id: actitem.id,
|
||||
slug: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : null,
|
||||
visibility: actitem.visibility !== undefined ? actitem.visibility : 0,
|
||||
username: actitem.username,
|
||||
author_id: actitem.author_id,
|
||||
author_color: actitem.author_color,
|
||||
@@ -1044,21 +929,17 @@ export default {
|
||||
is_oc: actitem.is_oc || false,
|
||||
is_repost: actitem.checksum ? actitem.checksum.includes('_bypass_') : false,
|
||||
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,
|
||||
height: actitem.height || null,
|
||||
original_filename: actitem.original_filename || null,
|
||||
expires_at: actitem.expires_at || null,
|
||||
expires_in: lib.expiresIn(actitem.expires_at)
|
||||
|
||||
original_filename: actitem.original_filename || null
|
||||
},
|
||||
title: `${(getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id} - ${cfg.websrv.domain}`,
|
||||
title: `${actitem.id} - ${cfg.websrv.domain}`,
|
||||
pagination: {
|
||||
end: (getEnableItemSlugs() && endItem[0]?.slug) ? endItem[0].slug : (endItem[0]?.id || itemid),
|
||||
start: (getEnableItemSlugs() && startItem[0]?.slug) ? startItem[0].slug : (startItem[0]?.id || itemid),
|
||||
next: (getEnableItemSlugs() && nextItem[0]?.slug) ? nextItem[0].slug : (nextItem[0]?.id || null),
|
||||
prev: (getEnableItemSlugs() && prevItem[0]?.slug) ? prevItem[0].slug : (prevItem[0]?.id || null),
|
||||
page: (getEnableItemSlugs() && actitem.slug) ? actitem.slug : actitem.id,
|
||||
end: endItem[0]?.id || itemid,
|
||||
start: startItem[0]?.id || itemid,
|
||||
next: nextItem[0]?.id || null,
|
||||
prev: prevItem[0]?.id || null,
|
||||
page: actitem.id,
|
||||
cheat: cheat
|
||||
},
|
||||
phrase: cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)],
|
||||
@@ -1114,22 +995,7 @@ export default {
|
||||
const isStrict = strictParams.length > 0;
|
||||
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
let baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
if (!session) {
|
||||
if ((mode === 3 || mode === undefined || mode === null) && !multiRatingSQL) {
|
||||
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))"
|
||||
: "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))"
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
|
||||
let item;
|
||||
@@ -1142,7 +1008,6 @@ export default {
|
||||
WHERE
|
||||
${db.unsafe(modequery)}
|
||||
AND items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
AND items.title ILIKE ${'%' + titleQuery + '%'}
|
||||
AND items.title IS NOT NULL
|
||||
${mimeSQL}
|
||||
@@ -1165,7 +1030,6 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and "user".user ilike ${user}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!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
|
||||
@@ -1205,7 +1069,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${tagFilter}
|
||||
${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``}
|
||||
@@ -1228,7 +1091,6 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and h.slug = ${hall}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!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``}
|
||||
@@ -1245,7 +1107,6 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and uha.hall_id = ${userHallId}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${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``}
|
||||
order by random()
|
||||
@@ -1254,9 +1115,9 @@ export default {
|
||||
} else {
|
||||
// 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.
|
||||
const globalModeQuery = modequery;
|
||||
// tagId optimisation only applies for single native modes for logged-in users (not multi-rating or guest mode)
|
||||
const tagId = session && !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||
const globalModeQuery = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
// tagId optimisation only applies for single native modes (not multi-rating)
|
||||
const tagId = !multiRatingSQL && (mode === 0 || mode === 1 || mode === 4)
|
||||
? (mode === 4 ? (cfg.nsfl_tag_id || 3) : (mode === 1 ? 2 : 1))
|
||||
: null;
|
||||
// If audio is included, we avoid the strict tagId optimization to ensure audio is visible
|
||||
@@ -1272,7 +1133,6 @@ export default {
|
||||
${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``}
|
||||
WHERE items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${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``}
|
||||
@@ -1298,8 +1158,7 @@ export default {
|
||||
};
|
||||
},
|
||||
getComments: async (itemId, sort = 'new', process = true) => {
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!numericId) return [];
|
||||
if (!itemId) return [];
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
const comments = await db`
|
||||
@@ -1312,7 +1171,7 @@ export default {
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE c.item_id = ${numericId} AND c.is_deleted = false
|
||||
WHERE c.item_id = ${itemId} AND c.is_deleted = false
|
||||
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 DESC
|
||||
@@ -1455,12 +1314,11 @@ export default {
|
||||
}
|
||||
},
|
||||
getSubscriptionStatus: async (userId, itemId) => {
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return false;
|
||||
if (!userId || !itemId) return false;
|
||||
const tStart = Date.now();
|
||||
try {
|
||||
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 ${numericId} in ${Date.now() - tStart}ms`);
|
||||
const sub = await db`SELECT 1 FROM comment_subscriptions WHERE user_id = ${userId} AND item_id = ${itemId} AND is_subscribed = true`;
|
||||
console.log(`[${new Date().toISOString()}] [GETSUB] Checked sub for item ${itemId} in ${Date.now() - tStart}ms`);
|
||||
return sub.length > 0;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -1468,10 +1326,9 @@ export default {
|
||||
},
|
||||
processMentions,
|
||||
markNotificationsRead: async (userId, itemId) => {
|
||||
const numericId = await resolveNumericItemId(itemId);
|
||||
if (!userId || !numericId) return;
|
||||
if (!userId || !itemId) return;
|
||||
try {
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${numericId} AND is_read = false`;
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${userId} AND item_id = ${itemId} AND is_read = false`;
|
||||
} catch (e) {
|
||||
console.error('[F0CKLIB] Error marking notifications as read:', e);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
@@ -319,7 +319,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 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' });
|
||||
@@ -651,77 +651,6 @@ export default (router, tpl) => {
|
||||
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) => {
|
||||
if (!getEnableCleanup()) {
|
||||
return res.redirect("/admin");
|
||||
@@ -732,7 +661,6 @@ export default (router, tpl) => {
|
||||
enable_cleanup: getEnableCleanup(),
|
||||
cleanup_start_date: getCleanupStartDate(),
|
||||
cleanup_end_date: getCleanupEndDate(),
|
||||
cleanup_include_engaged: getCleanupIncludeEngaged(),
|
||||
totals: await lib.countf0cks(),
|
||||
tmp: null
|
||||
};
|
||||
@@ -746,18 +674,15 @@ export default (router, tpl) => {
|
||||
try {
|
||||
const cleanup_start_date = req.post.cleanup_start_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_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);
|
||||
setCleanupEndDate(cleanup_end_date);
|
||||
setCleanupIncludeEngaged(cleanup_include_engaged);
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), cleanup_include_engaged: getCleanupIncludeEngaged() });
|
||||
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
@@ -776,23 +701,17 @@ export default (router, tpl) => {
|
||||
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
// 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', 'cleanup_include_engaged')`;
|
||||
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`;
|
||||
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
|
||||
|
||||
const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
|
||||
const isEnabled = settingsMap['enable_cleanup'] === 'true';
|
||||
const startDate = settingsMap['cleanup_start_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
|
||||
setEnableCleanup(isEnabled);
|
||||
setCleanupStartDate(startDate);
|
||||
setCleanupEndDate(endDate);
|
||||
setCleanupIncludeEngaged(includeEngaged);
|
||||
|
||||
if (!isEnabled) {
|
||||
throw new Error('Cleanup is disabled in settings.');
|
||||
@@ -802,7 +721,7 @@ export default (router, tpl) => {
|
||||
throw new Error('Please select both a Start Date and an End Date.');
|
||||
}
|
||||
|
||||
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate} (includeEngaged: ${includeEngaged})...`);
|
||||
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`);
|
||||
|
||||
const start_stamp = ~~(new Date(startDate).getTime() / 1000);
|
||||
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
|
||||
@@ -818,7 +737,7 @@ export default (router, tpl) => {
|
||||
AND i.is_pinned = false
|
||||
AND i.stamp >= ${start_stamp}
|
||||
AND i.stamp <= ${end_stamp}
|
||||
${includeEngaged ? db`` : db`AND (
|
||||
AND (
|
||||
-- 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))
|
||||
OR
|
||||
@@ -827,7 +746,7 @@ export default (router, tpl) => {
|
||||
OR
|
||||
-- Case 3: Pending posts (not yet approved) that are old enough
|
||||
(i.active = false AND i.is_deleted = false)
|
||||
)`}
|
||||
)
|
||||
`;
|
||||
|
||||
const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import cfg from "../config.mjs";
|
||||
import { createI18n } from "../i18n.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
|
||||
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
@@ -35,12 +35,12 @@ export default (router, tpl) => {
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: itemid,
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
ratings: ratingsArr,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
@@ -170,8 +170,7 @@ export default (router, tpl) => {
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid,
|
||||
slug: data.item?.slug || null
|
||||
id: itemid
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -195,7 +194,7 @@ export default (router, tpl) => {
|
||||
|
||||
|
||||
// Infinite scroll endpoint for index thumbnails
|
||||
router.get(/^\/ajax\/items/, async (req, res) => {
|
||||
router.get(/\/ajax\/items/, async (req, res) => {
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
|
||||
@@ -2,22 +2,15 @@ import { promises as fs } from "fs";
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||
import queue from '../../queue.mjs';
|
||||
import search from '../../routeinc/search.mjs';
|
||||
import path from "path";
|
||||
import f0cklib from '../../routeinc/f0cklib.mjs';
|
||||
import audit from '../../audit.mjs';
|
||||
import { parseMultipart, collectBody } from '../../multipart.mjs';
|
||||
import { purgeExpiredUploads } from '../../lib_delete.mjs';
|
||||
import { calculateExpiresAt } from './upload.mjs';
|
||||
|
||||
const allowedMimes = ["audio", "image", "video", "%"];
|
||||
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 getGlobalfilter = () => cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const metaCache = new Map();
|
||||
const MAX_META_CACHE = 2000;
|
||||
|
||||
@@ -601,12 +594,10 @@ export default router => {
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin
|
||||
exclude: req.session?.excluded_tags || []
|
||||
});
|
||||
|
||||
if (!data || !data.itemid) {
|
||||
if (!data.itemid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
items: []
|
||||
@@ -645,7 +636,6 @@ export default router => {
|
||||
items: {
|
||||
...safeItem,
|
||||
id: item.id,
|
||||
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
|
||||
dest: relativeDest,
|
||||
url: directUrl,
|
||||
direct_url: directUrl
|
||||
@@ -710,11 +700,9 @@ export default router => {
|
||||
};
|
||||
|
||||
const excludedTags = req.session?.excluded_tags || [];
|
||||
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce("items".visibility, 0) = 0`;
|
||||
|
||||
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 || 0;
|
||||
const newest = (await db`select max(id) as id from "items"`)[0].id;
|
||||
const oldest = (await db`select min(id) as id from "items"`)[0].id;
|
||||
const modequery = lib.getMode(opt.mode);
|
||||
|
||||
const rows = (await db`
|
||||
@@ -724,7 +712,6 @@ export default router => {
|
||||
where
|
||||
${db.unsafe(modequery)} and
|
||||
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``}
|
||||
${opt.older
|
||||
? db`and id <= ${opt.older}`
|
||||
@@ -740,8 +727,8 @@ export default router => {
|
||||
`).sort((a, b) => b.id - a.id);
|
||||
|
||||
return res.json({
|
||||
atEnd: rows.length > 0 && rows[0].id === newest,
|
||||
atStart: rows.length > 0 && rows[rows.length - 1].id === oldest,
|
||||
atEnd: rows[0].id === newest,
|
||||
atStart: rows[rows.length - 1].id === oldest,
|
||||
success: true,
|
||||
items: rows
|
||||
}, 200);
|
||||
@@ -756,6 +743,20 @@ export default router => {
|
||||
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
|
||||
`;
|
||||
|
||||
if (item.length === 0) {
|
||||
return res.json({
|
||||
@@ -764,54 +765,12 @@ export default router => {
|
||||
});
|
||||
}
|
||||
|
||||
const actitem = item[0];
|
||||
const session = req.session;
|
||||
const isOwnerOrAdmin = session && (
|
||||
session.admin ||
|
||||
session.is_moderator ||
|
||||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase())
|
||||
);
|
||||
|
||||
// Exclude unlisted (1) and private (2) items from API responses for non-owners/non-admins
|
||||
if ((actitem.visibility || 0) > 0 && !isOwnerOrAdmin) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'no items found'
|
||||
});
|
||||
}
|
||||
|
||||
let guestTagFilter = db``;
|
||||
if (!session) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
guestTagFilter = cfg.websrv.public_untagged
|
||||
? db`and (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))`
|
||||
: db`and id in (select item_id from tags_assign where tag_id in (1, 2))`;
|
||||
} else {
|
||||
guestTagFilter = cfg.websrv.public_untagged
|
||||
? 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))`
|
||||
: db`and id in (select item_id from tags_assign where tag_id = 1)`;
|
||||
}
|
||||
}
|
||||
|
||||
const next = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id > ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
|
||||
order by id
|
||||
limit 1
|
||||
`;
|
||||
const prev = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id < ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
|
||||
order by id desc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const rows = {
|
||||
...actitem,
|
||||
...item[0],
|
||||
...{
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
}
|
||||
};
|
||||
|
||||
return res.json({
|
||||
@@ -1079,24 +1038,7 @@ export default router => {
|
||||
});
|
||||
|
||||
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
|
||||
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;
|
||||
const postid = +req.post.postid;
|
||||
|
||||
// Check if already faved by this user — compare as numbers to avoid type mismatch
|
||||
const existing = await db`
|
||||
@@ -1226,121 +1168,6 @@ 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) => {
|
||||
const itemid = +req.params.id;
|
||||
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
|
||||
|
||||
@@ -353,30 +353,6 @@ 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
|
||||
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
||||
const { color } = req.post;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { spawn as _spawnRaw } from 'child_process';
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||
import queue from '../../queue.mjs';
|
||||
import path from "path";
|
||||
@@ -138,71 +137,6 @@ const parseMultipart = (buffer, boundary) => {
|
||||
|
||||
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
|
||||
const collectBody = (req) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -435,11 +369,6 @@ export default router => {
|
||||
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
|
||||
const filename = `yt:${videoId}`;
|
||||
|
||||
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||
const itemSlug = 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`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
@@ -451,14 +380,11 @@ export default router => {
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
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')}
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -511,9 +437,6 @@ export default router => {
|
||||
});
|
||||
} else {
|
||||
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
|
||||
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
const session = {
|
||||
id: req.session.id,
|
||||
user: req.session.user,
|
||||
@@ -751,7 +674,6 @@ export default router => {
|
||||
await fs.unlink(source).catch(() => { });
|
||||
|
||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
@@ -764,14 +686,11 @@ export default router => {
|
||||
username: session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
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')}
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -806,8 +725,8 @@ export default router => {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved and public)
|
||||
if (!isApprovalRequired && targetVisibility === 0) {
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
@@ -823,8 +742,8 @@ export default router => {
|
||||
}
|
||||
}
|
||||
|
||||
// Push to Matrix Channel (only if auto-approved and public)
|
||||
if (!isApprovalRequired && targetVisibility === 0) {
|
||||
// Push to Matrix Channel (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
try {
|
||||
const self = router.self;
|
||||
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
|
||||
router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -156,8 +155,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
|
||||
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 globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
/* <mode-override> */
|
||||
// prioritize query mode (from AJAX) over session default
|
||||
@@ -176,7 +174,7 @@ export default (router, tpl) => {
|
||||
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||
|
||||
const comments = await db`
|
||||
SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug
|
||||
SELECT c.*, i.mime, i.id as item_id
|
||||
FROM comments c
|
||||
LEFT JOIN items i ON c.item_id = i.id
|
||||
WHERE c.user_id = ${userId} AND c.is_deleted = false
|
||||
@@ -210,7 +208,6 @@ export default (router, tpl) => {
|
||||
let processedComments = mentionsProcessed.map(c => {
|
||||
return {
|
||||
...c,
|
||||
item_slug: getEnableItemSlugs() ? c.item_slug : null,
|
||||
content: c.content
|
||||
};
|
||||
});
|
||||
@@ -573,9 +570,7 @@ export default (router, tpl) => {
|
||||
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
|
||||
const itemQuery = await db`
|
||||
SELECT
|
||||
i.slug,
|
||||
i.xd_score,
|
||||
COALESCE(i.visibility, 0) as visibility,
|
||||
(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[])
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
|
||||
@@ -597,7 +592,6 @@ export default (router, tpl) => {
|
||||
type: 'comment',
|
||||
id: commentId,
|
||||
item_id: item_id,
|
||||
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
|
||||
parent_id: parent_id || null,
|
||||
body: notifyBody,
|
||||
username: req.session.user,
|
||||
@@ -615,9 +609,9 @@ export default (router, tpl) => {
|
||||
// 1. Thread live update
|
||||
db.notify('comments', JSON.stringify(livePayload));
|
||||
|
||||
// 2. Sidebar activity update (only for public items)
|
||||
const itemVisibility = itemQuery[0]?.visibility ?? 0;
|
||||
if (itemVisibility === 0) {
|
||||
// 2. Sidebar activity update
|
||||
// Compute is_long using the full content (not the truncated notifyBody) so the
|
||||
// sidebar renders the correct clamped state immediately on first paint.
|
||||
const activityIsLong = content.length > 120
|
||||
|| content.split('\n').length > 2
|
||||
|| activityFiles.length > 0
|
||||
@@ -638,7 +632,6 @@ export default (router, tpl) => {
|
||||
files: activityFiles,
|
||||
is_long: activityIsLong
|
||||
}));
|
||||
}
|
||||
|
||||
// Automatically subscribe user to the thread
|
||||
const subResult = await db`
|
||||
@@ -1002,8 +995,7 @@ export default (router, tpl) => {
|
||||
|
||||
// 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 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 globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
|
||||
const comments = await db`
|
||||
@@ -1011,7 +1003,6 @@ export default (router, tpl) => {
|
||||
c.*,
|
||||
i.mime,
|
||||
i.id as item_id,
|
||||
i.slug as item_slug,
|
||||
i.dest as item_dest,
|
||||
(SELECT ta.tag_id FROM tags_assign ta
|
||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||
@@ -1028,7 +1019,6 @@ export default (router, tpl) => {
|
||||
WHERE c.is_deleted = false
|
||||
AND i.active = true
|
||||
AND i.is_deleted = false
|
||||
AND COALESCE(i.visibility, 0) = 0
|
||||
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``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
@@ -1037,15 +1027,9 @@ export default (router, tpl) => {
|
||||
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
|
||||
const filesMap = new Map();
|
||||
if (processedCommentsList.length > 0) {
|
||||
if (comments.length > 0) {
|
||||
const commentIds = comments.map(c => c.id);
|
||||
try {
|
||||
const files = await db`
|
||||
|
||||
@@ -437,9 +437,8 @@ export default (router) => {
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
original_filename: original_filename || null,
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||
original_filename: original_filename || null
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export default (router, tpl) => {
|
||||
ratings: ratingsArr,
|
||||
mime: mime,
|
||||
fav: false,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id,
|
||||
random: isRandom
|
||||
});
|
||||
@@ -253,7 +253,7 @@ export default (router, tpl) => {
|
||||
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
|
||||
|
||||
if (!data.success) {
|
||||
if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) {
|
||||
if (data.is_private) {
|
||||
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||
return res.reply({
|
||||
code: 403,
|
||||
@@ -390,8 +390,6 @@ 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_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
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');
|
||||
@@ -416,11 +414,11 @@ export default (router, tpl) => {
|
||||
|
||||
// Specific route for direct item links: /user/:user/:itemid
|
||||
// This avoids ambiguity with the profile route
|
||||
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>[a-zA-Z0-9_-]+)$/, handleGenericRoute);
|
||||
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
|
||||
|
||||
// Generic router for everything else (Index, Tags, standard User Grids)
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
|
||||
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<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);
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
|
||||
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
/* </routing-refactor> */
|
||||
|
||||
router.get(/^\/(about)$/, (req, res) => {
|
||||
|
||||
@@ -114,7 +114,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
const f0ck = await db`
|
||||
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
|
||||
select i.dest, i.mime, i.username, i.id, ta.tag_id
|
||||
from "items" i
|
||||
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
|
||||
@@ -156,9 +156,6 @@ export default (router, tpl) => {
|
||||
console.error('[MOD APPROVE] Failed to notify user:', err);
|
||||
}
|
||||
|
||||
const isPublic = (f0ck[0].visibility || 0) === 0;
|
||||
|
||||
if (isPublic) {
|
||||
// Push to Discord Webhook (Direct)
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
@@ -220,7 +217,6 @@ export default (router, tpl) => {
|
||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move files to public location
|
||||
const movePaths = [
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import { setMotd } from "../motd.mjs";
|
||||
|
||||
export const clients = new Set();
|
||||
@@ -123,7 +122,7 @@ db.listen('activity', async (payload) => {
|
||||
// We need the username, avatar, and item mime for the preview
|
||||
// trigger only gave us user_id and item_id
|
||||
const [details] = await db`
|
||||
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime, i.slug as item_slug, COALESCE(i.visibility, 0) as visibility,
|
||||
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime,
|
||||
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
@@ -132,7 +131,6 @@ db.listen('activity', async (payload) => {
|
||||
`;
|
||||
|
||||
if (details) {
|
||||
if (details.visibility > 0) return;
|
||||
data.username = details.username;
|
||||
data.avatar = details.avatar;
|
||||
data.avatar_file = details.avatar_file;
|
||||
@@ -140,7 +138,6 @@ db.listen('activity', async (payload) => {
|
||||
data.username_color = details.username_color;
|
||||
data.display_name = details.display_name || null;
|
||||
data.tag_id = details.tag_id;
|
||||
data.item_slug = getEnableItemSlugs() ? details.item_slug : null;
|
||||
} else {
|
||||
data.username = 'System';
|
||||
}
|
||||
@@ -213,7 +210,6 @@ db.listen('motd', (payload) => {
|
||||
db.listen('new_item', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
if (data.visibility && data.visibility !== 0) return;
|
||||
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'new_item', data });
|
||||
@@ -371,7 +367,7 @@ export default (router, tpl) => {
|
||||
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
|
||||
const notifications = typeFilter
|
||||
? await db`
|
||||
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -396,7 +392,7 @@ export default (router, tpl) => {
|
||||
OFFSET ${offset}
|
||||
`
|
||||
: await db`
|
||||
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -430,7 +426,7 @@ export default (router, tpl) => {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||
return { ...n, reason };
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -447,7 +443,7 @@ export default (router, tpl) => {
|
||||
|
||||
try {
|
||||
const notifications = await db`
|
||||
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -484,7 +480,7 @@ export default (router, tpl) => {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||
return { ...n, reason };
|
||||
});
|
||||
|
||||
return res.reply({
|
||||
|
||||
@@ -97,11 +97,11 @@ export default (router, tpl) => {
|
||||
`;
|
||||
|
||||
const favotop = await db`
|
||||
select favorites.item_id as id, items.slug, count(*) favs
|
||||
select favorites.item_id, count(*) favs
|
||||
from favorites
|
||||
join items on items.id = favorites.item_id
|
||||
where items.active = true
|
||||
group by favorites.item_id, items.slug
|
||||
group by favorites.item_id
|
||||
having count(*) > 1
|
||||
order by favs desc
|
||||
limit 10
|
||||
@@ -110,7 +110,7 @@ export default (router, tpl) => {
|
||||
let xdtop = [];
|
||||
if (config.websrv.enable_xd_score) {
|
||||
const xdRows = await db`
|
||||
select id, slug, xd_score
|
||||
select id, xd_score
|
||||
from items
|
||||
where active = true and is_deleted = false and xd_score > 0
|
||||
order by xd_score desc
|
||||
|
||||
@@ -268,7 +268,6 @@ export default (router, tpl) => {
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
${excludeSwfSQL}
|
||||
${excludePdfSQL}
|
||||
${excludeArchiveSQL}
|
||||
@@ -293,7 +292,6 @@ export default (router, tpl) => {
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
${excludeSwfSQL}
|
||||
${excludePdfSQL}
|
||||
${excludeArchiveSQL}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default (router, tpl) => {
|
||||
const subs = await db`
|
||||
SELECT
|
||||
s.created_at as sub_date,
|
||||
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
|
||||
i.id, i.dest, i.mime, i.username as uploader_name
|
||||
FROM comment_subscriptions s
|
||||
JOIN items i ON s.item_id = i.id
|
||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||
@@ -45,7 +45,6 @@ export default (router, tpl) => {
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
slug: i.slug || null,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
@@ -109,7 +108,7 @@ export default (router, tpl) => {
|
||||
const subs = await db`
|
||||
SELECT
|
||||
s.created_at as sub_date,
|
||||
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
|
||||
i.id, i.dest, i.mime, i.username as uploader_name
|
||||
FROM comment_subscriptions s
|
||||
JOIN items i ON s.item_id = i.id
|
||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||
@@ -119,7 +118,6 @@ export default (router, tpl) => {
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
slug: i.slug || null,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
|
||||
@@ -31,9 +31,7 @@ export async function regenerateTagImage(tag, mode) {
|
||||
JOIN tags_assign ta ON ta.item_id = i.id
|
||||
JOIN tags t ON t.id = ta.tag_id
|
||||
${modeFilter}
|
||||
WHERE (t.tag = ${tag} OR t.normalized = ${tag})
|
||||
AND i.active = true
|
||||
AND COALESCE(i.visibility, 0) = 0
|
||||
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
@@ -9,23 +9,7 @@ export default (router, tpl) => {
|
||||
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
|
||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||
const isGuest = !sessionObj;
|
||||
let baseMode = lib.getMode(mode);
|
||||
if (isGuest) {
|
||||
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))"
|
||||
: "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))"
|
||||
: "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;
|
||||
const modequery = lib.getMode(mode);
|
||||
|
||||
let restrictedFilter = db``;
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
@@ -51,7 +35,6 @@ export default (router, tpl) => {
|
||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE items.active = true
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND t.id NOT IN (1, 2)
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
@@ -75,7 +58,6 @@ export default (router, tpl) => {
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||
AND items.active = true
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
${userExcludeFilter}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default (router, tpl) => {
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: req.params.page,
|
||||
mode: req.mode,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
@@ -138,7 +138,7 @@ export default (router, tpl) => {
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: req.params.itemid,
|
||||
mode: req.mode,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
@@ -260,7 +260,7 @@ export default (router, tpl) => {
|
||||
FROM items i
|
||||
JOIN user_halls_assign uha ON uha.item_id = i.id
|
||||
${modeFilter}
|
||||
WHERE uha.hall_id = ${hall.id} AND i.active = true AND COALESCE(i.visibility, 0) = 0
|
||||
WHERE uha.hall_id = ${hall.id} AND i.active = true
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
@@ -9,23 +9,7 @@ export default (router, tpl) => {
|
||||
const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => {
|
||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||
const isGuest = !sessionObj;
|
||||
let baseMode = lib.getMode(mode);
|
||||
if (isGuest) {
|
||||
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))"
|
||||
: "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))"
|
||||
: "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;
|
||||
const modequery = lib.getMode(mode);
|
||||
|
||||
let restrictedFilter = db``;
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
@@ -51,7 +35,6 @@ export default (router, tpl) => {
|
||||
JOIN tags_assign ta ON t.id = ta.tag_id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE items.active = true
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND t.id NOT IN (1, 2)
|
||||
AND ta.user_id = ${userId}
|
||||
AND ${db.unsafe(modequery)}
|
||||
@@ -76,7 +59,6 @@ export default (router, tpl) => {
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
|
||||
AND items.active = true
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND ta.user_id = ${userId}
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
|
||||
@@ -26,17 +26,14 @@ export default new class {
|
||||
* @returns {string}
|
||||
*/
|
||||
getRealIP(req) {
|
||||
let rawIp = req.headers['cf-connecting-ip'] ||
|
||||
let ip = req.headers['cf-connecting-ip'] ||
|
||||
req.headers['true-client-ip'] ||
|
||||
req.headers['x-client-ip'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.headers['x-forwarded-for'] ||
|
||||
req.socket?.remoteAddress;
|
||||
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
||||
req.socket.remoteAddress;
|
||||
|
||||
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();
|
||||
if (!ip) return "unknown";
|
||||
|
||||
// Handle IPv6 loopback and mapped IPv4
|
||||
if (ip === "::1") ip = "127.0.0.1";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import cfg from "./config.mjs";
|
||||
import db from "./sql.mjs";
|
||||
import lib from "./lib.mjs";
|
||||
|
||||
let manual_approval = true;
|
||||
let min_tags = 3;
|
||||
@@ -16,37 +14,9 @@ let enable_pdf = false;
|
||||
let enable_cleanup = false;
|
||||
let cleanup_start_date = '';
|
||||
let cleanup_end_date = '';
|
||||
let cleanup_include_engaged = false;
|
||||
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
|
||||
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 = () => {
|
||||
if (cfg.websrv.enable_cleanup === false) return false;
|
||||
return enable_cleanup;
|
||||
@@ -59,9 +29,6 @@ export const setCleanupStartDate = (val) => cleanup_start_date = val || '';
|
||||
export const getCleanupEndDate = () => cleanup_end_date;
|
||||
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 setEnablePdf = (val) => enable_pdf = !!val;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where id >= ${randomId} and active = true and coalesce(visibility, 0) = 0
|
||||
where id >= ${randomId} and active = true
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
@@ -43,7 +43,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where active = true and coalesce(visibility, 0) = 0
|
||||
where active = true
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
@@ -54,7 +54,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where active = true and coalesce(visibility, 0) = 0 and
|
||||
where
|
||||
${args.map(a => a.charAt(0) === "!"
|
||||
? db`username not ilike ${a.slice(1)}`
|
||||
: db`username ilike ${a}`
|
||||
|
||||
@@ -752,9 +752,8 @@ export default async bot => {
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval(),
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
@@ -859,9 +858,8 @@ export default async bot => {
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval(),
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
|
||||
@@ -20,11 +20,9 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
|
||||
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, ensureAllItemsHaveSlugs } 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, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds } from "./inc/settings.mjs";
|
||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||
import { createI18n } from "./inc/i18n.mjs";
|
||||
import { safeDeleteMediaFile, purgeExpiredUploads } from "./inc/lib_delete.mjs";
|
||||
|
||||
import security from "./inc/security.mjs";
|
||||
|
||||
import { createRequire } from 'module';
|
||||
@@ -518,37 +516,6 @@ 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.
|
||||
// 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.
|
||||
@@ -692,7 +659,7 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'iced', 'orange', 'p1nk', '4d'];
|
||||
const availableThemes = cfg.websrv.themes || ['amoled', 'atmos', 'f0ck', 'f0ck95d', 'f0ck98', 'iced', 'orange', 'p1nk', '4d'];
|
||||
const defaultTheme = cfg.websrv.theme || 'amoled';
|
||||
req.theme = (req.cookies?.theme && availableThemes.includes(req.cookies.theme)) ? req.cookies.theme : defaultTheme;
|
||||
req.fullscreen = req.cookies.fullscreen || 0;
|
||||
@@ -705,7 +672,7 @@ process.on('uncaughtException', err => {
|
||||
user = [_cachedRow];
|
||||
} else {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".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
|
||||
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
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
@@ -908,28 +875,13 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
});
|
||||
|
||||
// Intercept app.readBody to allow memoized reading of request body.
|
||||
// flummpress runs app.use() in parallel via Promise.all before routing/body-reading,
|
||||
// so validateCsrf needs to be able to read req.post without breaking subsequent router handler body parsing.
|
||||
const _originalReadBody = app.readBody.bind(app);
|
||||
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) => {
|
||||
// CSRF validation helper — used by route handlers that have already populated req.session
|
||||
// NOTE: Cannot be used in flummpress app.use() middlewares for upload/avatar bypass handlers
|
||||
// because flummpress runs ALL middlewares in parallel (Promise.all), so the session
|
||||
// middleware hasn't finished by the time these run. Those handlers validate CSRF inline.
|
||||
const validateCsrf = (req, res) => {
|
||||
if (req.session && req.session.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) {}
|
||||
}
|
||||
|
||||
const token = req.headers['x-csrf-token'] || req.body?.csrf_token || req.post?.csrf_token || req.url.qs?.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'}`);
|
||||
res.writeHead(403, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Invalid CSRF token' }));
|
||||
@@ -951,7 +903,7 @@ process.on('uncaughtException', err => {
|
||||
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
|
||||
if (cfg.websrv.userhalls_enabled !== false && cfg.websrv.enable_userhall_image_upload !== false && req.url.pathname.match(/^\/api\/v2\/me\/halls\/[^/]+\/image$/)) return;
|
||||
if (!(await validateCsrf(req, res))) return;
|
||||
if (!validateCsrf(req, res)) return;
|
||||
});
|
||||
|
||||
// Bypass middleware for direct upload handling
|
||||
@@ -1272,12 +1224,6 @@ process.on('uncaughtException', err => {
|
||||
setCleanupEndDate(endSetting[0].value);
|
||||
}
|
||||
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) {
|
||||
console.warn(`[BOOT] Cleanup settings fetch failed:`, e.message);
|
||||
setEnableCleanup(!!cfg.websrv.enable_cleanup);
|
||||
@@ -1363,9 +1309,6 @@ process.on('uncaughtException', err => {
|
||||
console.warn(`[BOOT] NSFP setting fetch failed:`, e.message);
|
||||
}
|
||||
|
||||
// Ensure all items in database have a unique slug backfilled
|
||||
ensureAllItemsHaveSlugs();
|
||||
|
||||
const globals = {
|
||||
lul: cfg.websrv.lul,
|
||||
themes: cfg.websrv.themes,
|
||||
@@ -1419,16 +1362,10 @@ process.on('uncaughtException', err => {
|
||||
default_font: cfg.websrv.default_font || "",
|
||||
site_description: cfg.websrv.description || "The webs dumpster",
|
||||
enable_nsfl: !!cfg.enable_nsfl,
|
||||
enable_private_uploads: cfg.enable_private_uploads !== false,
|
||||
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,
|
||||
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 || []),
|
||||
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 dm_attachments() { return getDmAttachments(); },
|
||||
get dm_unencrypted() { return getDmUnencrypted(); },
|
||||
@@ -1437,7 +1374,6 @@ process.on('uncaughtException', err => {
|
||||
get enable_cleanup() { return getEnableCleanup(); },
|
||||
get cleanup_start_date() { return getCleanupStartDate(); },
|
||||
get cleanup_end_date() { return getCleanupEndDate(); },
|
||||
get cleanup_include_engaged() { return getCleanupIncludeEngaged(); },
|
||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||
telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false,
|
||||
ts: Date.now(),
|
||||
@@ -1546,18 +1482,23 @@ process.on('uncaughtException', err => {
|
||||
globals.recaptcha_enabled = perRequestRecaptcha;
|
||||
|
||||
// Resolve per-request infobox preference
|
||||
// Guests always get false — the alternative infobox is a logged-in user preference only
|
||||
const activeReq = req || effectiveReq;
|
||||
const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
|
||||
? activeReq.session.use_alternative_infobox
|
||||
: (activeReq && !activeReq.session
|
||||
? false
|
||||
: (data && typeof data.user_alternative_infobox === 'boolean'
|
||||
? data.user_alternative_infobox
|
||||
: (cfg.websrv.user_alternative_infobox !== false));
|
||||
: (cfg.websrv.user_alternative_infobox !== false)));
|
||||
|
||||
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
|
||||
? activeReq.session.use_alternative_steuerung
|
||||
: (activeReq && !activeReq.session
|
||||
? false
|
||||
: (data && typeof data.user_alternative_steuerung === 'boolean'
|
||||
? data.user_alternative_steuerung
|
||||
: (cfg.websrv.user_alternative_steuerung !== false));
|
||||
: (cfg.websrv.user_alternative_steuerung !== false)));
|
||||
|
||||
data = Object.assign({}, globals, data || {}, {
|
||||
t: perRequestT,
|
||||
@@ -1627,11 +1568,6 @@ process.on('uncaughtException', err => {
|
||||
setTimeout(cleanupStaleSessions, 30_000);
|
||||
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
|
||||
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
|
||||
const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0;
|
||||
|
||||
@@ -78,7 +78,6 @@ export async function handleKoepfeUpload(req, res) {
|
||||
throw new Error('Unsupported format');
|
||||
}
|
||||
|
||||
await fs.mkdir(cfg.paths.koepfe, { recursive: true });
|
||||
const newName = crypto.randomBytes(8).toString('hex') + ext;
|
||||
const targetPath = path.join(cfg.paths.koepfe, newName);
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
|
||||
import queue from "./inc/queue.mjs";
|
||||
import path from "path";
|
||||
import https from "https";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.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.
|
||||
// Adding a new archive type to config.json is sufficient; no code change needed.
|
||||
@@ -36,8 +34,6 @@ 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
|
||||
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 expires_at bigint DEFAULT NULL`.catch(() => {});
|
||||
|
||||
|
||||
// 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)
|
||||
@@ -47,10 +43,6 @@ db`ALTER TABLE items ALTER COLUMN checksum TYPE character varying(255)`.catch(()
|
||||
db`ALTER TABLE items ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
||||
db`ALTER TABLE comment_files ALTER COLUMN dest TYPE character varying(60)`.catch(() => {});
|
||||
|
||||
// One-time migration: fix NULL visibility values and ensure NOT NULL default going forward
|
||||
db`UPDATE items SET visibility = 0 WHERE visibility IS NULL`.catch(() => {});
|
||||
db`ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0`.catch(() => {});
|
||||
|
||||
export const handleUpload = async (req, res, self) => {
|
||||
// Manual session lookup is required here because this handler is called from a
|
||||
// bypass middleware that runs in parallel with the main session middleware.
|
||||
@@ -146,44 +138,6 @@ export const handleUpload = async (req, res, self) => {
|
||||
|
||||
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
|
||||
|
||||
// Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference
|
||||
let targetVisibility = 0;
|
||||
if (cfg.enable_private_uploads !== false) {
|
||||
const 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;
|
||||
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
|
||||
return sendJson(res, { success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
|
||||
@@ -496,17 +450,14 @@ export const handleUpload = async (req, res, self) => {
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !manualApproval,
|
||||
is_oc: is_oc,
|
||||
original_filename: originalFilename,
|
||||
title: title,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
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')}
|
||||
height: itemHeight
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height')}
|
||||
`;
|
||||
|
||||
const itemid = await queue.getItemID(filename);
|
||||
@@ -727,8 +678,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
// Auto-tagging from embedded metadata was removed — the user must select suggestions explicitly.
|
||||
|
||||
|
||||
// Discord Webhook (only for public uploads)
|
||||
if (targetVisibility === 0) {
|
||||
// Discord Webhook
|
||||
try {
|
||||
const discordClient = cfg.clients.find(c => c.type === 'discord');
|
||||
if (discordClient && discordClient.webhook_url) {
|
||||
@@ -752,10 +702,9 @@ export const handleUpload = async (req, res, self) => {
|
||||
} catch (err) {
|
||||
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved and public)
|
||||
if (!manualApproval && targetVisibility === 0) {
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
if (!manualApproval) {
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
@@ -771,8 +720,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Push to Matrix Channel (only if auto-approved and public)
|
||||
if (!manualApproval && targetVisibility === 0) {
|
||||
// Push to Matrix Channel
|
||||
try {
|
||||
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
|
||||
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
|
||||
@@ -787,7 +735,6 @@ export const handleUpload = async (req, res, self) => {
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Matrix notification error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Staff Notifications
|
||||
if (manualApproval) {
|
||||
@@ -821,16 +768,13 @@ export const handleUpload = async (req, res, self) => {
|
||||
: 'Upload successful! Your upload is now live.';
|
||||
|
||||
const imagesPath = cfg.websrv.paths?.images || '/b';
|
||||
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
msg: successMsg,
|
||||
itemid: itemid,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility,
|
||||
manual_approval: manualApproval,
|
||||
redirect: !manualApproval ? itemRoute : null,
|
||||
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
|
||||
redirect: !manualApproval ? `/${itemid}` : null,
|
||||
url: !manualApproval ? `${cfg.main.url.full}/${itemid}` : `${cfg.main.url.full}/`,
|
||||
file_url: !manualApproval ? `${cfg.main.url.full}${imagesPath}/${filename}` : null,
|
||||
// Fields for immediate client-side grid injection (avoids SSE race condition)
|
||||
dest: filename,
|
||||
|
||||
@@ -117,20 +117,18 @@
|
||||
status.style.color = 'var(--accent)';
|
||||
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '{{ csrf_token }}';
|
||||
const res = await fetch('/admin/settings', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': csrfToken
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
manual_approval: approvalToggle.checked ? 'on' : 'off',
|
||||
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
|
||||
min_tags: minTagsInput.value,
|
||||
trusted_uploads: trustedUploadsInput.value,
|
||||
csrf_token: csrfToken
|
||||
csrf_token: '{{ csrf_token }}'
|
||||
}).toString()
|
||||
});
|
||||
|
||||
|
||||
@@ -26,14 +26,6 @@
|
||||
</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;">
|
||||
<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>
|
||||
@@ -109,13 +101,8 @@
|
||||
async function runCleanup() {
|
||||
const btn = document.getElementById('run-cleanup-btn');
|
||||
const status = document.getElementById('run-status');
|
||||
const includeEngaged = document.getElementById('cleanup_include_engaged')?.checked || false;
|
||||
|
||||
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;
|
||||
if (!confirm('Are you absolutely sure? This will PERMANENTLY delete files from disk. This action cannot be undone.')) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'CLEANING UP...';
|
||||
@@ -123,17 +110,12 @@
|
||||
status.style.color = 'var(--accent)';
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append('include_engaged', includeEngaged ? 'true' : 'false');
|
||||
|
||||
const res = await fetch('/admin/cleanup/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token || '{{ csrf_token }}'
|
||||
},
|
||||
body: formData
|
||||
}
|
||||
});
|
||||
|
||||
const cleanup_response = await res.json();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@include(snippets/page-title)
|
||||
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
||||
@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').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 }}">
|
||||
<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 }}">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<div class="_204863">
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
@@ -102,7 +102,7 @@
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="user-infobox-body">
|
||||
<div class="user-infobox-description">
|
||||
@@ -111,9 +111,9 @@
|
||||
<div class="user-infobox-actions">
|
||||
@if(session)
|
||||
@if(user_has_favorited)
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
|
||||
@else
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
|
||||
@endif
|
||||
@endif
|
||||
<span id="oc-badge-container-infobox">@if(item.is_oc)<span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
@@ -125,15 +125,13 @@
|
||||
|
||||
<span class="badge badge-dark">
|
||||
|
||||
<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>
|
||||
<a href="/{{ item.id }}" class="id-link" @if(user_alternative_infobox)style="display:none"@endif>{{ 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)
|
||||
<span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
@endif
|
||||
</span>
|
||||
@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(!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(halls_enabled && item.primaryHall)
|
||||
<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
|
||||
@@ -145,9 +143,9 @@
|
||||
@if(session)
|
||||
@if(!user_alternative_infobox)
|
||||
@if(user_has_favorited)
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
|
||||
@else
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" 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>
|
||||
@@ -199,7 +197,7 @@
|
||||
</span>
|
||||
<span class="badge" id="favs" @if(!item.favorites.length) hidden@endif>
|
||||
@each(item.favorites as fav)
|
||||
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
|
||||
@if(fav.hide_fav_badge)
|
||||
<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
|
||||
<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>
|
||||
@@ -257,58 +255,6 @@
|
||||
<td>{!! item.title !!}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr class="info-id-row">
|
||||
<th>ID</th>
|
||||
<td><code>{{ item.id }}</code></td>
|
||||
</tr>
|
||||
<tr class="info-visibility-row">
|
||||
<th>Visibility</th>
|
||||
<td>
|
||||
<span id="info-visibility-label" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
@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 id="info-visibility-edit-btn" class="btn-secondary btn-sm" style="margin-left: 8px; padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}"><i class="fa-solid fa-pen"></i> Edit</button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<tr class="info-expiry-row">
|
||||
<th>Expires</th>
|
||||
<td>
|
||||
<div class="info-expiry-wrap" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
<span id="info-expiry-label" style="display: inline-flex; align-items: center; gap: 6px; color: #ffaa00;" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||
@if(item.expires_at)
|
||||
<i class="fa-solid fa-clock"></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> Never
|
||||
@endif
|
||||
</span>
|
||||
@if(can_manage_item)
|
||||
<div class="info-expiry-edit-controls" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<select id="info-expiry-select" class="info-expiry-select" style="padding: 2px 6px; font-size: 0.85em; background: rgba(0,0,0,0.4); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px;" data-item-id="{{ item.id }}">
|
||||
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent (Never)</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 id="info-set-expiry-btn" class="btn-secondary btn-sm" style="padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}"><i class="fa-solid fa-check"></i> Save</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
|
||||
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
@@ -326,18 +272,18 @@
|
||||
@if(item.checksum)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||
<td><pre class="info-hash-codeblock"><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></pre></td>
|
||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(item.show_repost_row)
|
||||
@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.slug || rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.slug || rp.id }}</a>
|
||||
<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.slug || rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.slug || rp.id }}</a>
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
</td>
|
||||
@@ -360,50 +306,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
@if(can_manage_item)
|
||||
<button class="btn-secondary" id="info-rethumb-btn" data-item-id="{{ item.id }}"><i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail</button>
|
||||
@endif
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 420px;">
|
||||
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
|
||||
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
||||
<form id="visibility-form" onsubmit="return false;">
|
||||
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
||||
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
||||
</div>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<div class="_204863">
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
@@ -119,25 +119,20 @@
|
||||
</div>
|
||||
<div class="blahlol">
|
||||
<span class="badge badge-dark">
|
||||
<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>
|
||||
<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>
|
||||
</span>@if(halls_enabled && item.primaryHall) — @endif
|
||||
@if(halls_enabled && item.primaryHall)
|
||||
<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
|
||||
</span> —
|
||||
@endif
|
||||
<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
|
||||
|
||||
|
||||
<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>
|
||||
<div class="gapRight">
|
||||
@if(session)
|
||||
@if(user_has_favorited)
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
|
||||
@else
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
|
||||
@endif
|
||||
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
||||
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
|
||||
@@ -162,7 +157,7 @@
|
||||
</div>
|
||||
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
|
||||
@each(item.favorites as fav)
|
||||
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
|
||||
@if(fav.hide_fav_badge)
|
||||
<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
|
||||
<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>
|
||||
@@ -202,58 +197,6 @@
|
||||
<td>{!! item.title !!}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr class="info-id-row">
|
||||
<th>ID</th>
|
||||
<td><code>{{ item.id }}</code></td>
|
||||
</tr>
|
||||
<tr class="info-visibility-row">
|
||||
<th>Visibility</th>
|
||||
<td>
|
||||
<span id="info-visibility-label" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
@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 id="info-visibility-edit-btn" class="btn-secondary btn-sm" style="margin-left: 8px; padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}"><i class="fa-solid fa-pen"></i> Edit</button>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<tr class="info-expiry-row">
|
||||
<th>Expires</th>
|
||||
<td>
|
||||
<div class="info-expiry-wrap" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
<span id="info-expiry-label" style="display: inline-flex; align-items: center; gap: 6px; color: #ffaa00;" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||
@if(item.expires_at)
|
||||
<i class="fa-solid fa-clock"></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> Never
|
||||
@endif
|
||||
</span>
|
||||
@if(can_manage_item)
|
||||
<div class="info-expiry-edit-controls" style="display: inline-flex; align-items: center; gap: 6px;">
|
||||
<select id="info-expiry-select" class="info-expiry-select" style="padding: 2px 6px; font-size: 0.85em; background: rgba(0,0,0,0.4); color: #fff; border: 1px solid rgba(255,255,255,0.2); border-radius: 4px;" data-item-id="{{ item.id }}">
|
||||
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent (Never)</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 id="info-set-expiry-btn" class="btn-secondary btn-sm" style="padding: 2px 8px; font-size: 0.8em;" data-item-id="{{ item.id }}"><i class="fa-solid fa-check"></i> Save</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
|
||||
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
@@ -271,18 +214,18 @@
|
||||
@if(item.checksum)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||
<td><pre class="info-hash-codeblock"><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></pre></td>
|
||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(item.show_repost_row)
|
||||
@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.slug || rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.slug || rp.id }}</a>
|
||||
<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.slug || rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.slug || rp.id }}</a>
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
</td>
|
||||
@@ -305,50 +248,7 @@
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
@if(can_manage_item)
|
||||
<button class="btn-secondary" id="info-rethumb-btn" data-item-id="{{ item.id }}"><i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail</button>
|
||||
@endif
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="visibility-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 420px;">
|
||||
<div class="modal-header" style="margin-bottom: 15px; border-bottom: 1px solid var(--border-color, #333); padding-bottom: 10px;">
|
||||
<h3 style="margin: 0; font-size: 1.1em; color: var(--text-color, #fff);"><i class="fa-solid fa-eye"></i> Change Visibility</h3>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 10px 0; text-align: left;">
|
||||
<form id="visibility-form" onsubmit="return false;">
|
||||
<input type="hidden" id="visibility-item-id" value="{{ item.id }}" />
|
||||
<div class="visibility-options" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||
<input type="radio" name="visibility" value="0" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-globe"></i> Public</strong>
|
||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Visible to everyone in main feeds, search, and rankings.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||
<input type="radio" name="visibility" value="1" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-link"></i> Unlisted</strong>
|
||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Hidden from main feeds; accessible only via direct link or slug.</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="visibility-option" style="display: flex; align-items: flex-start; gap: 12px; padding: 12px; border: 1px solid var(--nav-border-color, #444); border-radius: 6px; cursor: pointer; background: var(--bg-secondary, rgba(255,255,255,0.03));">
|
||||
<input type="radio" name="visibility" value="2" style="margin-top: 3px;" />
|
||||
<div>
|
||||
<strong style="display: block; color: var(--text-color, #fff);"><i class="fa-solid fa-lock"></i> Private</strong>
|
||||
<span style="font-size: 0.85em; color: var(--text-muted, #aaa);">Only visible to you and site moderators.</span>
|
||||
</div>
|
||||
</label>
|
||||
</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>
|
||||
@@ -94,7 +94,7 @@
|
||||
<tbody>
|
||||
@each(favotop as favo)
|
||||
<tr>
|
||||
<td><a href="/{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}">#{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}</a></td>
|
||||
<td><a href="/{{ favo.item_id }}">#{{ favo.item_id }}</a></td>
|
||||
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
|
||||
</tr>
|
||||
@endeach
|
||||
@@ -109,7 +109,7 @@
|
||||
<tbody>
|
||||
@each(xdtop as item)
|
||||
<tr>
|
||||
<td><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a></td>
|
||||
<td><a href="/{{ item.id }}">#{{ item.id }}</a></td>
|
||||
<td>
|
||||
<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>
|
||||
|
||||
@@ -334,19 +334,6 @@
|
||||
<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: 15px;">
|
||||
<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: 200px; padding: 5px; 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
|
||||
<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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
|
||||
|
||||
<head>
|
||||
@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
|
||||
@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
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0096ff">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -56,6 +56,7 @@
|
||||
@if(!private_society || session)
|
||||
@if(development || (private_society && !session))
|
||||
<link rel="stylesheet" href="/s/css/f0ckm.css?v={{ ts }}">
|
||||
<link rel="stylesheet" href="/s/css/98.css?v={{ ts }}">
|
||||
@endif
|
||||
@if(development)
|
||||
<link rel="stylesheet" href="/s/css/v0ck.css?v={{ ts }}">
|
||||
@@ -72,18 +73,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
@if(typeof item !== 'undefined')
|
||||
<link rel="canonical" href="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<link rel="canonical" href="https://{{ domain }}/{{ item.id }}" />
|
||||
<meta property="og:site_name" content="{{ domain }}" />
|
||||
<meta property="og:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="og:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="og:title" content="{{ item.id }}" />
|
||||
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||
<meta name="description" content="{{ site_description }}" />
|
||||
<meta property="og:description" content="{{ site_description }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="twitter:card" content="summary" />
|
||||
<meta property="twitter:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="twitter:title" content="{{ item.id }}" />
|
||||
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||
<meta property="twitter:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="twitter:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||
@else
|
||||
<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" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@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 }}">
|
||||
<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="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
@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;">
|
||||
@else
|
||||
<span class="f0ck">{{ custom_navbar_brand_text || domain }}</span>
|
||||
<span class="f0ck">{{ custom_navbar_brand_text || domain.split('.')[0] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@each(notifications as n)
|
||||
@if(n.type === 'approve')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ 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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_id) }}</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_id) }}</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -33,46 +33,46 @@
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'deny')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ 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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||
<div class="notif-msg">
|
||||
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_slug || n.item_id) }}</strong>
|
||||
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_id) }}</strong>
|
||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
||||
</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'item_deleted')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ 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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
|
||||
<div class="notif-msg">
|
||||
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_slug || n.item_id) }}</strong>
|
||||
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_id) }}</strong>
|
||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
||||
</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'upload_comment')
|
||||
<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 }}">
|
||||
<a href="/{{ 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 || '' }}">
|
||||
<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 class="notif-content">
|
||||
<div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div>
|
||||
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_id) }}</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'upload_success')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ 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 || '' }}">
|
||||
<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>
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'upload_error')
|
||||
<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 }}">
|
||||
<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 }}">
|
||||
@if(n.item_id)
|
||||
<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'};" />
|
||||
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<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 }}">
|
||||
<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 }}">
|
||||
@if(n.item_id)
|
||||
<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'" />
|
||||
@@ -125,9 +125,9 @@
|
||||
<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-msg">
|
||||
@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_slug || n.item_id) }}
|
||||
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_slug || n.item_id) }}
|
||||
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_id) }}
|
||||
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_id) }}
|
||||
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_id) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@each(items as item)
|
||||
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
|
||||
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="sub-link">
|
||||
<a href="/{{ item.id }}" class="sub-link">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<img src="{{ item.thumb }}" loading="lazy" />
|
||||
<div class="sub-info">
|
||||
<span class="sub-id">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</span>
|
||||
<span class="sub-id">#{{ item.id }}</span>
|
||||
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -79,41 +79,6 @@
|
||||
</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">
|
||||
<label>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
@if(count.f0cks)
|
||||
<div class="posts no-infinite-scroll">
|
||||
@each(f0cks.items as item)
|
||||
<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">
|
||||
<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">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
@@ -156,7 +156,7 @@
|
||||
@if(count.favs)
|
||||
<div class="posts no-infinite-scroll">
|
||||
@each(favs.items as item)
|
||||
<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">
|
||||
<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">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
|
||||
Reference in New Issue
Block a user