9 Commits

Author SHA1 Message Date
6856f4109e g 2026-07-18 12:31:05 +02:00
4fa0ed7e58 f0ckv2 specifics... 2026-07-18 12:27:52 +02:00
2a5f8288d2 f0ckv2 specifics 2026-07-18 12:22:51 +02:00
a8af6427eb Merge pull request 'master' (#18) from master into f0ckv2
Reviewed-on: #18
2026-07-17 23:34:12 +00:00
b26b7518cb Merge pull request 'pipi kaka' (#17) from master into f0ckv2
Reviewed-on: #17
2026-07-17 22:33:32 +00:00
ce03f9d17f Merge pull request 'master' (#16) from master into f0ckv2
Reviewed-on: #16
2026-07-17 22:23:15 +00:00
87905dd547 Merge pull request 'master' (#15) from master into f0ckv2
Reviewed-on: #15
2026-07-17 21:53:32 +00:00
18a871976c ffff 2026-07-17 22:59:53 +02:00
d09cb44fa7 f0ckv2 things 2026-07-17 22:06:56 +02:00
86 changed files with 1055 additions and 3861 deletions

View File

@@ -1,9 +1,9 @@
POSTGRES_USER=f0ckm
POSTGRES_DB=f0ckm
POSTGRES_PASSWORD=f0ckm
# --- Nginx & Tor Profiles (Optional) ---
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
# COMPOSE_PROFILES=tor
# --- Nginx & Let's Encrypt Configuration (Optional) ---
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
# COMPOSE_PROFILES=f0ckm-nginx
#
# VIRTUAL_HOST=yourdomain.com
# LETSENCRYPT_HOST=yourdomain.com

1
.gitignore vendored
View File

@@ -18,4 +18,3 @@ public/tag_cache
config.json
bundle.css
public/s/fonts/impact.woff
f0ck-svelte/*

View File

@@ -78,23 +78,3 @@ now visit http://localhost:1337 in your browser, you can develop without needing
## NGINX
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
## Tor Hidden Service (.onion)
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
```bash
cat ./f0ckm-data/tor/f0ckm_hs/hostname
```
### Automatic Onion-Location Header
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
```json
"main": {
"onion": "http://yourgeneratedaddress.onion"
}
```

View File

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

View File

@@ -1,9 +0,0 @@
# Tor configuration for f0ckm
# Enable SOCKS proxy for internal container & external connections
SocksPort 0.0.0.0:9050
# Tor Hidden Service (v3 Onion Service) configuration
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
HiddenServiceDir /var/lib/tor/f0ckm_hs/
HiddenServicePort 80 f0ckm:1337

View File

@@ -5,7 +5,6 @@
"domain": "example.com",
"regex": "example\\.com"
},
"onion": "http://your-onion-address.onion",
"socks": "socks5://127.0.0.1:9050",
"mail": "admin@example.com",
"maxfilesize": 104857600,
@@ -29,11 +28,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",
@@ -59,15 +53,12 @@
"amoled"
],
"theme": "amoled",
"default_font": "",
"default_layout": "legacy",
"custom_favicon": "/s/img/favicon.gif",
"custom_brand_image": [],
"custom_navbar_brand_text": "",
"show_koepfe": false,
"hide_sidebar_default": true,
"koepfe": [],
"enable_tor_hs": true,
"enable_global_chat": true,
"enable_danmaku": true,
"private_messages": true,
@@ -129,7 +120,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,
@@ -140,8 +130,6 @@
"open_registration_require_mail_andor_token": false,
"private_society": false,
"private_society_gate": "cloudflare",
"private_society_gate_location": "Frankfurt",
"private_society_gate_template": "_gate_template",
"public_nsfw": false,
"paths": {
"images": "/b",

View File

@@ -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:
@@ -50,14 +49,9 @@ services:
tor:
image: dockurr/tor
container_name: tor
profiles:
- tor
ports:
- "127.0.0.1:9050:9050"
- "127.0.0.1:8118:8118"
volumes:
- ./config/tor:/etc/tor:ro
- ./f0ckm-data/tor:/var/lib/tor:Z
- "9050:9050"
- "8118:8118"
networks:
- f0ckm-net
restart: always
@@ -103,17 +97,19 @@ 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:

View File

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

View File

@@ -1,5 +0,0 @@
-- Add favorites_private column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';

View File

@@ -1,5 +0,0 @@
-- Add hide_fav_badge column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';

View File

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

View File

@@ -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
--
@@ -1505,25 +1498,12 @@ CREATE TABLE public.user_options (
receive_user_notifications boolean DEFAULT true,
do_not_disturb boolean DEFAULT false,
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
force_comment_display_mode integer DEFAULT 0
);
ALTER TABLE public.user_options OWNER TO f0ckm;
COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
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 +2162,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 +2944,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

View File

@@ -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
View File

@@ -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"
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,9 +13,8 @@
/* Meme Creator Styles */
.meme-creator-container {
box-sizing: border-box !important;
padding: 20px;
max-width: 1100px;
max-width: 1200px;
width: 100%;
margin: 0 auto;
position: relative;
@@ -112,7 +111,6 @@
.canvas-wrapper {
flex: 1;
max-width: 650px;
min-width: 0; /* Allow shrinking */
background: #000;
border: 2px solid var(--nav-bg, #2b2b2b);
@@ -240,49 +238,22 @@ canvas#memeCanvas {
color: var(--white, #fff);
}
/* Mobile Stacking - Clean Flexbox Column Layout */
/* Mobile Stacking - Simple 2-Row Layout */
@media (max-width: 950px) {
.meme-editor-layout {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: 0.6fr auto !important;
gap: 20px;
}
.meme-controls {
box-sizing: border-box !important;
width: 100% !important;
max-width: 500px !important;
grid-row: 2;
overflow: visible !important;
}
.canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important;
max-width: 650px !important;
margin-bottom: 10px;
overflow: visible !important;
height: auto !important;
}
}
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
This prevents the canvas from shrinking too much when the sidebar takes space. */
@media (max-width: 1250px) {
body:not(.sidebar-right-hidden) .meme-editor-layout {
display: flex !important;
flex-direction: column !important;
align-items: center !important;
gap: 20px;
}
body:not(.sidebar-right-hidden) .meme-controls {
box-sizing: border-box !important;
width: 100% !important;
max-width: 500px !important;
overflow: visible !important;
}
body:not(.sidebar-right-hidden) .canvas-wrapper {
box-sizing: border-box !important;
width: 100% !important;
max-width: 650px !important;
grid-row: 1;
margin-bottom: 10px;
overflow: visible !important;
height: auto !important;
@@ -323,19 +294,20 @@ canvas#memeCanvas {
/* Sidebar space reservation for meme pages */
.meme-layout-wrapper {
box-sizing: border-box !important;
width: 100%;
transition: padding-right 0.3s ease-in-out;
}
@media (min-width: 1000px) {
@media (min-width: 1200px) {
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
.meme-layout-wrapper {
padding-right: 300px;
}
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0;
}
}
/* Collapse reserved space when sidebar is hidden */
body.sidebar-right-hidden .meme-layout-wrapper {
padding-right: 0 !important;
}

View File

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

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="#242526"/>
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
</svg>

Before

Width:  |  Height:  |  Size: 443 B

View File

@@ -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))
};
};
@@ -161,13 +155,7 @@
postid,
existingTags: tags,
anchorEl: anchor,
onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
renderTags
});
};

View File

@@ -54,7 +54,7 @@ class CommentSystem {
document.body.classList.remove('sidebar-left-hidden');
const layout = this.container.closest('.item-layout-container');
if (layout) layout.classList.remove('sidebar-hidden');
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
} else if (isHidden) {
this.container.classList.add('faded-out');
this.container.style.display = 'none';
document.body.classList.add('sidebar-left-hidden');
@@ -120,49 +120,24 @@ class CommentSystem {
if (!document.body.classList.contains('layout-legacy') && !document.body.classList.contains('legacy-view')) return;
const updateBtn = () => {
if (!this.initialLoadDone) return;
const btn = document.querySelector('.scroll-to-bottom');
if (!btn) return;
const container = document.getElementById('comments-container');
if (!container) return;
const currentlyAtBottom = btn.classList.contains('is-at-bottom');
let isAtBottom = currentlyAtBottom;
const rect = container.getBoundingClientRect();
// Detect if we are at the bottom of the comments section
// We also require window.scrollY > 50 to prevent flickering to 'scroll to top'
// when the page first loads and images haven't fully pushed the container down yet.
const isAtBottom = (rect.bottom < window.innerHeight + 100) && (window.scrollY > 50);
// Base the bottom state primarily on whether the input box / end-of-thread marker is visible
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder');
if (bottomElement) {
const bottomRect = bottomElement.getBoundingClientRect();
if (currentlyAtBottom) {
// We are pointing UP. Switch to DOWN if we scroll significantly UP away from bottomElement
if (bottomRect.top > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
// We are pointing DOWN. Switch to UP if bottomElement enters viewport or we scroll past it
if (bottomRect.top <= window.innerHeight + 50) {
isAtBottom = true;
}
}
} else {
// Fallback to container rect if no bottom element
const rect = container.getBoundingClientRect();
if (currentlyAtBottom) {
if (rect.bottom > window.innerHeight + 250) {
isAtBottom = false;
}
} else {
if (rect.bottom <= window.innerHeight + 50) {
isAtBottom = true;
}
}
}
if (isAtBottom && !currentlyAtBottom) {
if (isAtBottom) {
btn.classList.add('is-at-bottom');
btn.setAttribute('title', 'Scroll to top');
} else if (!isAtBottom && currentlyAtBottom) {
} else {
btn.classList.remove('is-at-bottom');
btn.setAttribute('title', 'Scroll to bottom');
}
@@ -596,7 +571,7 @@ class CommentSystem {
if (cached) cached.content = fullContent;
}
contentEl.dataset.raw = fullContent;
contentEl.dataset.raw = this.escapeHtml(fullContent);
contentEl.innerHTML = this.renderCommentContent(fullContent, commentId);
CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl);
@@ -622,7 +597,6 @@ class CommentSystem {
const contentEl = el.querySelector('.comment-content');
if (contentEl) {
contentEl.dataset.raw = data.content;
contentEl.innerHTML = this.renderCommentContent(data.content, data.comment_id);
CommentSystem.autoplayConvertedGifs(contentEl);
CommentSystem.playEmojiVideos(contentEl);
@@ -703,6 +677,7 @@ class CommentSystem {
this.render(comments, this.user, initialIsSubscribed);
this.initialLoadDone = true;
this.restoreState(state);
if (this.scrollHandler) this.scrollHandler();
this._loadDanmaku(comments);
if (scrollToId) {
@@ -773,6 +748,7 @@ class CommentSystem {
this.reconcile(data.comments, data.user_id, data.is_subscribed);
this.initialLoadDone = true;
this.restoreState(state);
if (this.scrollHandler) this.scrollHandler();
if (scrollToId) {
this.preservingScroll = false;
@@ -802,6 +778,7 @@ class CommentSystem {
this.render(data.comments, data.user_id, data.is_subscribed);
this.initialLoadDone = true;
this.restoreState(state);
if (this.scrollHandler) this.scrollHandler();
this._loadDanmaku(data.comments);
if (scrollToId) {
@@ -858,14 +835,26 @@ class CommentSystem {
* border-right) is fully restored after the effect completes.
*
* @param {Element} el - The .comment element to animate.
* @param {boolean} entering - Unused (kept for compatibility).
* @param {boolean} entering - True to also add 'comment-entering' (own post);
* false for 'new-item-fade' only (live others).
*/
static _animateNewComment(el, entering = false) {
if (!el) return;
el.classList.add('new-item-fade');
setTimeout(() => {
el.classList.remove('new-item-fade');
}, 2500);
const classes = entering
? ['comment-entering', 'new-item-fade']
: ['new-item-fade'];
// Reset in case the element already has a stale animation
classes.forEach(c => el.classList.remove(c));
void el.offsetWidth; // force reflow so re-adding the class restarts the animation
classes.forEach(c => el.classList.add(c));
const cleanup = () => {
classes.forEach(c => el.classList.remove(c));
};
el.addEventListener('animationend', cleanup, { once: true });
}
@@ -881,6 +870,7 @@ class CommentSystem {
if (el) {
// Remove highlight from any previously highlighted or newly-posted comment
document.querySelectorAll('.comment-highlighted').forEach(c => c.classList.remove('comment-highlighted'));
document.querySelectorAll('.comment-entering').forEach(c => c.classList.remove('comment-entering'));
document.querySelectorAll('.new-item-fade').forEach(c => c.classList.remove('new-item-fade'));
// Always smooth-scroll so there's no jarring jump
@@ -1026,7 +1016,7 @@ class CommentSystem {
preview.dataset.id = targetId;
// Remove temporary animation/highlight classes
preview.classList.remove('new-item-fade', 'comment-highlighted');
preview.classList.remove('new-item-fade', 'comment-highlighted', 'comment-entering');
// Remove input forms or action buttons
const actions = preview.querySelector('.comment-actions');
@@ -1140,7 +1130,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;
@@ -1161,13 +1150,12 @@ class CommentSystem {
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
const contentEl = body.querySelector('.comment-content');
if (contentEl) {
let rawText = (contentEl.dataset.raw || '').trim();
if (rawText.includes('&gt;') || rawText.includes('&lt;') || rawText.includes('&amp;')) {
const txt = document.createElement('textarea');
txt.innerHTML = rawText;
rawText = txt.value;
}
const lines = rawText.split('\n');
const LINE_MAX = 200;
const rawText = (contentEl.dataset.raw || '').trim();
// Preserve all lines but cap any single line exceeding LINE_MAX chars
const lines = rawText.split('\n').map(line =>
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
);
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
if (isNew) {
@@ -1622,25 +1610,17 @@ class CommentSystem {
syncLevel(roots, list, false);
}
// Maximum characters & lines to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 500; }
static get ITEM_VIEW_MAX_LINES() { return 6; }
// Maximum characters to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
renderCommentContent(content, commentId = null, bypassTruncation = false) {
if (!content) return '';
// Truncate long comments before any processing
// Truncate extremely long comments before any processing
let truncated = false;
if (!bypassTruncation) {
const lines = content.split('\n');
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
content = lines.slice(0, CommentSystem.ITEM_VIEW_MAX_LINES).join('\n');
truncated = true;
}
if (content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS);
truncated = true;
}
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
truncated = true;
}
if (typeof marked === 'undefined') {
@@ -1789,10 +1769,9 @@ class CommentSystem {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const renderedContent = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: quoteContent;
return `<span class="greentext">&gt;${renderedContent}</span>`;
}
@@ -2198,7 +2177,7 @@ class CommentSystem {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return div.innerHTML;
}
renderCommentAttachments(files, content = '') {
@@ -2406,50 +2385,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 +2803,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 +2811,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 +2821,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 +3144,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;
@@ -3386,15 +3314,9 @@ class CommentSystem {
params.append('has_poll', '1');
}
const csrfToken = window.f0ckSession?.csrf_token || '';
if (csrfToken) params.append('csrf_token', csrfToken);
const res = await fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
@@ -3660,9 +3582,9 @@ class CommentSystem {
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
// optimistic DOM insert to guarantee the button regardless of rendering path.
_ensureTruncationButton(commentEl, rawContent) {
if (!rawContent) return;
const lineCount = rawContent.split('\n').length;
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
console.log('[ensureBtn] below threshold, skipping');
return;
}
const contentEl = commentEl.querySelector('.comment-content');
@@ -3864,14 +3786,6 @@ class CommentSystem {
toggleComments() {
if (!this.container) return;
if (document.body.classList.contains('layout-modern')) {
if (window.innerWidth < 1000) return;
if (typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
return;
}
}
const layout = this.container.closest('.item-layout-container');
const sidebar = this.container.closest('.item-sidebar-left');
const siblings = sidebar ? [
@@ -4516,71 +4430,20 @@ class CommentSystem {
}, { passive: true });
};
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
let lastHoldTime = 0;
const addTabTouch = (btn, cb, onHold) => {
// Helper: reliable touch handling for tab buttons on mobile
const addTabTouch = (btn, cb) => {
let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// Build tab buttons — Favorites first
@@ -4592,13 +4455,23 @@ class CommentSystem {
favTab.innerHTML = '<i class="fa-solid fa-star" style="color:var(--emoji-fav-color,#f5c518);font-size:1.1em;"></i>';
favTab.addEventListener('mousedown', e => e.preventDefault());
favTab.addEventListener('click', () => showTab('__favs__'));
addTabTouch(favTab, () => showTab('__favs__'), () => toggleFavDefault(favTab));
addTabTouch(favTab, () => showTab('__favs__'));
// Right-click on fav tab → set/unset as default
favTab.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (Date.now() - lastHoldTime < 1000) return;
toggleFavDefault(favTab);
if (navigator.maxTouchPoints > 0) return;
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
});
// Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {

View File

@@ -46,19 +46,11 @@
return;
}
// Mark internal drags to prevent them from triggering the dropzone
window.addEventListener('dragstart', (e) => {
if (e.dataTransfer) {
e.dataTransfer.setData('application/x-f0ck-internal', 'true');
}
});
// Global Drag Events
window.addEventListener('dragenter', (e) => {
e.preventDefault();
if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
if (e.dataTransfer.types.includes('Files')) {
dragCounter++;
dropOverlay.classList.add('active');
}
@@ -67,17 +59,10 @@
window.addEventListener('dragover', (e) => {
e.preventDefault();
if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) {
// Change dropEffect to none to indicate this dropzone doesn't accept internal elements
e.dataTransfer.dropEffect = 'none';
}
});
window.addEventListener('dragleave', (e) => {
if (window.location.pathname === '/upload') return;
// dragleave doesn't always have reliable dataTransfer.types, but if it does, check it
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
@@ -87,8 +72,6 @@
window.addEventListener('drop', (e) => {
if (window.location.pathname === '/upload') return;
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
e.preventDefault();
dragCounter = 0;
dropOverlay.classList.remove('active');

File diff suppressed because it is too large Load Diff

View File

@@ -1976,73 +1976,6 @@ if (window.__dmLoaded) {
dmGridArea = document.createElement('div');
dmGridArea.className = 'emoji-picker-grid';
// Helper: reliable touch handling for tab buttons on mobile (supports hold-to-action)
let lastDmHoldTime = 0;
const addDmTabTouch = (btn, cb, onHold) => {
let ts = null;
let holdTimer = null;
let holdFired = false;
btn.addEventListener('touchstart', (e) => {
holdFired = false;
ts = { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (onHold) {
holdTimer = setTimeout(() => {
holdFired = true;
lastDmHoldTime = Date.now();
try { navigator.vibrate?.(40); } catch(err) {}
onHold();
}, 500);
}
e.stopPropagation();
}, { passive: true });
btn.addEventListener('touchmove', (e) => {
if (!ts) return;
const dx = Math.abs(e.touches[0].clientX - ts.x);
const dy = Math.abs(e.touches[0].clientY - ts.y);
if (dx > 10 || dy > 10) {
clearTimeout(holdTimer);
ts = null;
}
}, { passive: true });
btn.addEventListener('touchend', (e) => {
clearTimeout(holdTimer);
if (holdFired) {
e.preventDefault();
e.stopPropagation();
holdFired = false;
ts = null;
return;
}
if (!ts) return;
const dx = Math.abs(e.changedTouches[0].clientX - ts.x);
const dy = Math.abs(e.changedTouches[0].clientY - ts.y);
ts = null;
if (dx < 10 && dy < 10) { e.preventDefault(); cb(); }
});
btn.addEventListener('touchcancel', () => {
clearTimeout(holdTimer);
holdFired = false;
ts = null;
});
};
const toggleDmFavDefault = (tabEl) => {
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
tabEl.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
tabEl.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
};
// ── Favorites tab ──
const favsTab = document.createElement('button');
favsTab.className = 'ep-tab ep-tab-favs';
@@ -2052,13 +1985,22 @@ if (window.__dmLoaded) {
favsTab.innerHTML = '<i class="fa-solid fa-star" style="font-size:14px;color:var(--emoji-fav-color,#f5c518);"></i>';
favsTab.addEventListener('mousedown', e => e.preventDefault());
favsTab.addEventListener('click', () => showDmTab('__favs__', packs));
addDmTabTouch(favsTab, () => showDmTab('__favs__', packs), () => toggleDmFavDefault(favsTab));
// Right-click on fav tab → set/unset as default
favsTab.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (Date.now() - lastDmHoldTime < 1000) return;
toggleDmFavDefault(favsTab);
if (navigator.maxTouchPoints > 0) return;
const KEY = 'f0ck_emoji_default_tab';
const isDefault = localStorage.getItem(KEY) === '__favs__';
if (isDefault) {
localStorage.removeItem(KEY);
favsTab.classList.remove('ep-tab-default');
window._showEmojiToast?.('Favorites unset as default tab');
} else {
localStorage.setItem(KEY, '__favs__');
favsTab.classList.add('ep-tab-default');
window._showEmojiToast?.('Favorites set as default tab');
}
});
// Mark as default if already set
if (localStorage.getItem('f0ck_emoji_default_tab') === '__favs__') {
@@ -2086,7 +2028,6 @@ if (window.__dmLoaded) {
}
tab.addEventListener('mousedown', e => e.preventDefault());
tab.addEventListener('click', () => showDmTab(pack.id ?? null, packs));
addDmTabTouch(tab, () => showDmTab(pack.id ?? null, packs));
dmTabBar.appendChild(tab);
});

View File

@@ -1000,10 +1000,9 @@
e.stopPropagation();
const itemId = slide.dataset.id;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
method: 'DELETE',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
});
const data = await resp.json();
if (data.success) pill.remove();
@@ -1158,14 +1157,10 @@
if (nowFaved) flashFav(slide);
}
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/togglefav', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `postid=${id}`
});
const data = await resp.json();
// Sync count to server truth (handles race conditions)
@@ -1587,10 +1582,9 @@
e.stopPropagation();
const itemId = slide.dataset.id;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
method: 'DELETE',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
});
const data = await resp.json();
if (data.success) pill.remove();
@@ -1694,7 +1688,7 @@
avatar: '/a/default.png',
stamp: p.time,
timeago: timeAgo(p.time * 1000),
tags: ``,
tags: `4chan, /${data.board}/`,
is_video: isVideo,
is_image: isImage,
is_audio: false,
@@ -1856,17 +1850,16 @@
else if (item.external_board === 'gif') rating = 'nsfw';
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/scroller/rehost', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
'x-csrf-token': window.scrollerCsrf || ''
},
body: new URLSearchParams({
url: item.external_media_url || item.dest,
rating: rating,
tags: '',
tags: '4chan',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
...(item.original_filename ? { original_filename: item.original_filename } : {})
})
@@ -1973,10 +1966,9 @@
showShareToast(info.toast);
}
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/v2/tags/${id}/cycle-rating`, {
method: 'PUT',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
})
.then(r => r.json())
.then(data => {
@@ -2127,12 +2119,7 @@
const contentEl = commentEl.querySelector('.comment-content');
if (!contentEl) return;
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
if (raw.includes('&gt;') || raw.includes('&lt;') || raw.includes('&amp;')) {
const txt = document.createElement('textarea');
txt.innerHTML = raw;
raw = txt.value;
}
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
const lines = raw.split('\n');
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
@@ -2800,16 +2787,11 @@
if (!content || !commentsItemId || commentsPosting) return;
commentsPosting = true; commentSendBtn.disabled = true;
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
const resp = await fetch('/api/comments', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: postBody
});
const data = await resp.json();
@@ -2917,14 +2899,10 @@
if (addTagSendBtn) addTagSendBtn.disabled = true;
closeSugg();
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch(`/api/v2/tags/${targetId}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `tagname=${encodeURIComponent(tag)}`
});
const data = await resp.json();
if (data.success) {
@@ -3680,11 +3658,7 @@
if (sMarkAll) {
sMarkAll.addEventListener('click', async () => {
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
await fetch('/api/notifications/read', {
method: 'POST',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
await fetch('/api/notifications/read', { method: 'POST' });
updateScrollerNotifBadge(0);
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
updateScrollerTabBadges(sCachedNotifs);
@@ -3699,12 +3673,7 @@
if (!item) return;
const nid = item.dataset.id;
if (nid && item.classList.contains('unread')) {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
fetch(`/api/notifications/${nid}/read`, {
method: 'POST',
keepalive: true,
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
}).catch(() => {});
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
item.classList.remove('unread');
// Update cache
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));

View File

@@ -1119,97 +1119,6 @@
});
}
const favsPrivateToggle = document.getElementById('favorites_private_toggle');
if (favsPrivateToggle) {
favsPrivateToggle.addEventListener('change', async () => {
const favorites_private = favsPrivateToggle.checked;
try {
const res = await fetch('/api/v2/settings/favorites_private', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ favorites_private })
});
const data = await res.json();
if (data.success) {
showStatus('Favorites privacy preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.favorites_private = favorites_private;
}
} else {
alert(data.msg || 'Error saving preference');
favsPrivateToggle.checked = !favorites_private; // Revert
}
} catch (err) {
console.error('Update Favorites Privacy error:', err);
alert('Connection error');
favsPrivateToggle.checked = !favorites_private; // Revert
}
});
}
const hideFavBadgeToggle = document.getElementById('hide_fav_badge_toggle');
if (hideFavBadgeToggle) {
hideFavBadgeToggle.addEventListener('change', async function() {
const hide_fav_badge = hideFavBadgeToggle.checked;
try {
const res = await fetch('/api/v2/settings/hide_fav_badge', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ hide_fav_badge })
});
const data = await res.json();
if (data.success) {
showStatus('Hide favorite badge preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.hide_fav_badge = hide_fav_badge;
}
} else {
alert(data.msg || 'Error saving preference');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
} catch (err) {
console.error('Update Hide Fav Badge error:', err);
alert('Connection error');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
});
}
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) {

View File

@@ -47,7 +47,7 @@
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return div.innerHTML;
};
const playSidebarEmojiVideos = (container) => {
@@ -153,6 +153,12 @@
const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return '';
// Truncate extremely long comments before any processing to keep the sidebar
// fast and the DOM lean, regardless of markdown / regex complexity.
if (content.length > SIDEBAR_CONTENT_TRUNCATE) {
content = content.substring(0, SIDEBAR_CONTENT_TRUNCATE) + '\u2026';
}
if (typeof marked === 'undefined') {
return escapeHtml(content)
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
@@ -262,10 +268,9 @@
// Manual greentext handling — apply emoji if the user preference allows it
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: escapedQuote;
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: quoteContent;
return `<span class="greentext">&gt;${rendered}</span>`;
}
@@ -479,9 +484,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;
@@ -492,13 +496,13 @@
|| rawContent.split('\n').length > 2
|| (c.files && c.files.length > 0);
}
// Always force isLong if there are inline media tags that expand vertically or files are attached
if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
// Always force isLong if there are inline media tags that expand vertically
if (!isLong && (/\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
isLong = true;
}
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 +547,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'} &raquo;</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'} &raquo;</a>
</div>`;
}
@@ -583,7 +587,7 @@
const isExpanded = container.classList.contains('expanded');
const scrollHeight = inner.scrollHeight;
const clientHeight = inner.clientHeight;
const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
const hasUnloadedImages = Array.from(inner.querySelectorAll('img:not(.emoji), video')).some(
media => {
if (media.tagName === 'IMG') {
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
@@ -635,8 +639,8 @@
const attachMediaLoadListeners = (element) => {
// Only target images and videos inside comment-content-inner.
// Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
// Avatars, emojis, and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img:not(.emoji), .comment-content-inner video').forEach(media => {
if (media.dataset.loadListenerBound) return;
media.dataset.loadListenerBound = 'true';

View File

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

View File

@@ -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))
};
};
@@ -126,13 +120,7 @@
postid,
existingTags: tags,
anchorEl: anchor,
onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
renderTags
});
};

View File

@@ -306,12 +306,7 @@ if (!window.UserCommentSystem) {
const trimmed = line.trimStart();
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`;
return `<span class="greentext">&gt;${quoteContent}</span>`;
}
// Per-line limit
@@ -410,12 +405,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() {
@@ -456,7 +450,7 @@ if (!window.UserCommentSystem) {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return div.innerHTML;
}
}
}

View File

@@ -5,33 +5,33 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<div class="v0ck_progress_filled"></div>
<div class="v0ck_seek_marker"></div>
</div>
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play" tabindex="-1">
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play">
<svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_play" href="${svg}#play"></use>
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
</svg>
</button>
<div class="v0ck_volume_group">
<button class="v0ck_player_button v0ck_volume" tabindex="-1">
<button class="v0ck_player_button v0ck_volume">
<svg style="width: 20px; height: 20px;">
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
</svg>
</button>
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" tabindex="-1" />
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" />
</div>
<button class="v0ck_player_button v0ck_playtime" tabindex="-1">00:00 / 00:00</button>
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button>
<span style="flex: 30"></span>
<div class="v0ck_settings_container">
<button class="v0ck_player_button v0ck_settings_btn" title="Settings" tabindex="-1">
<button class="v0ck_player_button v0ck_settings_btn" title="Settings">
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>
</button>
<div class="v0ck_settings_menu v0ck_hidden">
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">SWF</button>
<div class="v0ck_menu_item v0ck_bg_row">
<span class="v0ck_switch_label">Background</span>
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
@@ -44,11 +44,11 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
<span class="v0ck_switch_label">Danmaku</span>
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
</div>
<button id="v0ck_download" class="v0ck_menu_item" title="Download File" tabindex="-1">Download${size ? ` (${size})` : ''}</button>
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button>
</div>
</div>
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen" tabindex="-1">
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen">
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
</button>
</div>

View File

@@ -30,7 +30,7 @@ const resolvePath = (defaultRel) => {
const absStorage = path.resolve(storage);
if (defaultRel.startsWith('public/')) {
const sub = defaultRel.replace('public/', '');
if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') {
if (sub === 's/emojis' || sub === 's/koepfe') {
const storagePath = path.join(absStorage, sub.split('/').pop());
if (fs.existsSync(storagePath)) return path.resolve(storagePath);
return local;
@@ -51,7 +51,6 @@ config.paths = {
s: path.join(base, 'public/s'),
emojis: resolvePath('public/s/emojis'),
koepfe: resolvePath('public/s/koepfe'),
fonts: resolvePath('public/s/fonts'),
memes: resolvePath('public/memes'),
e: resolvePath('e'),
pending: resolvePath('pending'),

View File

@@ -38,16 +38,6 @@ export default new class {
.replace(/'/g, "&#039;");
}
generateSlug(length = 11) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
let slug = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
slug += chars[bytes[i] % chars.length];
}
return slug;
}
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
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");
};
@@ -165,7 +132,6 @@ export default new class {
// Build suffix with query params
let suffix = env.strict ? '?strict=1' : '';
if (env.tagger) suffix += (suffix ? '&' : '?') + `tagger=${encodeURIComponent(env.tagger)}`;
// mainDisplay: decoded for human-readable display (e.g. div.location)
// main: keeps percent-encoding for use in href attributes
@@ -493,15 +459,4 @@ export default new class {
console.error(`[ERROR REF ${errId}] ${context}:`, err);
return `Internal Error. Reference: ${errId}`;
}
isOnionRequest(req) {
if (!req || !req.headers) return false;
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'] || req.headers['x-forwarded-server'] || '';
if (!rawHost) return false;
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
const firstHost = hostStr.split(',')[0].trim();
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort.endsWith('.onion');
}
};

View File

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

View File

@@ -165,12 +165,6 @@
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
"favorites_private": "Private Favoriten",
"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",
@@ -541,7 +535,6 @@
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
"no_uploads": "Keine Uploads gefunden",
"no_favs": "Keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:",
@@ -801,12 +794,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."
}
}

View File

@@ -165,12 +165,6 @@
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
"disable_swiping": "Disable Swiping",
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
"favorites_private": "Private Favorites",
"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",
@@ -545,7 +539,6 @@
"subscribe_uploads_btn": "Subscribe user to uploads",
"no_uploads": "no uploads found",
"no_favs": "no favorites",
"private_favorites": "private favorites",
"back_to_profile": "Back to Profile",
"ban_modal_title": "Ban User",
"ban_modal_reason": "Reason:",
@@ -803,12 +796,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."
}
}

View File

@@ -165,10 +165,6 @@
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
"disable_swiping": "Swipen uitschakelen",
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
"favorites_private": "Privé favorieten",
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
"hide_fav_badge": "Favoriet-badge avatar verbergen",
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
"enable_bg_blur": "Achtergrondvervaging inschakelen",
@@ -539,7 +535,6 @@
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
"no_uploads": "geen uploads gevonden",
"no_favs": "geen favorieten",
"private_favorites": "privé favorieten",
"back_to_profile": "Terug naar Profiel",
"ban_modal_title": "Gebruiker Bannen",
"ban_modal_reason": "Reden:",

View File

@@ -165,10 +165,6 @@
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
"favorites_private": "Private Favoriten",
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -540,7 +536,6 @@
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
"no_uploads": "keine Aufladierungen gefunden",
"no_favs": "keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:",

View File

@@ -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);
@@ -35,7 +18,7 @@ const flashMimes = Object.entries(cfg.mimes || {}).filter(([, ext]) => ext === '
const COUNT_CACHE_TTL_MS = 90_000;
const countCache = new Map(); // key → { total, expiresAt }
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) {
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj }) {
return JSON.stringify([
modequery,
tag ?? '',
@@ -47,8 +30,7 @@ function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, ex
excludedTags.slice().sort().join(','),
newerThan ?? '',
minXd,
userHallObj?.id ?? '',
tagger ?? ''
userHallObj?.id ?? ''
]);
}
@@ -153,60 +135,8 @@ const xdScoreMeta = (score) => {
return { tier: 5, label: 'xDDDDD+' };
};
async function checkFavoritesAccess(rawUser, { session, user_id, is_admin } = {}) {
if (!rawUser) return { isPrivate: false, isAllowed: true };
const decodedUser = decodeURI(rawUser);
const targetUserRows = await db`
select u.id, u."user", u.admin, uo.favorites_private
from "user" u
left join user_options uo on uo.user_id = u.id
where u."user" ilike ${decodedUser} or u.login ilike ${decodedUser}
limit 1
`;
if (!targetUserRows.length || !targetUserRows[0].favorites_private) {
return { isPrivate: false, isAllowed: true };
}
const targetUser = targetUserRows[0];
let reqUserId = user_id;
let reqIsAdmin = is_admin;
if (typeof session === 'object' && session !== null) {
if (reqUserId === undefined) reqUserId = session.id;
if (reqIsAdmin === undefined) reqIsAdmin = !!session.admin;
}
let isAllowed = false;
if (reqUserId) {
if (+reqUserId === +targetUser.id) {
isAllowed = true;
} else if (reqIsAdmin === true) {
isAllowed = true;
} else if (reqIsAdmin === undefined) {
const reqUserRows = await db`select admin from "user" where id = ${+reqUserId} limit 1`;
if (reqUserRows.length > 0 && reqUserRows[0].admin) {
isAllowed = true;
}
}
}
return { isPrivate: true, isAllowed };
}
export default {
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, is_admin, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore, tagger: rawTagger } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
getf0cks: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, page, mode, ratings, fav, session, limit, strict, newer, exclude, user_id, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, minXdScore } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
@@ -259,26 +189,10 @@ 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 tagger = rawTagger ? lib.escapeLike(rawTagger) : null;
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner };
// 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``;
@@ -286,21 +200,6 @@ export default {
if (isTitleSearch && titleQuery) {
// Title search: match items.title ILIKE '%query%'
titleFilter = db`and items.title ILIKE ${'%' + titleQuery + '%'} and items.title IS NOT NULL`;
} else if (tagger && tag) {
// Tagger+tag filter: items where the specific user applied this specific tag
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
const conditions = terms.map(term => {
return db`and items.id in (
select ta.item_id from tags_assign ta
join tags t on t.id = ta.tag_id
join "user" u on u.id = ta.user_id
where t.normalized like '%' || slugify(${term}) || '%'
and u.user ilike ${tagger}
)`;
});
tagFilter = db`${conditions}`;
}
} else if (tag) {
const terms = tag.split(',').map(t => t.trim()).filter(Boolean);
if (terms.length > 0) {
@@ -334,10 +233,7 @@ 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 });
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj });
let total = getCachedCount(cacheKey);
if (total === null) {
@@ -348,7 +244,6 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -357,6 +252,7 @@ export default {
${hallFilter}
${userHallFilter}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
@@ -391,7 +287,6 @@ export default {
where
${db.unsafe(modequery)}
and items.active = true
${visibilityFilter}
${tagFilter}
${titleFilter}
${fav ? db`and fav_u.user ilike ${user}` : db``}
@@ -400,6 +295,7 @@ export default {
${hallFilter}
${userHallFilter}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
${newerThan ? db`and items.id > ${newerThan}` : db``}
${xdFilter}
@@ -422,8 +318,6 @@ export default {
const rows = (await db`
select
items.id,
items.slug,
items.visibility,
items.mime,
items.dest,
items.username as username,
@@ -480,7 +374,7 @@ export default {
for (let i = Math.max(1, act_page - range); i <= Math.min(act_page + range, pages); i++)
cheat.push(i);
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict, tagger });
const link = lib.genLink({ user, tag, hall: hallObj ? hallObj.slug : hall, mime, type: fav ? 'favs' : 'uploads', path: 'p/', strict: strict });
// Override link for title searches — pagination must use the /tag/title:... prefix
if (isTitleSearch && titleQuery) {
@@ -519,18 +413,7 @@ export default {
view_mode: fav ? 'favs' : 'uploads'
};
},
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, is_admin, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return {
success: false,
is_private: true,
message: "private favorites"
};
}
}
getf0ck: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, itemid: rawItemid, mode, ratings, session, strict, exclude, user_id, fav, random, userHall: rawUserHall, userHallOwner: rawUserHallOwner, lang } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
// --- title: prefix — search items.title instead of the tags table ---
@@ -560,17 +443,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 +457,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)";
}
} else if (!cfg.websrv.public_untagged && (mode === 2 || (Array.isArray(ratings) && ratings.length === 1 && ratings[0] === 'untagged'))) {
baseMode = "1 = 0";
}
const modequery = multiRatingSQL ?? lib.getMode(effMode);
if (itemid === null) {
return {
success: false,
message: "404 - upload not found"
};
}
const modequery = baseMode;
let tagFilter = db``;
let titleFilter = db``;
@@ -646,9 +510,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}
@@ -657,14 +518,17 @@ export default {
${!fav && user ? db`and items.username ilike ${user}` : db``}
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : 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``}
`;
};
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 +552,16 @@ 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``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : 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 +570,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() || cfg.websrv.public_untagged === false)) {
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,
@@ -758,6 +593,11 @@ export default {
}
};
}
}
return {
success: false,
message: "Sorry, this post is currently not visible."
};
}
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
@@ -765,11 +605,11 @@ export default {
// 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 +620,7 @@ export default {
where
${buildConditions()}
${whereClause}
group by items.id, items.slug
group by items.id
${orderBy}
limit ${limit}
`;
@@ -795,7 +635,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 +644,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``}
@@ -853,7 +692,7 @@ export default {
link.suffix = '';
}
const favorites = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name, "user_options".hide_fav_badge
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".username_color, "user_options".display_name
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
@@ -866,24 +705,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 +732,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 +755,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 +767,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 +784,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 +795,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 +853,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)],
@@ -1066,14 +871,7 @@ export default {
tmp
};
return data;
},
getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, user_id, is_admin, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
if (fav && rawUser) {
const { isPrivate, isAllowed } = await checkFavoritesAccess(rawUser, { session, user_id, is_admin });
if (isPrivate && !isAllowed) {
return null;
}
}
}, getRandom: async ({ user: rawUser, tag: rawTag, hall: rawHall, mime: rawMime, mode, ratings, fav, session, strict, exclude, userHall: rawUserHall, userHallOwner: rawUserHallOwner } = {}) => {
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
const hall = rawHall || null;
@@ -1114,22 +912,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,11 +925,11 @@ 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}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT 1
@@ -1165,9 +948,9 @@ 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``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
group by items.id
order by random()
limit 1
@@ -1205,12 +988,12 @@ 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``}
${mimeSQL}
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : 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``}
group by items.id, tags.tag
order by random()
@@ -1228,9 +1011,9 @@ 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``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
order by random()
limit 1
@@ -1245,8 +1028,9 @@ export default {
${db.unsafe(modequery)}
and uha.hall_id = ${userHallId}
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``}
${!session && cfg.websrv.public_untagged === false ? db`and exists (select 1 from tags_assign where item_id = items.id)` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
order by random()
limit 1
@@ -1254,9 +1038,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,9 +1056,9 @@ 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``}
${!session && cfg.websrv.public_untagged === false ? db`AND EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id)` : 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``}
${!useTagIdOpt ? db`AND ${db.unsafe(globalModeQuery)}` : db``}
ORDER BY random()
@@ -1298,8 +1082,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 +1095,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 +1238,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 +1250,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);
}

View File

@@ -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' });
@@ -650,78 +650,7 @@ 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})`;
@@ -1509,8 +1428,8 @@ export default (router, tpl) => {
const userId = newUser[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
`;
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {

View File

@@ -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);
@@ -219,33 +218,18 @@ export default (router, tpl) => {
mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
session: !!req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
fav: query.fav === 'true',
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
newer: query.newer || null,
minXdScore: req.session?.min_xd_score || 0,
tagger: query.tagger || null
minXdScore: req.session?.min_xd_score || 0
});
if (!data.success) {
if (data.is_private) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const privateHtml = `<div class="private-favs-msg" style="padding: 30px; text-align: center; color: var(--text-muted); font-size: 1.1em;">${tErr('profile.private_favorites')}</div>`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
is_private: true,
html: privateHtml,
hasMore: false
})
});
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@@ -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,
next: next[0]?.id ?? null,
prev: prev[0]?.id ?? null
...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`
@@ -1124,7 +1066,7 @@ export default router => {
}
const favs = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
@@ -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);

View File

@@ -315,68 +315,6 @@ export default router => {
}
});
// Update Favorites Privacy preference
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
try {
await db`
update user_options
set favorites_private = ${favorites_private}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.favorites_private = favorites_private;
return res.json({ success: true, favorites_private }, 200);
} catch (e) {
console.error('Update Favorites Privacy pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Hide Fav Badge preference
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
try {
await db`
update user_options
set hide_fav_badge = ${hide_fav_badge}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
return res.json({ success: true, hide_fav_badge }, 200);
} catch (e) {
console.error('Update Hide Fav Badge pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// 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;
@@ -548,7 +486,7 @@ export default router => {
// F-023 Security: Validate font against actual files on disk
// The font value is rendered into CSS url() in header.html, so it must be a real filename
if (font) {
const fontsDir = cfg.paths.fonts;
const fontsDir = path.join(path.resolve(), 'public/s/fonts');
try {
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
if (!available.includes(font)) {

View File

@@ -25,8 +25,7 @@ export default router => {
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
if (!req.params.postid || !req.post.tagname) {
return res.json({
success: false,
msg: 'missing postid or tag'
@@ -34,7 +33,7 @@ export default router => {
}
const postid = +req.params.postid;
const tagname = rawTagname.trim();
const tagname = req.post.tagname?.trim();
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
if (protectedTags.includes(tagname.toLowerCase())) {

View File

@@ -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) => {
@@ -402,16 +336,7 @@ export default router => {
}
}
let url = inputUrl.trim();
try {
const parsed = new URL(url);
if (parsed.searchParams.has('igsh')) {
parsed.searchParams.delete('igsh');
url = parsed.toString();
}
} catch (e) {
// Ignore malformed URL, keep original string
}
const url = inputUrl.trim();
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = url.match(ytRegex);
@@ -435,11 +360,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 +371,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 +428,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 +665,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 +677,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 +716,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 +733,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');

View File

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

View File

@@ -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,30 +609,29 @@ 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) {
const activityIsLong = content.length > 120
|| content.split('\n').length > 2
|| activityFiles.length > 0
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(content);
db.notify('activity', JSON.stringify({
user_id: req.session.id,
item_id: item_id,
type: 'comment',
body: notifyBody,
id: commentId,
item_rating_class: ratingClass,
item_rating_label: ratingLabel,
avatar: req.session.avatar,
avatar_file: req.session.avatar_file,
username: req.session.user,
username_color: req.session.username_color,
display_name: req.session.display_name || null,
files: activityFiles,
is_long: activityIsLong
}));
}
// 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
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(content);
db.notify('activity', JSON.stringify({
user_id: req.session.id,
item_id: item_id,
type: 'comment',
body: notifyBody,
id: commentId,
item_rating_class: ratingClass,
item_rating_label: ratingLabel,
avatar: req.session.avatar,
avatar_file: req.session.avatar_file,
username: req.session.user,
username_color: req.session.username_color,
display_name: req.session.display_name || null,
files: activityFiles,
is_long: activityIsLong
}));
// Automatically subscribe user to the thread
const subResult = await db`
@@ -979,6 +972,15 @@ export default (router, tpl) => {
// Recent Activity Page
router.get(/\/activity\/?/, async (req, res) => {
if (!req.session && cfg.main.hide_comments_from_public) {
if (req.url.qs?.json === 'true' || req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, comments: [], page: 1, hasMore: false, require_login: true })
});
}
return res.reply({ code: 403, body: "Forbidden" });
}
try {
const page = +(req.url.qs?.page || 1);
const limit = Math.min(+(req.url.qs?.limit || 50), 50);
@@ -1002,8 +1004,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 +1012,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 +1028,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 +1036,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`

View File

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

View File

@@ -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
});
@@ -99,9 +99,8 @@ export default (router, tpl) => {
ratings: ratingsArr,
mime: mime,
fav: true,
session: req.session,
session: !!req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
random: isRandom
});
if (favs && 'items' in favs) {
@@ -237,36 +236,20 @@ export default (router, tpl) => {
fav: req.params.mode == 'favs',
mode: req.mode,
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
session: req.session,
session: !!req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
url: decodeURIComponent(req.url.pathname || req.url),
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
explicitStrict: !!(req.query?.strict || req.url.qs?.strict),
random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)),
lang: req.lang,
tagger: req.url.qs?.tagger || null
lang: req.lang
});
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) {
if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({
code: 403,
body: tpl.render('error', {
message: tErr('profile.private_favorites'),
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false,
error_filter_hint: null,
error_filter_hint_link: null
}, req)
});
}
// For index/grid views with zero items (empty DB), render an empty grid instead of error
if (mode !== 'item') {
data.items = [];
@@ -390,8 +373,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 +397,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) => {

View File

@@ -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,69 +156,65 @@ 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');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
}
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
}
}

View File

@@ -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,13 +138,13 @@ 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';
}
// Broadcast to ALL connected clients
// Broadcast to ALL connected clients (except guests if comments are hidden)
for (const client of clients) {
if (!client.userId && cfg.main.hide_comments_from_public) continue;
client.send({ type: 'activity', data });
}
} catch (e) {
@@ -213,7 +211,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 +368,7 @@ export default (router, tpl) => {
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
const notifications = typeFilter
? await db`
SELECT n.id, n.type, n.item_id, 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 +393,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 +427,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 +444,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 +481,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({

View File

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

View File

@@ -48,7 +48,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() }, req)
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
});
}
@@ -58,7 +58,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() }, req)
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
});
};
@@ -67,7 +67,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() }, req)
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
});
};
@@ -88,9 +88,8 @@ export default (router, tpl) => {
return renderError("Passwords do not match.");
}
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
const isOnion = lib.isOnionRequest(req);
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
// reCAPTCHA verification
if (cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try {
@@ -168,8 +167,8 @@ export default (router, tpl) => {
const avatarFile = 'default.png';
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
`;
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);

View File

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

View File

@@ -23,11 +23,6 @@ export default (router, tpl) => {
route: /^\/s\/koepfe\//
});
router.static({
dir: cfg.paths.fonts,
route: /^\/s\/fonts\//
});
router.static({
dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'),
route: /^\/s\/ruffle\//

View File

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

View File

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

View File

@@ -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}
@@ -113,7 +95,7 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
return res.json({
success: true,
html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE
});
@@ -146,7 +128,6 @@ export default (router, tpl) => {
phrase,
tmp: null,
hidePagination: true,
user: req.session?.user ? { user: req.session.user } : null,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'Tags',

View File

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

View File

@@ -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}
@@ -138,7 +120,7 @@ export default (router, tpl) => {
return res.json({
success: true,
html: tpl.render('tag-cards', { toptags: tags, user: { user: userName }, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page,
pagination: paginationHtml,
hasMore: tags.length === TAGS_PER_PAGE

View File

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

View File

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

View File

@@ -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}`

View File

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

View File

@@ -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';
@@ -51,7 +49,7 @@ const gateOptions = {
},
cloudflare_status: {
status: 'ok',
location: cfg.websrv.private_society_gate_location || 'Frankfurt',
location: 'Frankfurt',
name: 'Cloudflare',
status_text: 'Working',
},
@@ -81,13 +79,11 @@ const nginx502Fallback = `<html>
</html>`;
// Login + Register modal injected before </body>
// Dynamic function so reCAPTCHA can be bypassed for .onion requests
// This string is built at startup so it can reference cfg.recaptcha values.
const _rcEnabled = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
const _rcSiteKey = (cfg.recaptcha && cfg.recaptcha.site_key) || '';
function getGateLoginInjection(req) {
const rcEnabled = _rcEnabled && !lib.isOnionRequest(req);
return `
const gateLoginInjection = `
<div id="hot-corner" style="position:fixed;bottom:0;left:0;width:20px;height:20px;z-index:9999;"></div>
<div id="gate-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.45);z-index:10000;align-items:center;justify-content:center;">
@@ -127,7 +123,7 @@ function getGateLoginInjection(req) {
<input type="text" name="token" placeholder="Invite token" autocomplete="off"
style="background:white;color:black;border:1px solid #bbb;padding:7px 10px;width:100%;box-sizing:border-box;font-size:14px;font-family:inherit;" />
<input type="text" name="email_confirm_field" style="display:none !important;" tabindex="-1" autocomplete="off" />
${rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
${_rcEnabled ? '<div id="gate-recaptcha" style="margin:4px 0;transform-origin:left top;"></div>' : ''}
<button type="submit" id="gate-register-btn" style="background:#0051c3;color:white;border:none;padding:9px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit;"
onmouseover="this.style.background='#003681'" onmouseout="if(!this.disabled)this.style.background='#0051c3'">Create account</button>
<p style="text-align:center;font-size:0.85em;margin:6px 0 0;color:#555;">
@@ -268,9 +264,8 @@ function getGateLoginInjection(req) {
if (_sb.length > 12) _sb = _sb.slice(-12);
});
</script>
${rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
${_rcEnabled ? '<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaGateReady&render=explicit" async defer><\/script>' : ''}
`;
}
// Text injected into the "What can I do?" section
@@ -287,40 +282,11 @@ try {
}
// Called on every gated request — produces a fresh page with unique Ray ID + timestamp
// Accepts an optional request object to inject the visitor's real IP into the footer reveal and dynamic host into page text/title.
// Accepts an optional request object to inject the visitor's real IP into the footer reveal.
function buildGatePage(req) {
if (!_cfRender) return nginx502Fallback;
let reqHost = null;
if (req && req.headers) {
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'];
if (rawHost) {
const firstHost = rawHost.split(',')[0].trim();
let extracted = '';
if (firstHost.startsWith('[')) {
const closeBracket = firstHost.indexOf(']');
extracted = closeBracket !== -1 ? firstHost.substring(1, closeBracket) : firstHost;
} else {
extracted = firstHost.split(':')[0].trim();
}
const isLocalHost = ['localhost', '127.0.0.1', '::1', 'f0ckm', '0.0.0.0', 'localhost.localdomain'].includes(extracted.toLowerCase());
if (extracted && !isLocalHost) {
reqHost = extracted;
}
}
}
if (!reqHost) reqHost = cfg.main.url.domain;
const currentGateOptions = {
...gateOptions,
host_status: {
...gateOptions.host_status,
location: reqHost,
},
what_can_i_do: gateSignInButton,
};
let html = _cfRender(currentGateOptions);
let html = _cfRender({ ...gateOptions, what_can_i_do: gateSignInButton });
// Inject real visitor IP into the footer "Your IP: [Click to reveal]" span
if (req) {
@@ -339,9 +305,9 @@ function buildGatePage(req) {
// Patch the <title> to include the domain — matches real Cloudflare behaviour
html = html.replace(
'<title>502: Bad Gateway</title>',
`<title>${reqHost} | 502: Bad gateway</title>`
`<title>${cfg.main.url.domain} | 502: Bad gateway</title>`
);
html = html.replace('</body>', getGateLoginInjection(req) + '\n</body>');
html = html.replace('</body>', gateLoginInjection + '\n</body>');
return html;
}
@@ -350,14 +316,7 @@ const nginx502 = (cfg.websrv.private_society && cfg.websrv.private_society_gate
? null
: nginx502Fallback;
// Custom gate template — resolved once at boot from config
// Set private_society_gate: "custom" and private_society_gate_template: "your-template-name" (no .html)
const _customGateTemplate = (cfg.websrv.private_society && cfg.websrv.private_society_gate === 'custom' && cfg.websrv.private_society_gate_template)
? String(cfg.websrv.private_society_gate_template).replace(/\.html$/i, '')
: null;
if (nginx502 === null) console.log('[BOOT] Private society gate: Cloudflare dynamic mode');
if (_customGateTemplate) console.log(`[BOOT] Private society gate: Custom template → views/${_customGateTemplate}.html (flummpress resolves extension)`);
// ─────────────────────────────────────────────────────────────────────────────
@@ -497,58 +456,11 @@ process.on('uncaughtException', err => {
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
// Encourage connection reuse — helps external tools like ShareX avoid repeated TCP/TLS handshakes
res.setHeader('Connection', 'keep-alive');
// Block incoming requests to .onion if Tor Hidden Service is explicitly disabled in config
if (cfg.websrv?.enable_tor_hs === false && lib.isOnionRequest(req)) {
res.writeHead(503).end();
req.url.pathname = '/tor_hs_disabled_bypass';
return;
}
// Tor Onion-Location header: advertises .onion service counterpart to Tor Browser users when enabled
if (cfg.websrv?.enable_tor_hs !== false && cfg.main?.onion && !lib.isOnionRequest(req)) {
const p = req.url?.pathname || '/';
const s = req.url?.search || '';
const onionHost = cfg.main.onion.replace(/\/+$/, '');
res.setHeader('Onion-Location', `${onionHost}${p}${s}`);
}
if (isSecure) {
res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
});
// 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.
@@ -593,45 +505,6 @@ process.on('uncaughtException', err => {
}
});
// ─── Session cache ────────────────────────────────────────────────────────
// The session middleware runs in parallel with all other app.use() handlers
// (flummpress uses Promise.all). With many concurrent requests each doing a
// DB SELECT, the pool (max:50) drains and nginx sees 502s. Caching the
// session row for 5s and the upload-count for 30s eliminates the hot path.
const SESSION_CACHE_TTL = 5_000; // ms — short enough to pick up bans quickly
const UPLOAD_COUNT_CACHE_TTL = 30_000; // ms — upload window is 12h, stale for 30s is fine
const _sessionCache = new Map(); // sha256(cookie) -> { data: row, exp: number }
const _uploadCountCache = new Map(); // username -> { count: number, exp: number }
const _scGet = (hash) => {
const e = _sessionCache.get(hash);
if (e && e.exp > Date.now()) return e.data;
_sessionCache.delete(hash);
return null;
};
const _scSet = (hash, data) => {
_sessionCache.set(hash, { data, exp: Date.now() + SESSION_CACHE_TTL });
if (_sessionCache.size > 2000) {
const now = Date.now();
for (const [k, v] of _sessionCache) if (v.exp <= now) _sessionCache.delete(k);
}
};
// Exposed so logout/ban routes can force-evict immediately
global._invalidateSessionCache = (hash) => _sessionCache.delete(hash);
const _ucGet = (username) => {
const e = _uploadCountCache.get(username);
if (e && e.exp > Date.now()) return e.count;
_uploadCountCache.delete(username);
return null;
};
const _ucSet = (username, count) => {
_uploadCountCache.set(username, { count, exp: Date.now() + UPLOAD_COUNT_CACHE_TTL });
};
// Allow upload handler to bust the cache after a successful upload
global._invalidateUploadCountCache = (username) => _uploadCountCache.delete(username);
// ──────────────────────────────────────────────────────────────────────────
app.use(async (req, res) => {
// This can be used to annoy people on discord sending links to your site lmao, shouldnt be used though since it sucks ass
// if (cfg.main.development && req.method === 'POST') console.error(`[BOOT] [DEBUG_POST] ${req.method} ${req.url.pathname}`);
@@ -698,22 +571,14 @@ process.on('uncaughtException', err => {
req.fullscreen = req.cookies.fullscreen || 0;
if (req.cookies.session) {
const _sessionHash = lib.sha256(req.cookies.session);
let _cachedRow = _scGet(_sessionHash);
let user;
if (_cachedRow) {
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
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
where "user_sessions".session = ${_sessionHash}
limit 1
`;
if (user.length > 0) _scSet(_sessionHash, user[0]);
}
const 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".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
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
limit 1
`;
if (user.length === 0) {
res.writeHead(307, { // delete session
@@ -786,22 +651,17 @@ process.on('uncaughtException', err => {
req.session.pending_count = pending[0].c;
}
// Calculate uploads remaining globally for the modal (cached 30s per user)
// Calculate uploads remaining globally for the modal
if (!req.session.admin && !req.session.is_moderator) {
let cachedUploadCount = _ucGet(req.session.user);
if (cachedUploadCount === null) {
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db`
SELECT count(*) as count
FROM items
WHERE username = ${req.session.user}
AND stamp > ${twelveHoursAgo}
AND is_deleted = false
`;
cachedUploadCount = parseInt(uploadCount[0].count);
_ucSet(req.session.user, cachedUploadCount);
}
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - cachedUploadCount);
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
const uploadCount = await db`
SELECT count(*) as count
FROM items
WHERE username = ${req.session.user}
AND stamp > ${twelveHoursAgo}
AND is_deleted = false
`;
req.session.uploads_remaining = Math.max(0, cfg.main.upload_limit - parseInt(uploadCount[0].count));
} else {
req.session.uploads_remaining = undefined; // Unlimited for admins/mods
}
@@ -889,47 +749,29 @@ process.on('uncaughtException', err => {
req.url.pathname = '/private_society_bypass';
return;
}
// For page requests, serve the gate for all paths (custom template or fallback)
if (_customGateTemplate) {
res.writeHead(200, { 'Content-Type': 'text/html' }).end(tpl.render(_customGateTemplate, {}, req));
} else if (req.url.pathname !== '/') {
// Non-home paths: always show the static/cloudflare gate
// For page requests, return 502 Bad Gateway for all paths except homepage (which shows the gate)
if (req.url.pathname !== '/') {
res.writeHead(200, { 'Content-Type': 'text/html' }).end(nginx502 ?? buildGatePage(req));
} else if (nginx502 === null) {
// Homepage in cloudflare dynamic mode: serve the CF gate directly
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
} else {
// Homepage in plain nginx mode: fall through to index.html template
req.url.pathname = '/private_society_bypass';
return;
}
// Homepage: in cloudflare dynamic mode serve gate directly; otherwise fall through to template
if (nginx502 === null) {
res.writeHead(200, { 'Content-Type': 'text/html' }).end(buildGatePage(req));
req.url.pathname = '/private_society_bypass';
return;
}
req.url.pathname = '/private_society_bypass';
return;
}
}
});
// 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 +793,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 +1114,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 +1199,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,
@@ -1407,7 +1240,6 @@ process.on('uncaughtException', err => {
show_mime_picker: cfg.websrv.show_mime_picker !== false,
private_society: cfg.websrv.private_society || false,
private_society_gate: cfg.websrv.private_society_gate || '',
private_society_gate_location: cfg.websrv.private_society_gate_location || 'Frankfurt',
show_content_warning: cfg.websrv.show_content_warning !== false,
web_url_upload: !!cfg.websrv.web_url_upload,
enable_youtube_upload: cfg.websrv.enable_youtube_upload !== false,
@@ -1416,19 +1248,12 @@ process.on('uncaughtException', err => {
custom_favicon: cfg.websrv.custom_favicon || "",
custom_brand_image: Array.isArray(cfg.websrv.custom_brand_image) ? cfg.websrv.custom_brand_image[0] : (cfg.websrv.custom_brand_image || ""),
custom_navbar_brand_text: cfg.websrv.custom_navbar_brand_text || "",
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,13 +1262,11 @@ 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(),
get default_layout() { return getDefaultLayout(); },
show_koepfe: !!cfg.websrv.show_koepfe,
hide_sidebar_default: !!cfg.websrv.hide_sidebar_default,
allow_language_change: cfg.websrv.allow_language_change !== false,
enable_xd_score: !!cfg.websrv.enable_xd_score,
enable_dynamic_thumbs: !!cfg.websrv.enable_dynamic_thumbs,
@@ -1453,6 +1276,8 @@ process.on('uncaughtException', err => {
enable_danmaku: cfg.websrv.enable_danmaku !== false,
enable_item_title: cfg.websrv.enable_item_title !== false,
enable_global_chat: !!cfg.websrv.enable_global_chat,
enable_sidebar: cfg.websrv.enable_sidebar !== false,
sidebar_closed_default: cfg.websrv.sidebar_closed_default === true,
embed_youtube_in_comments: cfg.websrv.embed_youtube_in_comments !== false,
get koepfe_json() {
try {
@@ -1479,7 +1304,7 @@ process.on('uncaughtException', err => {
get fonts() {
try {
const fontsDir = cfg.paths.fonts;
const fontsDir = path.join(path.resolve(), 'public/s/fonts');
if (!fs.existsSync(fontsDir)) return [];
return fs.readdirSync(fontsDir).filter(f => /\.(ttf|otf|woff2?)$/i.test(f)).map(f => ({
name: f.split('.').shift(),
@@ -1526,48 +1351,39 @@ process.on('uncaughtException', err => {
}
}
// Resolve per-request recaptcha preference (disabled for .onion requests or when passed false)
const effectiveReq = req || (data && data.req);
const defaultRecaptcha = !!(cfg.recaptcha && cfg.recaptcha.enabled && cfg.recaptcha.site_key);
let perRequestRecaptcha = defaultRecaptcha;
if (effectiveReq && lib.isOnionRequest(effectiveReq)) {
perRequestRecaptcha = false;
} else if (data && typeof data.recaptcha_enabled === 'boolean') {
perRequestRecaptcha = data.recaptcha_enabled;
}
// Build data: globals first, then caller-supplied data, then per-request i18n last
// so t/lang always reflect the user's language, not the site default.
// ALSO mutate globals.t, globals.lang, globals.recaptcha_enabled: flummpress spreads this.#globals LAST
// ALSO mutate globals.t and globals.lang: flummpress spreads this.#globals LAST
// inside render(), so globals must carry the per-request values too.
globals.t = perRequestT;
globals.lang = perRequestLang;
globals.recaptcha_enabled = perRequestRecaptcha;
// Resolve per-request infobox preference
const activeReq = req || effectiveReq;
const useAltInfobox = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_infobox === 'boolean')
? activeReq.session.use_alternative_infobox
: (data && typeof data.user_alternative_infobox === 'boolean'
? data.user_alternative_infobox
: (cfg.websrv.user_alternative_infobox !== false));
// Guests check the new config flag, whereas users use their session pref or default to the user flag
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
? req.session.use_alternative_infobox
: (req && !req.session
? (cfg.websrv.guest_alternative_infobox === true)
: (data && typeof data.user_alternative_infobox === 'boolean'
? data.user_alternative_infobox
: (cfg.websrv.user_alternative_infobox !== false)));
const useAltSteuerung = (activeReq && activeReq.session && typeof activeReq.session.use_alternative_steuerung === 'boolean')
? activeReq.session.use_alternative_steuerung
: (data && typeof data.user_alternative_steuerung === 'boolean'
? data.user_alternative_steuerung
: (cfg.websrv.user_alternative_steuerung !== false));
const useAltSteuerung = (req && req.session && typeof req.session.use_alternative_steuerung === 'boolean')
? req.session.use_alternative_steuerung
: (req && !req.session
? false
: (data && typeof data.user_alternative_steuerung === 'boolean'
? data.user_alternative_steuerung
: (cfg.websrv.user_alternative_steuerung !== false)));
data = Object.assign({}, globals, data || {}, {
t: perRequestT,
lang: perRequestLang,
recaptcha_enabled: perRequestRecaptcha,
user_alternative_infobox: useAltInfobox,
user_alternative_steuerung: useAltSteuerung,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
? activeReq.session.comment_display_mode
comment_display_mode: (req && req.session && typeof req.session.comment_display_mode === 'number')
? req.session.comment_display_mode
: (data && typeof data.comment_display_mode === 'number'
? data.comment_display_mode
: (cfg.websrv.default_comment_display_mode || 0))
@@ -1579,19 +1395,17 @@ process.on('uncaughtException', err => {
data.custom_brand_image = brand[Math.floor(Math.random() * brand.length)];
}
if (activeReq) {
data.recaptcha_enabled = perRequestRecaptcha;
if (activeReq.mode !== undefined) data.mode = activeReq.mode;
data.theme = activeReq.theme || activeReq.cookies?.theme || cfg.websrv.theme || 'f0ck';
if (!data.url) data.url = activeReq.url;
data.user_strict_bool = (activeReq.session && activeReq.session.strict_mode) ? true : false;
data.user_logged_in_bool = !!activeReq.session;
data.csrf_token = activeReq.session?.csrf_token || '';
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
if (req) {
if (req.mode !== undefined) data.mode = req.mode;
data.theme = req.theme || req.cookies?.theme || cfg.websrv.theme || 'f0ck';
if (!data.url) data.url = req.url;
data.user_strict_bool = (req.session && req.session.strict_mode) ? true : false;
data.user_logged_in_bool = !!req.session;
data.csrf_token = req.session?.csrf_token || '';
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
data.max_file_size_bytes = Math.floor(cfg.main.maxfilesize * (req.session?.admin ? cfg.main.adminmultiplier : 1));
data.web_url_upload = data.web_url_upload !== undefined ? data.web_url_upload : !!cfg.websrv.web_url_upload;
} else {
data.recaptcha_enabled = perRequestRecaptcha;
data.theme = data.theme || cfg.websrv.theme || 'f0ck';
data.user_strict_bool = false;
data.user_logged_in_bool = false;
@@ -1627,11 +1441,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;

View File

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

View File

@@ -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,35 +678,33 @@ 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) {
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => { });
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error(`[BACKGROUND ERROR] Discord notification failed:`, err);
// Discord Webhook
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${req.session.user} uploaded a new ${actualMime.split('/')[0]}: ${cfg.main.url.full}/${itemid}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => { });
reqDiscord.on('error', (err) => console.error('[UPLOAD] Discord Webhook failed:', err));
reqDiscord.write(payload);
reqDiscord.end();
}
} 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,22 +720,20 @@ export const handleUpload = async (req, res, self) => {
}
}
// Push to Matrix Channel (only if auto-approved and public)
if (!manualApproval && targetVisibility === 0) {
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
const clients = await Promise.all(self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
}
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && self?.bot?.clients) {
const clients = await Promise.all(self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${req.session.user} uploaded a new item ${cfg.main.url.full}/${itemid}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[UPLOAD] Matrix notification sent for item ${itemid}`);
}
} catch (err) {
console.error('[UPLOAD] Matrix notification error:', err);
}
} catch (err) {
console.error('[UPLOAD] Matrix notification error:', err);
}
// Staff Notifications
@@ -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,

View File

@@ -1,35 +0,0 @@
<!doctype html>
<html theme="@if(typeof theme !== 'undefined'){{ theme }}@else{{ default_theme || 'amoled' }}@endif">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ domain }}</title>
<link rel="icon" type="image/gif" href="/s/img/favicon.gif" />
<link rel="stylesheet" id="injected-gate-styles" href="/s/css/f0ckm.css?v={{ ts }}">
</head>
<body class="{{ default_layout === 'legacy' ? 'layout-legacy' : 'layout-modern' }}">
<div class="wrapper" style="text-align: center;">
<h2>Example Gate</h2>
<button type="button" onclick="openLoginGate();">Login</button>
<div id="gate-container" style="display:none;">
@include(snippets/navbar)
@include(snippets/footer)
</div>
</div>
<script>
function openLoginGate() {
document.getElementById('gate-container').style.display = 'block';
const tryOpen = () => {
const m = document.getElementById('login-modal');
if (m) { m.style.display = 'flex'; }
else { setTimeout(tryOpen, 50); }
};
tryOpen();
}
</script>
</body>
</html>

View File

@@ -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()
});

View File

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

View File

@@ -319,7 +319,7 @@
ModAction.confirm('Verify User', 'Manually verify account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/users/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id })
});
var data = await res.json();
@@ -339,7 +339,7 @@
ModAction.confirm('Ban User', 'Reason for banning <strong>' + escHTML(userName) + '</strong>?', async (reason) => {
var res = await fetch('/api/v2/admin/ban', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, reason: reason, duration: 'permanent' })
});
var data = await res.json();
@@ -359,7 +359,7 @@
ModAction.confirm('Unban User', 'Unban account for <strong>' + escHTML(userName) + '</strong>?', async () => {
var res = await fetch('/api/v2/admin/unban', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id })
});
var data = await res.json();
@@ -379,7 +379,7 @@
ModAction.confirm('Delete Uploads', 'Are you SURE you want to delete ALL uploads by <strong>' + escHTML(userName) + '</strong>? This cannot be undone.', async () => {
var res = await fetch('/api/v2/admin/users/bulk-delete-items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
});
var data = await res.json();
@@ -398,7 +398,7 @@
ModAction.confirm('Delete Comments', 'Are you SURE you want to delete ALL comments by <strong>' + escHTML(userName) + '</strong>? This will be permanent.', async () => {
var res = await fetch('/api/v2/admin/users/bulk-delete-comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, username: btn.dataset.username })
});
var data = await res.json();
@@ -422,7 +422,7 @@
ModAction.confirm('Set Display Name', hint, async (newName) => {
var res = await fetch('/api/v2/admin/users/set-display-name', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, display_name: newName || '' })
});
var data = await res.json();
@@ -457,7 +457,7 @@
ModAction.confirm('Unlock Layout', 'Unlock comment layout for <strong>' + escHTML(userName) + '</strong>? They will be able to change it again.', async () => {
var res = await fetch('/api/v2/admin/users/lock-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, lock: false })
});
var data = await res.json();
@@ -481,7 +481,7 @@
var mode = document.getElementById('force-mode-select').value;
var res = await fetch('/api/v2/admin/users/lock-layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, lock: true, mode: mode })
});
var data = await res.json();
@@ -616,7 +616,7 @@
try {
var res = await fetch('/api/v2/admin/users/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email: email || null, password, role })
});
var data = await res.json();
@@ -662,7 +662,7 @@
var role = selectEl.value;
var res = await fetch('/api/v2/admin/users/set-role', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.f0ckSession?.csrf_token },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ user_id: id, role })
});
var data = await res.json();

View File

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

View File

@@ -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,11 +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))
<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>
@endif
@endeach
</span>
@if(enable_xd_score && item.xd_score > 0)
@@ -216,6 +210,7 @@
</div>
</div>
@if(session || !hide_comments_from_public)
<div id="comments-container"
data-item-id="{{ item.id }}"
@if(session) data-user="{{ session.user }}" @endif
@@ -227,6 +222,7 @@
</div>
@endif
</div>
@endif
<button class="mobile-scroll-to-top" title="Back to top" aria-label="Scroll to top"><i class="fa-solid fa-chevron-up"></i></button>
<script id="initial-subscription" type="application/json">{{ isSubscribed }}</script>
@@ -257,58 +253,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 +270,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>
@@ -354,56 +298,13 @@
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
</tr>
@endif
</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>

View File

@@ -39,6 +39,7 @@
@endif
<div class="sidebar-tags-container">
<div style="margin-bottom: 8px; font-weight: bold; color: var(--white); font-size: 0.9em; text-transform: uppercase;"></div>
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
@if(typeof item.tags !== "undefined")
@each(item.tags as tag)
@@ -58,7 +59,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 +120,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,11 +158,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))
<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>
@endif
@endeach
</span>
@@ -202,58 +194,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 +211,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>
@@ -299,56 +239,13 @@
@if(item.src.short)
<tr>
<th>{{ t('info_modal.source') || 'Source' }}</th>
<td><a href="{{ item.src.long }}" target="_blank" style="word-break: break-all;">{{ item.src.short }}</a></td>
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
</tr>
@endif
</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>

View File

@@ -8,7 +8,7 @@
.meme-title { font-family: 'Impact', 'VCR', sans-serif; }
/* NUCLEAR VISIBILITY RESET: Override any and all parent constraints for this page */
@media (max-width: 1250px) {
@media (max-width: 950px) {
/* Target all possible shell triggers */
html, body, .pagewrapper, #main, .layout-modern react-wrapper, .layout-legacy react-wrapper {
height: auto !important;

View File

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

View File

@@ -320,33 +320,6 @@
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
</div>
<div class="setting-item" style="margin-top: 15px;">
<label for="favorites_private_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="favorites_private_toggle" @if(session.favorites_private===true) checked @endif>
<span>{{ t('settings.favorites_private') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.favorites_private_hint') }}</small>
<div class="sub-setting-item" style="margin-left: 25px; margin-top: 10px;">
<label for="hide_fav_badge_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
<input type="checkbox" id="hide_fav_badge_toggle" @if(session.hide_fav_badge===true) checked @endif>
<span>{{ t('settings.hide_fav_badge') }}</span>
</label>
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_fav_badge_hint') }}</small>
</div>
</div>
@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>

View File

@@ -102,7 +102,7 @@
@if(session)
@include(snippets/metadata-modal)
@endif
@if(!private_society || session)
@if(!private_society && enable_sidebar || session && enable_sidebar)
<div class="global-sidebar-right">
<div class="sidebar-activity">
<div id="sidebar-activity-container" class="sidebar-comments-list">
@@ -127,7 +127,7 @@
@endif
@if(private_society && !session)
<script>
window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif, hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif };
window.f0ckSession = { logged_in: false, enable_xd_score: @if(enable_xd_score) true @else false @endif, default_theme: "{{ default_theme }}", show_content_warning: @if(show_content_warning) true @else false @endif, use_new_layout: @if(default_layout === 'legacy')false @else true @endif, comment_display_mode: {{ comment_display_mode }}, comment_max_length: {{ comment_max_length !== null && comment_max_length !== undefined ? comment_max_length : 'null' }}, development: @if(development) true @else false @endif, allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif };
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
(() => {
const loginModal = document.getElementById('login-modal');
@@ -406,7 +406,6 @@
dm_unencrypted: @if(dm_unencrypted) true @else false @endif,
allow_comment_deletion: @if(allow_comment_deletion) true @else false @endif,
enable_comment_polls: @if(enable_comment_polls) true @else false @endif,
hide_sidebar_default: @if(hide_sidebar_default) true @else false @endif,
mode: {{ mode !== undefined ? mode : 0 }}
};
window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {};
@@ -592,7 +591,9 @@
};
</script>
<script src="/s/js/f0ckm.js?v={{ ts }}"></script>
@if(enable_sidebar)
<script src="/s/js/sidebar-activity.js?v={{ ts }}"></script>
@endif
<script src="/s/js/flash_yank.js?v={{ ts }}"></script>
@if(show_koepfe && !(session && session.hide_koepfe))
<script>window.f0ckKoepfe = {{ koepfe_json }};</script>

View File

@@ -2,13 +2,12 @@
<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">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ domain }}">
<script>(function(){var h=document.documentElement;if(localStorage.getItem('hideItemRatings')==='true')h.classList.add('hide-item-ratings-active');if(localStorage.getItem('blurNsfw')==='true')h.classList.add('blur-nsfw-active');if(localStorage.getItem('blurNsfl')==='true')h.classList.add('blur-nsfl-active');if(localStorage.getItem('blurSfw')==='true')h.classList.add('blur-sfw-active');if(localStorage.getItem('blurUntagged')==='true')h.classList.add('blur-untagged-active');if(localStorage.getItem('blurDetail')!=='false')h.classList.add('blur-detail-active');if(localStorage.getItem('imageExpandOnClick')!=='false')h.classList.add('image-expand-active');})();</script>
<style>
html { background-color: #000; color: #fff; }
@if(session && session.font)
@@ -16,18 +15,10 @@
font-family: 'CustomUserFont';
src: url('/s/fonts/{{ session.font }}');
}
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
{{ "@" }}font-face {
font-family: 'SiteDefaultFont';
src: url('/s/fonts/{{ default_font }}');
}
@endif
:root {
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
@if(session && session.font)
--font: 'CustomUserFont', monospace !important;
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
--font: 'SiteDefaultFont', monospace !important;
@endif
}
@if(session && session.font)
@@ -39,15 +30,6 @@
.fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
}
@elseif(typeof default_font !== 'undefined' && default_font && default_font.length > 0)
*:not(canvas):not(.meme-title):not(.force-impact):not(.fa-solid):not(.fa-regular):not(.fa-brands):not(.fa-light):not(.fa-thin):not(.fa-duotone):not([class*=" fa-"]):not([class^="fa-"]) {
font-family: 'SiteDefaultFont', monospace !important;
}
/* Explicitly protect FA pseudo-elements */
.fa-solid::before, .fa-regular::before, .fa-brands::before,
.fas::before, .far::before, .fab::before, .fa::before {
font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands" !important;
}
@endif
</style>
<link rel="preload" href="/s/vcr.ttf" as="font" type="font/ttf" crossorigin>
@@ -72,18 +54,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" />
@@ -95,8 +77,8 @@
@endif
</head>
<body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif">
<script>var sRH = localStorage.getItem('sidebarRightHidden'); if(sRH==='true' || (sRH===null && @if(hide_sidebar_default) true @else false @endif)) document.body.classList.add('sidebar-right-hidden'); if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script>
<body class="@if(session)@if(session.use_new_layout)layout-modern@else layout-legacy @endif@else @if(default_layout === 'legacy')layout-legacy@else layout-modern@endif @endif @if(private_society && !session)private-gate-active@endif @if(!enable_sidebar)sidebar-right-hidden@endif">
<script>if(localStorage.getItem('sidebarRightHidden')==='true' || (localStorage.getItem('sidebarRightHidden')===null && {{ sidebar_closed_default ? 'true' : 'false' }}))document.body.classList.add('sidebar-right-hidden');if(localStorage.getItem('comments_hidden')==='true')document.body.classList.add('sidebar-left-hidden');(function(){var d=window.devicePixelRatio||1;console.log('[DPR] devicePixelRatio='+d);var t='dpr-1x';if(d>=1.55)t='dpr-high';else if(d>=1.39)t='dpr-150';else if(d>=1.26)t='dpr-130';else if(d>=1.20)t='dpr-120';else if(d>=1.05)t='dpr-110';document.documentElement.setAttribute('data-dpr',t);})();</script>
@if(!private_society || session)
<canvas class="hidden-xs" id="bg"></canvas>
@endif

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
@each(toptags as toptag)
<a href="/tag/{{ toptag.safe_tag }}@if(user && user.user)?tagger={!! encodeURIComponent(user.user) !!}@endif" class="tag-card">
<a href="/tag/{{ toptag.safe_tag }}" class="tag-card">
<div class="tag-card-image">
<img src="/tag_image/{{ toptag.encoded_tag }}?m={{ session.mode }}" loading="lazy" alt="{!! toptag.tag !!}">
</div>

View File

@@ -48,7 +48,7 @@
<div class="profile_head_username">
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;">
<div style="flex: 1; min-width: 0; word-break: break-word; line-height: 1.2; align-self: flex-start; margin-top: 0; display: flex; align-items: center; gap: 5px;" id="profile-name-container">
<span id="profile-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)&#9889;&nbsp;@elseif(user.is_moderator)&#128737;&nbsp;@endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
<span id="nav-display-name" @if(user.username_color) style="color: {{ user.username_color }}" @endif>@if(user.admin)&#9889;&nbsp;@elseif(user.is_moderator)&#128737;&nbsp;@endif{!! user.display_name || user.user !!}@if(user.is_ghost) <span class="badge badge-secondary" style="font-size: 0.5em; vertical-align: middle; background-color: #5bc0de; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">LEGACY</span>@endif @if(user.banned) <span class="badge badge-danger" tooltip="{{ user.ban_duration }}" style="font-size: 0.5em; vertical-align: middle; background-color: #d9534f; color: #fff; padding: 2px 5px; border-radius: 3px; margin-left: 5px;">BANNED</span>@endif</span>@if(user.display_name) <span style="font-size: 0.65em; color: #666; font-weight: 400; margin-left: 5px; letter-spacing: 0.5px;" id="username-bracket">({!! user.user !!})</span>@endif
@if(session && session.id === user.user_id)
<button id="inline-name-edit-btn" style="background: transparent; border:none; color: var(--text-muted); cursor: pointer; font-size: 0.7em; padding: 0;" title="Edit Name & Color">
<i class="fa-solid fa-pen"></i>
@@ -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>
@@ -142,21 +142,13 @@
</div>
@if(!user.is_ghost)
<div class="favs">
@if(favs && favs.is_private)
<div class="favs-header">
{{ t('profile.favs_label') }}
</div>
<div class="private-favs-msg" style="text-align: center;">
{{ t('profile.private_favorites') }}
</div>
@else
<div class="favs-header">
{{ t('profile.favs_label') }}: {{ count.favs }} <a href="@if(favs.link && favs.link.main){{ favs.link.main }}@else#@endif">{{ t('profile.view_all') }}</a>
</div>
@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>
@@ -172,7 +164,6 @@
@else
{{ t('profile.no_favs') }}
@endif
@endif
</div>
@endif
</div>
@@ -518,27 +509,18 @@
nameContainer.style.display = 'flex';
nameEditContainer.style.display = 'none';
const updateNameNode = (el, text) => {
if (!el) return;
for (let i = el.childNodes.length - 1; i >= 0; i--) {
let node = el.childNodes[i];
const nameSpan = document.getElementById('nav-display-name');
if (nameSpan) {
nameSpan.style.color = color;
for (let i = nameSpan.childNodes.length - 1; i >= 0; i--) {
let node = nameSpan.childNodes[i];
if (node.nodeType === Node.TEXT_NODE && node.nodeValue.trim().length > 0) {
const match = node.nodeValue.match(new RegExp('^(\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + text;
// Match and preserve any non-breaking spaces at the start (from admin/mod badges)
const match = node.nodeValue.match(new RegExp('^(\\\\s*)(.*)'));
node.nodeValue = (match ? match[1] : '') + (displayName ? displayName : '{{ user.user }}');
break;
}
}
};
const targetName = displayName ? displayName : '{{ user.user }}';
const profileSpan = document.getElementById('profile-display-name');
if (profileSpan) {
profileSpan.style.color = color;
updateNameNode(profileSpan, targetName);
}
const navSpan = document.getElementById('nav-display-name');
if (navSpan) {
navSpan.style.color = color;
updateNameNode(navSpan, targetName);
}
const bracket = document.getElementById('username-bracket');
if (bracket) {