Compare commits
1 Commits
facette
...
chat-overh
| Author | SHA1 | Date | |
|---|---|---|---|
| f803ec27a5 |
@@ -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
|
||||
|
||||
20
README.md
20
README.md
@@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
11
build.bash
11
build.bash
@@ -16,7 +16,7 @@ fi
|
||||
# Parse arguments
|
||||
TARGET="master"
|
||||
CACHE_FLAG=""
|
||||
CUSTOM_BRANCH=""
|
||||
BRANCH_TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
@@ -27,8 +27,8 @@ for arg in "$@"; do
|
||||
CACHE_FLAG="--no-cache"
|
||||
;;
|
||||
*)
|
||||
if [ -z "$CUSTOM_BRANCH" ]; then
|
||||
CUSTOM_BRANCH=$arg
|
||||
if [ -z "$BRANCH_TARGET" ]; then
|
||||
BRANCH_TARGET=$arg
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -43,10 +43,7 @@ fi
|
||||
# Set Image name and branch defaults
|
||||
IMAGE_NAME="f0ckm"
|
||||
|
||||
if [ -n "$CUSTOM_BRANCH" ]; then
|
||||
BRANCH_TARGET="$CUSTOM_BRANCH"
|
||||
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
|
||||
elif [ "$TARGET" == "master" ]; then
|
||||
if [ "$TARGET" == "master" ]; then
|
||||
echo "--- PRODUCTION DEPLOYMENT (master) ---"
|
||||
BRANCH_TARGET="master"
|
||||
elif [ "$TARGET" == "stg" ]; then
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
client_max_body_size 10000M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
@@ -67,7 +61,6 @@
|
||||
"show_koepfe": false,
|
||||
"hide_sidebar_default": true,
|
||||
"koepfe": [],
|
||||
"enable_tor_hs": true,
|
||||
"enable_global_chat": true,
|
||||
"enable_danmaku": true,
|
||||
"private_messages": true,
|
||||
@@ -128,10 +121,7 @@
|
||||
"enable_profile_description": true,
|
||||
"user_alternative_infobox": false,
|
||||
"user_banner_enabled": true,
|
||||
"comment_banner_enabled": true,
|
||||
"comment_banner_max_height": 300,
|
||||
"user_alternative_steuerung": false,
|
||||
"expose_repost_links_to_guests": false,
|
||||
"enable_swf": false,
|
||||
"swf_thumb": "/s/img/swf.png",
|
||||
"enable_archive": true,
|
||||
@@ -142,8 +132,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",
|
||||
|
||||
@@ -31,8 +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/import/:/opt/f0ckm/f0ckm-data/import/:Z
|
||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||
|
||||
environment:
|
||||
@@ -41,12 +39,6 @@ services:
|
||||
- VIRTUAL_PORT=1337
|
||||
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
|
||||
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
|
||||
- DB_HOST=f0ckm-db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${POSTGRES_USER:-f0ckm}
|
||||
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
|
||||
- DB_NAME=${POSTGRES_DB:-f0ckm}
|
||||
- NODE_ENV=production
|
||||
ports:
|
||||
- "1337:1337"
|
||||
restart: unless-stopped
|
||||
@@ -57,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
|
||||
@@ -110,17 +97,21 @@ services:
|
||||
f0ckm-nginx:
|
||||
image: nginxproxy/nginx-proxy:latest
|
||||
container_name: f0ckm-nginx
|
||||
network_mode: "host"
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
environment:
|
||||
- ENABLE_IPV6=true
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- vhost:/etc/nginx/vhost.d:rw
|
||||
- html:/usr/share/nginx/html:rw
|
||||
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: unless-stopped
|
||||
|
||||
acme-companion:
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Migration: Add expires_at column to items table for expiring uploads
|
||||
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;
|
||||
@@ -1,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';
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
-- Fix items with NULL visibility (should default to 0 = public)
|
||||
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -75,9 +75,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -492,9 +492,9 @@
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@
|
||||
"build": "node scripts/build-css.mjs",
|
||||
"copy-vendor": "node scripts/copy-vendor.mjs",
|
||||
"seed": "node scripts/seed.mjs",
|
||||
"create-admin": "node scripts/create-admin.mjs",
|
||||
"import": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node scripts/import.mjs"
|
||||
"create-admin": "node scripts/create-admin.mjs"
|
||||
},
|
||||
"author": "Kibi Kelburton",
|
||||
"license": "MIT",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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 |
@@ -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))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -186,9 +180,6 @@
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
|
||||
@@ -468,15 +468,9 @@ class CommentSystem {
|
||||
// 2. Check for duplicates (if we just posted it ourselves via optimistic insert).
|
||||
// Even on early return, ensure the button is present if body was truncated.
|
||||
if (document.getElementById('c' + data.id)) {
|
||||
const el = document.getElementById('c' + data.id);
|
||||
if (el && data.banner_file && data.banner_file !== 'null') {
|
||||
el.style.setProperty('--author-banner', `url('/a/${data.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', data.banner_position === 'center' ? 'center top' : (data.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (data.banner_size && data.banner_size !== 'cover') ? data.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
if (data.body && data.body.endsWith('\u2026')) {
|
||||
console.log('[handleLiveComment] duplicate+truncated, ensuring button for', data.id);
|
||||
const el = document.getElementById('c' + data.id);
|
||||
const contentEl = el?.querySelector('.comment-content');
|
||||
if (contentEl && !contentEl.querySelector('.load-full-comment-btn')) {
|
||||
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
|
||||
@@ -513,10 +507,6 @@ class CommentSystem {
|
||||
avatar: data.avatar,
|
||||
avatar_file: data.avatar_file,
|
||||
username_color: data.username_color,
|
||||
banner_file: data.banner_file || null,
|
||||
banner_position: data.banner_position || null,
|
||||
banner_size: data.banner_size || null,
|
||||
banner_repeat: data.banner_repeat || null,
|
||||
video_time: data.video_time ?? null,
|
||||
is_new: true,
|
||||
is_deleted: false,
|
||||
@@ -603,20 +593,7 @@ class CommentSystem {
|
||||
// Update in-memory data so future reconciles use the full content
|
||||
if (this.lastData) {
|
||||
const cached = this.lastData.find(c => String(c.id) === String(commentId));
|
||||
if (cached) {
|
||||
cached.content = fullContent;
|
||||
if (json.comment.banner_file) cached.banner_file = json.comment.banner_file;
|
||||
if (json.comment.banner_position) cached.banner_position = json.comment.banner_position;
|
||||
if (json.comment.banner_size) cached.banner_size = json.comment.banner_size;
|
||||
if (json.comment.banner_repeat) cached.banner_repeat = json.comment.banner_repeat;
|
||||
}
|
||||
}
|
||||
|
||||
if (json.comment && json.comment.banner_file) {
|
||||
el.style.setProperty('--author-banner', `url('/a/${json.comment.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', json.comment.banner_position === 'center' ? 'center top' : (json.comment.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (json.comment.banner_size && json.comment.banner_size !== 'cover') ? json.comment.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
if (cached) cached.content = fullContent;
|
||||
}
|
||||
|
||||
contentEl.dataset.raw = fullContent;
|
||||
@@ -1062,6 +1039,10 @@ class CommentSystem {
|
||||
preview.style.position = 'fixed';
|
||||
preview.style.zIndex = (100000 + level).toString();
|
||||
|
||||
// Try to place it to the right of the link, or top/bottom if needed
|
||||
let left = rect.right + 10;
|
||||
let top = rect.top;
|
||||
|
||||
document.body.appendChild(preview);
|
||||
CommentSystem.playEmojiVideos(preview);
|
||||
|
||||
@@ -1086,28 +1067,16 @@ class CommentSystem {
|
||||
|
||||
const previewRect = preview.getBoundingClientRect();
|
||||
|
||||
// Default: position preview to the right of the link
|
||||
let left = rect.right + 10;
|
||||
let top = rect.top;
|
||||
|
||||
// If there's NOT enough space on the right, open to the left
|
||||
if (left + previewRect.width > window.innerWidth - 10) {
|
||||
// Boundary checks
|
||||
if (left + previewRect.width > window.innerWidth) {
|
||||
left = rect.left - previewRect.width - 10;
|
||||
}
|
||||
if (left < 0) left = 10;
|
||||
|
||||
// Clamp to screen boundaries if left/right are both tight
|
||||
if (left < 10) {
|
||||
left = 10;
|
||||
}
|
||||
if (left + previewRect.width > window.innerWidth - 10) {
|
||||
left = Math.max(10, window.innerWidth - previewRect.width - 10);
|
||||
}
|
||||
|
||||
// Clamp top & bottom boundary
|
||||
if (top + previewRect.height > window.innerHeight - 10) {
|
||||
if (top + previewRect.height > window.innerHeight) {
|
||||
top = window.innerHeight - previewRect.height - 10;
|
||||
}
|
||||
if (top < 10) top = 10;
|
||||
if (top < 0) top = 10;
|
||||
|
||||
preview.style.left = left + 'px';
|
||||
preview.style.top = top + 'px';
|
||||
@@ -1171,7 +1140,6 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
quoteComment(id, openerEl, body) {
|
||||
if (!window.f0ckSession || !window.f0ckSession.logged_in) return;
|
||||
// If no reply input open anywhere, open the local one
|
||||
let textarea = document.querySelector('.comment-input.reply-input textarea');
|
||||
let isNew = false;
|
||||
@@ -1192,13 +1160,17 @@ class CommentSystem {
|
||||
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
|
||||
const contentEl = body.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
const LINE_MAX = 200;
|
||||
let rawText = (contentEl.dataset.raw || '').trim();
|
||||
if (rawText.includes('>') || rawText.includes('<') || rawText.includes('&')) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = rawText;
|
||||
rawText = txt.value;
|
||||
}
|
||||
const lines = rawText.split('\n');
|
||||
// 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) {
|
||||
@@ -1533,13 +1505,6 @@ class CommentSystem {
|
||||
el.classList.toggle('pinned', !!incoming.is_pinned);
|
||||
const pinIcon = el.querySelector('.pin-icon');
|
||||
if (pinIcon) pinIcon.style.display = incoming.is_pinned ? 'block' : 'none';
|
||||
|
||||
if (incoming && incoming.banner_file && incoming.banner_file !== 'null') {
|
||||
el.style.setProperty('--author-banner', `url('/a/${incoming.banner_file}')`);
|
||||
el.style.setProperty('--author-banner-position', incoming.banner_position === 'center' ? 'center top' : (incoming.banner_position || 'center top'));
|
||||
el.style.setProperty('--author-banner-size', (incoming.banner_size && incoming.banner_size !== 'cover') ? incoming.banner_size : '100% auto');
|
||||
el.style.setProperty('--author-banner-repeat', 'no-repeat');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1660,25 +1625,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') {
|
||||
@@ -1827,11 +1784,10 @@ 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, '>');
|
||||
const renderedContent = quoteEmojis
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${renderedContent}</span>`;
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
return `<span class="greentext">>${renderedContent.replace(/>/g, '>')}</span>`;
|
||||
}
|
||||
|
||||
// 2. Per-line limit to prevent marked.parse recursion on single giant lines
|
||||
@@ -2208,15 +2164,7 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
|
||||
const bannerStyle = (bannerEnabled && comment.banner_file && comment.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${comment.banner_file}'); --author-banner-position: ${comment.banner_position === 'center' ? 'center top' : (comment.banner_position || 'center top')}; --author-banner-size: ${(comment.banner_size && comment.banner_size !== 'cover') ? comment.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
const authorUserId = comment.user_id ?? (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? (window.f0ckSession.id || window.f0ckSession.user_id) : null);
|
||||
const authorUsernameColor = comment.username_color || (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? window.f0ckSession.username_color : null);
|
||||
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}"><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
@@ -2452,50 +2400,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);
|
||||
@@ -2910,10 +2818,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
|
||||
@@ -2921,7 +2826,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;
|
||||
@@ -2932,13 +2836,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;
|
||||
@@ -3259,9 +3159,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;
|
||||
@@ -3540,50 +3437,6 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
let resolvedBannerFile = (json.comment?.banner_file && json.comment.banner_file !== 'null') ? json.comment.banner_file : ((session.banner_file && session.banner_file !== 'null') ? session.banner_file : null);
|
||||
let resolvedBannerPosition = (json.comment?.banner_position && json.comment.banner_position !== 'null') ? json.comment.banner_position : ((session.banner_position && session.banner_position !== 'null') ? session.banner_position : null);
|
||||
let resolvedBannerSize = (json.comment?.banner_size && json.comment.banner_size !== 'null') ? json.comment.banner_size : ((session.banner_size && session.banner_size !== 'null') ? session.banner_size : null);
|
||||
let resolvedBannerRepeat = (json.comment?.banner_repeat && json.comment.banner_repeat !== 'null') ? json.comment.banner_repeat : ((session.banner_repeat && session.banner_repeat !== 'null') ? session.banner_repeat : null);
|
||||
|
||||
if (!resolvedBannerFile && this.lastData && currentUsername) {
|
||||
const existingByMe = this.lastData.find(c =>
|
||||
c.username && c.username.toLowerCase() === currentUsername.toLowerCase() && c.banner_file && c.banner_file !== 'null'
|
||||
);
|
||||
if (existingByMe) {
|
||||
resolvedBannerFile = existingByMe.banner_file || null;
|
||||
resolvedBannerPosition = existingByMe.banner_position || null;
|
||||
resolvedBannerSize = existingByMe.banner_size || null;
|
||||
resolvedBannerRepeat = existingByMe.banner_repeat || null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolvedBannerFile && currentUsername) {
|
||||
const existingCommentEl = document.querySelector(`.comment-author[href="/user/${currentUsername}"], .user-infobox-block[style*="--author-banner"]`);
|
||||
if (existingCommentEl) {
|
||||
const commentDiv = existingCommentEl.closest('.comment') || existingCommentEl.closest('.user-infobox-block');
|
||||
if (commentDiv) {
|
||||
const style = commentDiv.getAttribute('style') || '';
|
||||
const bannerMatch = style.match(/--author-banner:\s*url\(['"]?\/a\/([^'"]+)['"]?\)/);
|
||||
if (bannerMatch) {
|
||||
resolvedBannerFile = bannerMatch[1];
|
||||
const posMatch = style.match(/--author-banner-position:\s*([^;]+)/);
|
||||
if (posMatch) resolvedBannerPosition = posMatch[1].trim();
|
||||
const sizeMatch = style.match(/--author-banner-size:\s*([^;]+)/);
|
||||
if (sizeMatch) resolvedBannerSize = sizeMatch[1].trim();
|
||||
const repeatMatch = style.match(/--author-banner-repeat:\s*([^;]+)/);
|
||||
if (repeatMatch) resolvedBannerRepeat = repeatMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedBannerFile && window.f0ckSession) {
|
||||
window.f0ckSession.banner_file = resolvedBannerFile;
|
||||
if (resolvedBannerPosition) window.f0ckSession.banner_position = resolvedBannerPosition;
|
||||
if (resolvedBannerSize) window.f0ckSession.banner_size = resolvedBannerSize;
|
||||
if (resolvedBannerRepeat) window.f0ckSession.banner_repeat = resolvedBannerRepeat;
|
||||
}
|
||||
|
||||
const newComment = {
|
||||
id: json.comment.id,
|
||||
item_id: this.itemId,
|
||||
@@ -3592,15 +3445,11 @@ class CommentSystem {
|
||||
files: files,
|
||||
created_at: json.comment.created_at || new Date().toISOString(),
|
||||
username: currentUsername,
|
||||
user_id: (json.comment && json.comment.user_id) || session.id || session.user_id || null,
|
||||
user_id: session.id || null,
|
||||
display_name: session.display_name || null,
|
||||
avatar: resolvedAvatar,
|
||||
avatar_file: resolvedAvatarFile,
|
||||
username_color: (json.comment && json.comment.username_color) || session.username_color || null,
|
||||
banner_file: resolvedBannerFile,
|
||||
banner_position: resolvedBannerPosition,
|
||||
banner_size: resolvedBannerSize,
|
||||
banner_repeat: resolvedBannerRepeat,
|
||||
username_color: session.username_color || null,
|
||||
is_deleted: false,
|
||||
is_pinned: false,
|
||||
video_time: json.comment.video_time ?? null,
|
||||
@@ -3627,7 +3476,7 @@ class CommentSystem {
|
||||
if (!this.lastData) this.lastData = [];
|
||||
const existingInLastData = this.lastData.find(c => c.id === newComment.id);
|
||||
if (existingInLastData) {
|
||||
Object.assign(existingInLastData, newComment);
|
||||
existingInLastData.files = files;
|
||||
} else {
|
||||
if (this.sort === 'new') this.lastData.unshift(newComment);
|
||||
else this.lastData.push(newComment);
|
||||
@@ -3754,9 +3603,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');
|
||||
@@ -4853,44 +4702,3 @@ document.addEventListener('f0ck:contentLoaded', () => {
|
||||
|
||||
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
|
||||
// Looking at f0ck.js, it seems to just replace innerHTML.
|
||||
|
||||
(function initCommentHeightObserver() {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
const observer = new ResizeObserver(entries => {
|
||||
for (const entry of entries) {
|
||||
const height = entry.contentRect ? entry.contentRect.height : entry.target.offsetHeight;
|
||||
if (height > 90) {
|
||||
entry.target.classList.add('tall-comment');
|
||||
} else {
|
||||
entry.target.classList.remove('tall-comment');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const observeAll = (root = document) => {
|
||||
root.querySelectorAll('.comment').forEach(el => observer.observe(el));
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => observeAll());
|
||||
} else {
|
||||
observeAll();
|
||||
}
|
||||
|
||||
if (window.MutationObserver) {
|
||||
const mutObs = new MutationObserver(mutations => {
|
||||
for (const m of mutations) {
|
||||
m.addedNodes.forEach(node => {
|
||||
if (node.nodeType === 1) {
|
||||
if (node.classList && node.classList.contains('comment')) observer.observe(node);
|
||||
if (node.querySelectorAll) node.querySelectorAll('.comment').forEach(el => observer.observe(el));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
mutObs.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -137,13 +137,10 @@
|
||||
const statusDiv = document.getElementById('avatar-upload-status');
|
||||
const preview = document.getElementById('avatar-preview');
|
||||
|
||||
const showStatus = (msg, type = 'info') => {
|
||||
if (msg && window.flashMessage) {
|
||||
window.flashMessage(msg, 2500, type);
|
||||
}
|
||||
const showStatus = (msg, type) => {
|
||||
if (statusDiv) {
|
||||
statusDiv.textContent = msg;
|
||||
statusDiv.className = 'avatar-status ' + (type || '');
|
||||
statusDiv.className = 'avatar-status ' + type;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,7 +177,6 @@
|
||||
filenameSpan.textContent = file.name;
|
||||
uploadBtn.disabled = false;
|
||||
showStatus('', '');
|
||||
uploadBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1123,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) {
|
||||
@@ -1678,11 +1583,8 @@
|
||||
|
||||
// Two-way sync: swatch → text input
|
||||
if (colorPicker && colorHex) {
|
||||
const colorBadge = document.getElementById('color-picker-badge');
|
||||
|
||||
colorPicker.addEventListener('input', () => {
|
||||
colorHex.value = colorPicker.value;
|
||||
if (colorBadge) colorBadge.style.background = colorPicker.value;
|
||||
const liveBox = document.getElementById('live-profile-preview-box');
|
||||
if (liveBox) {
|
||||
liveBox.style.setProperty('--author-accent', colorPicker.value);
|
||||
@@ -1690,11 +1592,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
colorPicker.addEventListener('change', () => {
|
||||
if (colorBadge) colorBadge.style.background = colorPicker.value;
|
||||
if (saveColorBtn) saveColorBtn.click();
|
||||
});
|
||||
|
||||
// Text input → swatch (validate on each keystroke)
|
||||
colorHex.addEventListener('input', () => {
|
||||
const val = colorHex.value.trim();
|
||||
@@ -1864,9 +1761,7 @@
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
await applyFontLive(font);
|
||||
if (window.flashMessage) {
|
||||
window.flashMessage('Website font updated!', 2500, 'success');
|
||||
}
|
||||
showStatus('Website font updated!', 'success');
|
||||
} else {
|
||||
alert(data.msg || 'Error saving font preference');
|
||||
}
|
||||
@@ -1882,35 +1777,6 @@
|
||||
const saveDescriptionBtn = document.getElementById('btn-save-description');
|
||||
const descriptionStatus = document.getElementById('description-status');
|
||||
|
||||
const liveDesc = document.getElementById('live-preview-description');
|
||||
const descControls = document.getElementById('desc-edit-controls');
|
||||
|
||||
if (liveDesc && descriptionTextarea) {
|
||||
liveDesc.addEventListener('focus', () => {
|
||||
if (descControls) descControls.style.display = 'flex';
|
||||
});
|
||||
|
||||
liveDesc.addEventListener('input', () => {
|
||||
descriptionTextarea.value = liveDesc.innerText.trim();
|
||||
});
|
||||
|
||||
liveDesc.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
liveDesc.blur();
|
||||
if (saveDescriptionBtn) saveDescriptionBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (descriptionTextarea) {
|
||||
descriptionTextarea.addEventListener('input', () => {
|
||||
if (liveDesc && document.activeElement !== liveDesc) {
|
||||
liveDesc.textContent = descriptionTextarea.value || '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (saveDescriptionBtn && descriptionTextarea) {
|
||||
saveDescriptionBtn.addEventListener('click', async () => {
|
||||
const description = descriptionTextarea.value;
|
||||
@@ -1991,13 +1857,10 @@
|
||||
const emailStatus = document.getElementById('email-status');
|
||||
const displayEmail = document.getElementById('display-email');
|
||||
|
||||
const showAccountStatus = (el, msg, type = 'info') => {
|
||||
if (msg && window.flashMessage) {
|
||||
window.flashMessage(msg, 2500, type);
|
||||
}
|
||||
const showAccountStatus = (el, msg, type) => {
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.className = 'avatar-status ' + (type || '');
|
||||
el.className = 'avatar-status ' + type;
|
||||
if (type === 'success') {
|
||||
setTimeout(() => { el.textContent = ''; el.className = 'avatar-status'; }, 5000);
|
||||
}
|
||||
@@ -2083,36 +1946,11 @@
|
||||
const displayNameBtn = document.getElementById('btn-update-display-name');
|
||||
const displayNameInput = document.getElementById('display_name_input');
|
||||
const displayNameStatus = document.getElementById('display-name-status');
|
||||
const liveUsernameEl = document.getElementById('live-preview-username');
|
||||
|
||||
if (liveUsernameEl && displayNameInput && displayNameBtn) {
|
||||
liveUsernameEl.addEventListener('input', () => {
|
||||
displayNameInput.value = liveUsernameEl.textContent.trim();
|
||||
});
|
||||
|
||||
liveUsernameEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
liveUsernameEl.blur();
|
||||
}
|
||||
});
|
||||
|
||||
liveUsernameEl.addEventListener('blur', () => {
|
||||
const val = liveUsernameEl.textContent.trim();
|
||||
if (!val) {
|
||||
liveUsernameEl.textContent = window.f0ckSession?.user || 'Username';
|
||||
displayNameInput.value = window.f0ckSession?.user || '';
|
||||
} else {
|
||||
displayNameInput.value = val;
|
||||
}
|
||||
displayNameBtn.click();
|
||||
});
|
||||
}
|
||||
|
||||
if (displayNameBtn && displayNameInput) {
|
||||
displayNameInput.addEventListener('input', () => {
|
||||
if (liveUsernameEl && document.activeElement !== liveUsernameEl) {
|
||||
liveUsernameEl.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
|
||||
const liveName = document.getElementById('live-preview-username');
|
||||
if (liveName) {
|
||||
liveName.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,11 +268,10 @@
|
||||
// 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, '>');
|
||||
const rendered = quoteEmojis
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
|
||||
: quoteContent;
|
||||
return `<span class="greentext">>${rendered.replace(/>/g, '>')}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit to prevent marked.parse recursion on single giant lines
|
||||
@@ -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;
|
||||
|
||||
@@ -498,7 +502,7 @@
|
||||
}
|
||||
|
||||
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
|
||||
const pollHtml = renderSidebarPoll(c.poll, c.id, c.item_id);
|
||||
|
||||
// Build avatar URL — same priority as the rest of the app
|
||||
let avatarSrc = '/a/default.png';
|
||||
@@ -543,27 +547,14 @@
|
||||
|
||||
itemPreview = `
|
||||
<div class="item-preview">
|
||||
<a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
||||
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||
<a href="/${c.item_id}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
|
||||
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} »</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
|
||||
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
|
||||
let bannerFile = c.banner_file;
|
||||
let bannerPos = c.banner_position;
|
||||
let bannerSz = c.banner_size;
|
||||
if (!bannerFile && window.f0ckSession && window.f0ckSession.id && (c.user_id === window.f0ckSession.id || c.username === window.f0ckSession.user)) {
|
||||
bannerFile = window.f0ckSession.banner_file;
|
||||
bannerPos = window.f0ckSession.banner_position;
|
||||
bannerSz = window.f0ckSession.banner_size;
|
||||
}
|
||||
const bannerStyle = (bannerEnabled && bannerFile && bannerFile !== 'null')
|
||||
? `style="--author-banner: url('/a/${bannerFile}'); --author-banner-position: ${bannerPos === 'center' ? 'center top' : (bannerPos || 'center top')}; --author-banner-size: ${(bannerSz && bannerSz !== 'cover') ? bannerSz : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<div class="comment" id="sc${c.id}" ${bannerStyle}>
|
||||
<div class="comment" id="sc${c.id}">
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
|
||||
@@ -168,48 +168,11 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
|
||||
if (row) { row.classList.add('uut-job--fading'); setTimeout(() => row.remove(), 300); }
|
||||
setTimeout(() => { _active.delete(jobId); _sync(); }, 350);
|
||||
|
||||
// Toast flash notification
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
const msg = (window.f0ckI18n && window.f0ckI18n.notif_upload_success) || 'Your background upload is finished!';
|
||||
window.flashMessage(msg, 3500, 'success');
|
||||
}
|
||||
|
||||
// Refresh notification bell count and items
|
||||
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
|
||||
window.NotificationSystemInstance.pollDebounced();
|
||||
}
|
||||
|
||||
const itemId = notif.item_id;
|
||||
if (itemId && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
const grid = document.querySelector('.posts');
|
||||
if (grid && !grid.querySelector(`a[href$="/${itemId}"]`)) {
|
||||
fetch(`/api/v2/item/${itemId}`)
|
||||
.then(r => r.json())
|
||||
.then(res => {
|
||||
if (res && res.success && res.rows) {
|
||||
const item = res.rows;
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: item.id,
|
||||
dest: item.dest,
|
||||
mime: item.mime,
|
||||
username: item.username,
|
||||
display_name: item.display_name || null,
|
||||
tag_id: item.rating_tag_id || item.tag_id || 0,
|
||||
is_oc: !!item.is_oc,
|
||||
slug: item.slug,
|
||||
visibility: item.visibility || 0
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.error('[UrlTracker] Failed to fetch item for live injection:', err));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Failure: update the row to show the error, keep until user closes
|
||||
const row = _$('uut-active-list')?.querySelector(`[data-jid="${CSS.escape(jobId)}"]`);
|
||||
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
|
||||
if (row) {
|
||||
const errMsg = notif.data?.msg || i18n().url_tracker_failed || 'Upload failed';
|
||||
const stageEl = row.querySelector('.uut-stage');
|
||||
if (stageEl) {
|
||||
if (notif.item_id) {
|
||||
@@ -225,13 +188,6 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||
if (barFill) { barFill.style.width = '100%'; barFill.style.background = '#ff5050'; barFill.style.backgroundImage = 'none'; barFill.style.animation = 'none'; }
|
||||
}
|
||||
_sync(); // update badge (count stays 0 for resolved jobs)
|
||||
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('✕ ' + errMsg, 4500, 'error');
|
||||
}
|
||||
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function') {
|
||||
window.NotificationSystemInstance.pollDebounced();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -952,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 = '';
|
||||
@@ -975,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';
|
||||
@@ -1221,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
|
||||
}
|
||||
@@ -1534,8 +1490,6 @@ window.initUploadForm = (selector) => {
|
||||
infoRow.className = 'file-meta-row-small';
|
||||
|
||||
let ratingSwitch = '';
|
||||
let visibilitySwitch = '';
|
||||
let expirySwitch = '';
|
||||
let tagsUI = '';
|
||||
let ocUI = '';
|
||||
let commentUI = '';
|
||||
@@ -1563,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 = `
|
||||
@@ -1656,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');
|
||||
@@ -2550,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: {
|
||||
@@ -2564,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,
|
||||
@@ -2673,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;
|
||||
@@ -2696,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');
|
||||
@@ -2749,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,
|
||||
@@ -2846,14 +2717,39 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
}
|
||||
|
||||
// URL background uploads do not redirect; standard file uploads redirect back to main page ('/')
|
||||
const isBgUrlUpload = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (isBgUrlUpload) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage((window.f0ckI18n && window.f0ckI18n.url_upload_started) || 'Background upload started', 3000, 'info');
|
||||
// Helper: inject the new item into the grid (deduplication is handled inside handleNewItem)
|
||||
const injectNewItem = () => {
|
||||
if (lastData && !lastData.manual_approval && !lastData.pending &&
|
||||
lastData.itemid && lastData.dest && lastData.mime &&
|
||||
window.NotificationSystemInstance &&
|
||||
typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: lastData.itemid,
|
||||
dest: lastData.dest,
|
||||
mime: lastData.mime,
|
||||
username: lastData.username || window.f0ckSession?.user || '',
|
||||
display_name: lastData.display_name || window.f0ckSession?.display_name || null,
|
||||
tag_id: lastData.tag_id ?? 0,
|
||||
is_oc: !!lastData.is_oc
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Skip redirect if every item was a background URL job
|
||||
const allPending = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (!allPending) {
|
||||
// Inject now if the grid is already in the DOM (upload modal open on main page)
|
||||
injectNewItem();
|
||||
// Navigate to main page, then inject again after the grid has loaded.
|
||||
// Awaiting loadPageAjax ensures the .posts grid DOM is present before the
|
||||
// handleNewItem call — this covers the item-page drag-and-upload scenario
|
||||
// where no grid exists until after navigation completes.
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
await window.loadPageAjax('/', true, { bypassCache: true });
|
||||
injectNewItem();
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
restoreBtn();
|
||||
|
||||
@@ -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))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -175,9 +169,6 @@
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
|
||||
@@ -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, '>');
|
||||
const rendered = quoteEmojis
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
return `<span class="greentext">>${quoteContent.replace(/>/g, '>')}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit
|
||||
@@ -410,16 +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);
|
||||
|
||||
const bannerStyle = (c.banner_file && c.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
return `<div class="comment" id="c${c.id}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
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() {
|
||||
|
||||
@@ -1,529 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import db from "../src/inc/sql.mjs";
|
||||
import lib from "../src/inc/lib.mjs";
|
||||
import cfg from "../src/inc/config.mjs";
|
||||
import queue from "../src/inc/queue.mjs";
|
||||
import f0cklib from "../src/inc/routeinc/f0cklib.mjs";
|
||||
import { getBypassDuplicateCheck } from "../src/inc/settings.mjs";
|
||||
|
||||
// Helper for parsing CLI flags
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
user: null,
|
||||
rating: null,
|
||||
tags: [],
|
||||
dir: cfg.paths.import || path.resolve("f0ckm-data/import"),
|
||||
keep: false,
|
||||
bypass: null, // null means use config default, true = force bypass, false = force check
|
||||
is_oc: false,
|
||||
title: null,
|
||||
help: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else if (arg === '--user' || arg === '-u') {
|
||||
options.user = args[++i];
|
||||
} else if (arg === '--rating' || arg === '-r') {
|
||||
options.rating = args[++i]?.toLowerCase();
|
||||
} else if (arg === '--tags' || arg === '-t') {
|
||||
const rawTags = args[++i];
|
||||
if (rawTags) {
|
||||
options.tags = rawTags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
} else if (arg === '--dir' || arg === '-d') {
|
||||
options.dir = path.resolve(args[++i]);
|
||||
} else if (arg === '--keep' || arg === '-k') {
|
||||
options.keep = true;
|
||||
} else if (arg === '--bypass' || arg === '-b') {
|
||||
options.bypass = true;
|
||||
} else if (arg === '--check') {
|
||||
options.bypass = false;
|
||||
} else if (arg === '--oc') {
|
||||
options.is_oc = true;
|
||||
} else if (arg === '--title') {
|
||||
options.title = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
f0ckm Bulk Import Script
|
||||
========================
|
||||
Imports all media files in f0ckm-data/import (or custom directory) into f0ckm.
|
||||
|
||||
Usage:
|
||||
node scripts/import.mjs [options]
|
||||
|
||||
Options:
|
||||
--user, -u <username> User to attribute uploads to (defaults to first admin)
|
||||
--rating, -r <sfw|nsfw|nsfl|untagged> Default rating for imported items
|
||||
--tags, -t <tag1,tag2> Global tags to assign to imported items
|
||||
--dir, -d <path> Directory to scan (default: f0ckm-data/import)
|
||||
--keep, -k Keep source files in import dir after import (default: delete)
|
||||
--bypass, -b Force bypass of duplicate checksum/pHash checks
|
||||
--check Force duplicate checking even if config enables bypass
|
||||
--oc Mark all imported files as Original Content (OC)
|
||||
--title <title> Set title for imported files
|
||||
--help, -h Show this help screen
|
||||
|
||||
Folder & Sidecar JSON Features:
|
||||
- Rating Subfolders: Files in 'import/sfw/', 'import/nsfw/', or 'import/nsfl/' automatically receive that rating.
|
||||
- Sidecar Metadata: Place a file.jpg.json or file.json alongside file.jpg to supply metadata:
|
||||
{ "title": "My Post", "rating": "sfw", "tags": ["funny", "cat"], "comment": "First comment", "is_oc": true }
|
||||
`);
|
||||
}
|
||||
|
||||
// Derive archive MIME types from cfg.mimes
|
||||
const ARCHIVE_MIMES = new Set(
|
||||
Object.entries(cfg.mimes || {})
|
||||
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
|
||||
.map(([mime]) => mime)
|
||||
);
|
||||
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
|
||||
|
||||
// Recursively collect all target files in directory (follows symlinked dirs and files)
|
||||
async function collectFiles(dir, visitedDirs = new Set()) {
|
||||
const results = [];
|
||||
|
||||
let canonicalDir;
|
||||
try {
|
||||
canonicalDir = await fs.promises.realpath(dir);
|
||||
} catch (_) {
|
||||
canonicalDir = dir;
|
||||
}
|
||||
|
||||
if (visitedDirs.has(canonicalDir)) {
|
||||
return results; // Avoid infinite loops on circular symlink references
|
||||
}
|
||||
visitedDirs.add(canonicalDir);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(dir);
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: unable to read directory '${dir}':`, e.message);
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const name of entries) {
|
||||
if (name.startsWith('.')) continue; // skip hidden files & .gitkeep
|
||||
const fullPath = path.join(dir, name);
|
||||
|
||||
try {
|
||||
// fs.promises.stat follows symlinks to inspect the underlying file/dir
|
||||
const st = await fs.promises.stat(fullPath);
|
||||
|
||||
if (st.isDirectory()) {
|
||||
const subResults = await collectFiles(fullPath, visitedDirs);
|
||||
results.push(...subResults);
|
||||
} else if (st.isFile()) {
|
||||
if (name.endsWith('.json')) continue; // skip sidecar metadata files
|
||||
results.push(fullPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[IMPORT] Warning: unable to stat '${fullPath}' (broken symlink?):`, err.message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Read optional sidecar JSON metadata
|
||||
async function readSidecarMetadata(filePath) {
|
||||
const candidates = [
|
||||
`${filePath}.json`,
|
||||
path.join(path.dirname(filePath), `${path.parse(filePath).name}.json`)
|
||||
];
|
||||
|
||||
for (const cand of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(cand)) {
|
||||
const raw = await fs.promises.readFile(cand, 'utf-8');
|
||||
return { meta: JSON.parse(raw), sidecarPath: cand };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: failed to parse sidecar JSON at ${cand}:`, e.message);
|
||||
}
|
||||
}
|
||||
return { meta: {}, sidecarPath: null };
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (opts.help) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`--- f0ckm Bulk Import Starting ---`);
|
||||
console.log(`Scan Directory: ${opts.dir}`);
|
||||
|
||||
if (!fs.existsSync(opts.dir)) {
|
||||
console.error(`[IMPORT ERROR] Directory does not exist: ${opts.dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. Resolve Target User
|
||||
let targetUser = null;
|
||||
if (opts.user) {
|
||||
const userRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE LOWER(login) = ${opts.user.toLowerCase()} OR LOWER("user") = ${opts.user.toLowerCase()}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (userRows.length > 0) {
|
||||
targetUser = userRows[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] Specified user '${opts.user}' not found in database.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const adminRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE admin = true
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (adminRows.length > 0) {
|
||||
targetUser = adminRows[0];
|
||||
} else {
|
||||
const anyUser = await db`SELECT id, "user", login, admin, is_moderator FROM "user" ORDER BY id ASC LIMIT 1`;
|
||||
if (anyUser.length > 0) {
|
||||
targetUser = anyUser[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] No users found in database. Please run npm run create-admin first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Attributing uploads to user: ${targetUser.user} (ID: ${targetUser.id})`);
|
||||
|
||||
// Ensure target directories exist
|
||||
await fs.promises.mkdir(cfg.paths.b, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.t, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.ca, { recursive: true });
|
||||
|
||||
// 2. Discover files
|
||||
const fileList = await collectFiles(opts.dir);
|
||||
|
||||
if (fileList.length === 0) {
|
||||
console.log(`[IMPORT] No files found in ${opts.dir}. Place files in '${opts.dir}' and re-run.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${fileList.length} file(s) to process.\n`);
|
||||
|
||||
const allowedCats = Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.map(c => c.toLowerCase()) : null;
|
||||
const allowedMimes = allowedCats
|
||||
? Object.keys(cfg.mimes).filter(m => allowedCats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)))
|
||||
: Object.keys(cfg.mimes);
|
||||
|
||||
let successCount = 0;
|
||||
let skippedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 3. Process each file sequentially
|
||||
for (let index = 0; index < fileList.length; index++) {
|
||||
const filePath = fileList[index];
|
||||
const baseName = path.basename(filePath);
|
||||
const relPath = path.relative(opts.dir, filePath);
|
||||
const prefix = `[${index + 1}/${fileList.length}]`;
|
||||
|
||||
try {
|
||||
// Check sidecar JSON metadata
|
||||
const { meta: sidecarMeta, sidecarPath } = await readSidecarMetadata(filePath);
|
||||
|
||||
// Determine Rating: Sidecar -> CLI option -> Folder name (import/sfw/ etc.) -> Default
|
||||
let effectiveRating = sidecarMeta.rating || opts.rating;
|
||||
if (!effectiveRating) {
|
||||
const parentDirName = path.basename(path.dirname(filePath)).toLowerCase();
|
||||
if (['sfw', 'nsfw', 'nsfl', 'untagged'].includes(parentDirName)) {
|
||||
effectiveRating = parentDirName;
|
||||
}
|
||||
}
|
||||
if (effectiveRating === 'untagged') effectiveRating = null;
|
||||
if (!effectiveRating && !cfg.websrv.shitpost_mode) {
|
||||
effectiveRating = 'sfw'; // Default fallback if rating required
|
||||
}
|
||||
|
||||
// Determine Title & Comments & OC
|
||||
const effectiveTitle = sidecarMeta.title || opts.title || null;
|
||||
const effectiveComment = sidecarMeta.comment || null;
|
||||
const effectiveIsOc = (sidecarMeta.is_oc !== undefined) ? !!sidecarMeta.is_oc : opts.is_oc;
|
||||
const effectiveVisibility = (typeof sidecarMeta.visibility === 'number') ? sidecarMeta.visibility : 0;
|
||||
|
||||
// Collect tags
|
||||
let mergedTags = [...opts.tags];
|
||||
if (Array.isArray(sidecarMeta.tags)) {
|
||||
mergedTags.push(...sidecarMeta.tags);
|
||||
} else if (typeof sidecarMeta.tags === 'string') {
|
||||
mergedTags.push(...sidecarMeta.tags.split(',').map(t => t.trim()));
|
||||
}
|
||||
mergedTags = mergedTags.filter(t => t && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
|
||||
// Verify file stats & MIME
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.size === 0) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} (0 bytes)`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect actual MIME via file command (with -L to follow symlinks)
|
||||
let actualMime = (await queue.spawn('file', ['-L', '--mime-type', '-b', filePath])).stdout.trim();
|
||||
|
||||
if ((actualMime === 'application/octet-stream' || !actualMime) && baseName.toLowerCase().endsWith('.swf')) {
|
||||
actualMime = 'application/x-shockwave-flash';
|
||||
}
|
||||
|
||||
if (!allowedMimes.includes(actualMime)) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Unsupported MIME type: '${actualMime}'`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase A: SHA-256, PHash, Dimensions, Audio-MP4 probe
|
||||
const needsPHash = !isArchiveMime(actualMime);
|
||||
const needsDims = !isArchiveMime(actualMime);
|
||||
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
|
||||
|
||||
const [checksum, phash, dimResult, audioMp4Result] = await Promise.all([
|
||||
queue.spawn('sha256sum', [filePath]).then(r => r.stdout.trim().split(' ')[0]),
|
||||
needsPHash ? queue.generatePHash(filePath).catch(e => { console.error(`[IMPORT] PHash error:`, e.message); return null; }) : Promise.resolve(null),
|
||||
needsDims ? (async () => {
|
||||
try {
|
||||
if (actualMime.startsWith('image/')) {
|
||||
const { stdout: magickOut } = await queue.spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { quiet: true, ignoreExitCode: true });
|
||||
const match = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
|
||||
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
|
||||
const { stdout: probeOut } = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath], { quiet: true, ignoreExitCode: true });
|
||||
const dimParts = probeOut.trim().split(',');
|
||||
if (dimParts.length >= 2) {
|
||||
const w = parseInt(dimParts[0], 10);
|
||||
const h = parseInt(dimParts[1], 10);
|
||||
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null),
|
||||
needsAudioMP4 ? (async () => {
|
||||
const ext = baseName.split('.').pop().toLowerCase();
|
||||
if (['m4a', 'aac'].includes(ext)) return 'audio/mp4';
|
||||
try {
|
||||
const probe = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath]);
|
||||
if (!probe.stdout.trim()) return 'audio/mp4';
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (audioMp4Result) {
|
||||
actualMime = audioMp4Result;
|
||||
}
|
||||
|
||||
// Phase B: Duplicate check
|
||||
const bypassDupes = (opts.bypass !== null) ? opts.bypass : (getBypassDuplicateCheck() || cfg.websrv.bypass_duplicate_check === true);
|
||||
if (!bypassDupes) {
|
||||
const [repostBySum, repostByPhash] = await Promise.all([
|
||||
queue.checkrepostsum(checksum),
|
||||
phash ? queue.checkrepostphash(phash) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (repostBySum) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Duplicate file (matches existing Item #${repostBySum})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
if (repostByPhash) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Visual duplicate (matches existing Item #${repostByPhash})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate target filename & UUID
|
||||
const uuid = await queue.genuuid();
|
||||
const ext = cfg.mimes[actualMime] || 'bin';
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const destPath = path.join(cfg.paths.b, filename);
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
// Copy file to public/b
|
||||
await fs.promises.copyFile(filePath, destPath);
|
||||
|
||||
const insertChecksum = bypassDupes ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
|
||||
// Insert into items table
|
||||
await db`
|
||||
INSERT INTO items ${db({
|
||||
src: '',
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
size: stat.size,
|
||||
checksum: insertChecksum,
|
||||
phash: phash,
|
||||
username: targetUser.user,
|
||||
userchannel: 'import_script',
|
||||
usernetwork: 'local',
|
||||
stamp: nowStamp,
|
||||
active: true,
|
||||
is_oc: effectiveIsOc,
|
||||
original_filename: baseName,
|
||||
title: effectiveTitle,
|
||||
width: dimResult?.width ?? null,
|
||||
height: dimResult?.height ?? null,
|
||||
visibility: effectiveVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: null
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
|
||||
`;
|
||||
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
// Subscribe uploader to comments
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${targetUser.id}, ${itemid})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
} catch (_) {}
|
||||
|
||||
// Generate thumbnails
|
||||
try {
|
||||
await queue.genThumbnail(filename, actualMime, itemid, '', false, 512);
|
||||
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`${prefix} [WARN] Thumbnail generation warning for #${itemid}:`, err.message);
|
||||
}
|
||||
|
||||
// Blurred thumbnail
|
||||
await queue.genBlurredThumbnail(itemid, false);
|
||||
|
||||
// Assign Rating Tag
|
||||
if (effectiveRating) {
|
||||
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: targetUser.id })}
|
||||
`;
|
||||
}
|
||||
|
||||
// Assign Custom Tags
|
||||
for (const tagName of mergedTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
}
|
||||
const tagId = tagRow[0].id;
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
// Auto OC tags
|
||||
if (effectiveIsOc) {
|
||||
for (const tagname of ['oc', 'original content']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto Flash tags
|
||||
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
|
||||
for (const tagname of ['Flash', 'SWF']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional first comment
|
||||
if (effectiveComment && effectiveComment.trim().length > 0) {
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: targetUser.id,
|
||||
content: effectiveComment.trim()
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
// PostgreSQL Live Notification
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
username: targetUser.user,
|
||||
display_name: targetUser.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!effectiveIsOc
|
||||
})})`;
|
||||
} catch (_) {}
|
||||
|
||||
// Clear cache
|
||||
f0cklib.clearCountCache();
|
||||
|
||||
// Cleanup imported source file unless --keep was passed
|
||||
if (!opts.keep) {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
if (sidecarPath) {
|
||||
await fs.promises.unlink(sidecarPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${prefix} [SUCCESS] Imported '${relPath}' -> Item #${itemid} (Rating: ${effectiveRating || 'untagged'}, MIME: ${actualMime})`);
|
||||
successCount++;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`${prefix} [ERROR] Failed to import '${relPath}':`, err.message);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(`Import Complete!`);
|
||||
console.log(`Total Scanned : ${fileList.length}`);
|
||||
console.log(`Successful : ${successCount}`);
|
||||
console.log(`Skipped : ${skippedCount}`);
|
||||
console.log(`Failed : ${failedCount}`);
|
||||
console.log(`========================================`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runImport().catch(err => {
|
||||
console.error(`[IMPORT FATAL ERROR]`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -190,20 +190,15 @@ export const handleBannerUpload = async (req, res) => {
|
||||
update user_options
|
||||
set banner_file = ${finalFilename},
|
||||
banner_position = ${parts.banner_position || 'center'},
|
||||
banner_size = ${parts.banner_size || 'cover'},
|
||||
banner_repeat = ${parts.banner_repeat || 'repeat'}
|
||||
banner_size = ${parts.banner_size || 'cover'}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
banner_file: finalFilename,
|
||||
banner_position: parts.banner_position || 'center',
|
||||
banner_size: parts.banner_size || 'cover',
|
||||
banner_repeat: parts.banner_repeat || 'repeat'
|
||||
banner_size: parts.banner_size || 'cover'
|
||||
});
|
||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||
} catch (dbErr) {
|
||||
@@ -278,9 +273,6 @@ export const handleBannerDelete = async (req, res) => {
|
||||
set banner_file = null
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
|
||||
@@ -12,23 +12,10 @@ config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.en
|
||||
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
|
||||
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
|
||||
|
||||
const isDocker = process.env.DB_HOST === 'f0ckm-db' || config.sql.host === 'f0ckm-db' || process.env.CONTAINER === 'true';
|
||||
if (process.env.NODE_ENV === 'production' || isDocker) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
config.main.development = false;
|
||||
}
|
||||
|
||||
if (config.main.development) {
|
||||
if (!process.env.DB_HOST && (config.sql.host === 'f0ckm-db' || !config.sql.host)) {
|
||||
config.sql.host = 'localhost';
|
||||
}
|
||||
if (!process.env.DB_PORT && config.sql.port === 5432) {
|
||||
config.sql.port = 5454;
|
||||
}
|
||||
if (!process.env.STORAGE_DIR) {
|
||||
process.env.STORAGE_DIR = 'f0ckm-data';
|
||||
}
|
||||
}
|
||||
|
||||
// Set timezone from config if not already set via environment variable
|
||||
if (!process.env.TZ && config.main.timezone) {
|
||||
process.env.TZ = config.main.timezone;
|
||||
@@ -66,12 +53,11 @@ config.paths = {
|
||||
koepfe: resolvePath('public/s/koepfe'),
|
||||
fonts: resolvePath('public/s/fonts'),
|
||||
memes: resolvePath('public/memes'),
|
||||
e: resolvePath('public/e'),
|
||||
e: resolvePath('e'),
|
||||
pending: resolvePath('pending'),
|
||||
deleted: resolvePath('deleted'),
|
||||
logs: resolvePath('logs'),
|
||||
tmp: resolvePath('tmp'),
|
||||
import: storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'))
|
||||
tmp: resolvePath('tmp')
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -38,16 +38,6 @@ export default new class {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
generateSlug(length = 11) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||
let slug = '';
|
||||
const bytes = crypto.randomBytes(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
slug += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||
};
|
||||
@@ -63,29 +53,6 @@ export default new class {
|
||||
const timeStr = t(unitKey, { n: interval });
|
||||
return t('timeago.ago', { t: timeStr });
|
||||
};
|
||||
expiresIn(expiresAt) {
|
||||
if (!expiresAt) return null;
|
||||
const expiresNum = parseInt(expiresAt, 10);
|
||||
if (isNaN(expiresNum)) return null;
|
||||
const now = ~~(Date.now() / 1000);
|
||||
const diff = expiresNum - now;
|
||||
if (diff <= 0) return "expiring now";
|
||||
|
||||
if (diff < 60) return `in ${diff}s`;
|
||||
if (diff < 3600) {
|
||||
const mins = Math.floor(diff / 60);
|
||||
return `in ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (diff < 86400) {
|
||||
const hours = Math.floor(diff / 3600);
|
||||
const mins = Math.floor((diff % 3600) / 60);
|
||||
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
}
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
md5(str) {
|
||||
return crypto.createHash('md5').update(str).digest("hex");
|
||||
};
|
||||
@@ -95,22 +62,21 @@ export default new class {
|
||||
getMode(mode) {
|
||||
let tmp;
|
||||
mode = Number(mode);
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
switch (mode) {
|
||||
case 1: // nsfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
|
||||
break;
|
||||
case 2: // untagged
|
||||
tmp = `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
|
||||
tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
|
||||
break;
|
||||
case 3: // all
|
||||
tmp = "1 = 1";
|
||||
break;
|
||||
case 4: // nsfl
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${nsflId})` : "1 = 0";
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0";
|
||||
break;
|
||||
default: // sfw
|
||||
tmp = `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`;
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
break;
|
||||
}
|
||||
return tmp;
|
||||
@@ -141,8 +107,7 @@ export default new class {
|
||||
parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`);
|
||||
}
|
||||
if (filtered.includes('untagged')) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
parts.push(`not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`);
|
||||
parts.push('not exists (select 1 from tags_assign where item_id = items.id)');
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return '(' + parts.join(' OR ') + ')';
|
||||
@@ -202,28 +167,27 @@ export default new class {
|
||||
const tagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const untagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})) and active = true and is_deleted = false
|
||||
where not exists (select 1 from tags_assign where item_id = items.id) and active = true
|
||||
`)[0].total;
|
||||
const sfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsfl = cfg.enable_nsfl ? +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true
|
||||
`)[0].total : 0;
|
||||
const deleted = +(await db`
|
||||
select count(*) as total
|
||||
@@ -282,22 +246,18 @@ export default new class {
|
||||
|
||||
return false;
|
||||
};
|
||||
async getTags(itemid, session = null) {
|
||||
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
|
||||
async getTags(itemid) {
|
||||
const tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join user_options uo on uo.user_id = "user".id
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
for (let t = 0; t < tags.length; t++) {
|
||||
tags[t].badge = this.getBadge(tags[t]);
|
||||
if (!hasSession) {
|
||||
delete tags[t].user;
|
||||
delete tags[t].display_name;
|
||||
}
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
@@ -500,15 +460,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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"meme": "Meme",
|
||||
"halls": "Hallen",
|
||||
"tags": "Tags",
|
||||
"chat": "Chat",
|
||||
"search": "Suchen",
|
||||
"random": "Zufall",
|
||||
"profile": "Profil",
|
||||
@@ -165,12 +166,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 +536,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 +795,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."
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"meme": "Meme",
|
||||
"halls": "Halls",
|
||||
"tags": "Tags",
|
||||
"chat": "Chat",
|
||||
"search": "Search",
|
||||
"random": "Random",
|
||||
"profile": "Profile",
|
||||
@@ -165,12 +166,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 +540,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 +797,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."
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
"meme": "meme",
|
||||
"halls": "Hallen",
|
||||
"tags": "Tags",
|
||||
"chat": "Chat",
|
||||
"search": "Zoeken",
|
||||
"random": "Willekeurig",
|
||||
"profile": "profiel",
|
||||
@@ -165,10 +166,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 +536,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:",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"meme": "Memen",
|
||||
"halls": "Hallen",
|
||||
"tags": "Etiketten",
|
||||
"chat": "Geschwafel",
|
||||
"search": "Suche",
|
||||
"random": "Zufall",
|
||||
"profile": "Profil",
|
||||
@@ -165,10 +166,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 +537,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:",
|
||||
|
||||
@@ -1,69 +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 computeBaseMode = (mode, ratings, session) => {
|
||||
const effMode = Number(mode ?? 0);
|
||||
const ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||
|
||||
let baseMode;
|
||||
if (effMode === 2) {
|
||||
if (ratingsArr && ratingsArr.includes('untagged') && ratingsArr.length > 1) {
|
||||
baseMode = lib.getMultiRatingMode(ratingsArr);
|
||||
} else {
|
||||
baseMode = lib.getMode(2);
|
||||
}
|
||||
} else if (effMode === 3) {
|
||||
baseMode = (ratingsArr && ratingsArr.length > 1) ? lib.getMultiRatingMode(ratingsArr) : lib.getMode(3);
|
||||
} else {
|
||||
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
baseMode = multiRatingSQL ?? lib.getMode(effMode);
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if ((effMode === 0 || effMode === 3 || mode === undefined || mode === null) && (!ratingsArr || ratingsArr.length <= 1)) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||
} else {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged) {
|
||||
if (effMode === 2 || (ratingsArr && ratingsArr.length === 1 && ratingsArr[0] === 'untagged')) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
}
|
||||
return baseMode;
|
||||
};
|
||||
|
||||
|
||||
const resolveNumericItemId = async (itemIdOrSlug) => {
|
||||
if (!itemIdOrSlug) return null;
|
||||
if (typeof itemIdOrSlug === 'number') return itemIdOrSlug;
|
||||
if (/^\d+$/.test(String(itemIdOrSlug))) return parseInt(itemIdOrSlug, 10);
|
||||
try {
|
||||
const rows = await db`SELECT id FROM items WHERE slug = ${String(itemIdOrSlug)} LIMIT 1`;
|
||||
return rows[0]?.id || null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
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);
|
||||
@@ -75,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, sort, timeframe, oc, pinned, hasComments }) {
|
||||
function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger }) {
|
||||
return JSON.stringify([
|
||||
modequery,
|
||||
tag ?? '',
|
||||
@@ -88,12 +31,7 @@ function buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, ex
|
||||
newerThan ?? '',
|
||||
minXd,
|
||||
userHallObj?.id ?? '',
|
||||
tagger ?? '',
|
||||
sort ?? '',
|
||||
timeframe ?? '',
|
||||
oc ? 1 : 0,
|
||||
pinned ? 1 : 0,
|
||||
hasComments ? 1 : 0
|
||||
tagger ?? ''
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -198,60 +136,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, sort, timeframe, oc, pinned, hasComments } = {}) => {
|
||||
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, tagger: rawTagger } = {}) => {
|
||||
const user = rawUser ? lib.escapeLike(decodeURI(rawUser)) : null;
|
||||
|
||||
// --- title: prefix — search items.title instead of the tags table ---
|
||||
@@ -301,44 +187,15 @@ export default {
|
||||
// xD filter: use materialized items.xd_score column (kept live by trigger) for fast indexed lookup
|
||||
const xdFilter = minXd > 0 ? db`and items.xd_score >= ${minXd}` : db``;
|
||||
|
||||
// Advanced filters
|
||||
const ocFilter = (oc === true || oc === '1' || oc === 'true') ? db`and items.is_oc = true` : db``;
|
||||
const pinnedFilter = (pinned === true || pinned === '1' || pinned === 'true') ? db`and items.is_pinned = true` : db``;
|
||||
const hasCommentsFilter = (hasComments === true || hasComments === '1' || hasComments === 'true')
|
||||
? db`and exists (select 1 from comments where item_id = items.id and is_deleted = false)`
|
||||
: db``;
|
||||
|
||||
let timeframeFilter = db``;
|
||||
if (timeframe && timeframe !== 'all') {
|
||||
let seconds = 0;
|
||||
if (timeframe === 'day') seconds = 86400;
|
||||
else if (timeframe === 'week') seconds = 86400 * 7;
|
||||
else if (timeframe === 'month') seconds = 86400 * 30;
|
||||
else if (timeframe === 'year') seconds = 86400 * 365;
|
||||
if (seconds > 0) {
|
||||
const minStamp = Math.floor(Date.now() / 1000) - seconds;
|
||||
timeframeFilter = db`and items.stamp >= ${minStamp}`;
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveSort = sort || (random ? 'random' : 'newest');
|
||||
let orderSQL = db`items.is_pinned desc, items.id desc`;
|
||||
if (effectiveSort === 'oldest') {
|
||||
orderSQL = db`items.is_pinned desc, items.id asc`;
|
||||
} else if (effectiveSort === 'popular' || effectiveSort === 'top' || effectiveSort === 'xd') {
|
||||
orderSQL = db`items.is_pinned desc, items.xd_score desc, items.id desc`;
|
||||
} else if (effectiveSort === 'comments') {
|
||||
orderSQL = db`items.is_pinned desc, (select count(*) from comments where item_id = items.id and is_deleted = false) desc, items.id desc`;
|
||||
} else if (effectiveSort === 'random') {
|
||||
orderSQL = db`random()`;
|
||||
}
|
||||
|
||||
const strictParams = ((strict || (tag && tag.includes(','))) && tag) ? tag.split(',').map(t => lib.slugify(t)).filter(t => t) : [];
|
||||
const 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, sort: effectiveSort, timeframe, oc, pinned, hasComments };
|
||||
const modequery = computeBaseMode(mode, ratings, session);
|
||||
const tmp = { user, tag: isTitleSearch ? _decodedTag : tag, hall: hallObj || hall, mime, page: actPage, mode: mode, view_mode: fav ? 'favs' : 'uploads', strict: strict, userHall: userHallObj || userHallSlug, userHallOwner, tagger };
|
||||
// Multi-rating support: if `ratings` array provided, build an OR-based SQL fragment
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -393,14 +250,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``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(items.visibility, 0) = 0`);
|
||||
|
||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger, sort: effectiveSort, timeframe, oc, pinned, hasComments });
|
||||
const cacheKey = buildCountCacheKey({ modequery, tag, user, hall, mime, fav, session, excludedTags, newerThan, minXd, userHallObj, tagger });
|
||||
let total = getCachedCount(cacheKey);
|
||||
|
||||
if (total === null) {
|
||||
@@ -411,7 +261,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -423,10 +272,6 @@ export default {
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||
${xdFilter}
|
||||
${ocFilter}
|
||||
${pinnedFilter}
|
||||
${hasCommentsFilter}
|
||||
${timeframeFilter}
|
||||
`;
|
||||
total = Number(totalRows[0].total);
|
||||
if (total > 0) setCachedCount(cacheKey, total);
|
||||
@@ -458,7 +303,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${fav ? db`and fav_u.user ilike ${user}` : db``}
|
||||
@@ -470,12 +314,8 @@ export default {
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
${newerThan ? db`and items.id > ${newerThan}` : db``}
|
||||
${xdFilter}
|
||||
${ocFilter}
|
||||
${pinnedFilter}
|
||||
${hasCommentsFilter}
|
||||
${timeframeFilter}
|
||||
${fav ? db`group by items.id, items.is_pinned` : db``}
|
||||
order by ${orderSQL}
|
||||
order by ${random ? db`random()` : db`items.is_pinned desc, items.id desc`}
|
||||
offset ${newerThan ? 0 : offset}
|
||||
limit ${eps}
|
||||
`;
|
||||
@@ -493,8 +333,6 @@ export default {
|
||||
const rows = (await db`
|
||||
select
|
||||
items.id,
|
||||
items.slug,
|
||||
items.visibility,
|
||||
items.mime,
|
||||
items.dest,
|
||||
items.username as username,
|
||||
@@ -590,18 +428,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 ---
|
||||
@@ -631,17 +458,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'
|
||||
@@ -655,16 +472,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 nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const itemModeQuery = session
|
||||
? computeBaseMode(mode, ratings, session)
|
||||
: (cfg.websrv.public_nsfw
|
||||
? `not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`
|
||||
: `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`
|
||||
);
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const modequery = multiRatingSQL ?? lib.getMode(effMode);
|
||||
|
||||
if (itemid === null) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
|
||||
let tagFilter = db``;
|
||||
let titleFilter = db``;
|
||||
@@ -703,18 +522,9 @@ export default {
|
||||
|
||||
// Helper to construct shared filter conditions
|
||||
const buildConditions = () => {
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(items.visibility, 0) = 0 or lower(items.username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(items.visibility, 0) = 0`);
|
||||
|
||||
return db`
|
||||
${db.unsafe(itemModeQuery)}
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
${visibilityFilter}
|
||||
and (items.expires_at IS NULL OR items.expires_at > ${Math.floor(Date.now() / 1000)})
|
||||
|
||||
${tagFilter}
|
||||
${titleFilter}
|
||||
${hallFilter}
|
||||
@@ -728,9 +538,11 @@ export default {
|
||||
};
|
||||
|
||||
const startTime = Date.now();
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for rawIdOrSlug=${rawIdOrSlug}`);
|
||||
console.log(`[${new Date().toISOString()}] [GETF0CK_OPT] Starting fetch for itemid=${itemid}`);
|
||||
|
||||
// 1. Fetch the main item
|
||||
// We only apply the active check and global NSFW filter (for guests) here.
|
||||
// We skip the 'mode' preference filter so that switching modes on an item view doesn't result in a 404 (post not visible).
|
||||
const items = await db`
|
||||
select distinct on (items.id)
|
||||
items.*,
|
||||
@@ -754,48 +566,15 @@ export default {
|
||||
left join "user_options" uo on uo.user_id = author_u.id
|
||||
${user_id ? db`left join user_video_views uvv on uvv.video_id = items.id and uvv.user_id = ${user_id}` : db``}
|
||||
where
|
||||
${itemLookup} and
|
||||
items.id = ${itemid} and
|
||||
items.active = true
|
||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const actitem = items[0];
|
||||
|
||||
if (!actitem) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
|
||||
const itemid = actitem.id;
|
||||
|
||||
// Check visibility permissions:
|
||||
const isOwnerOrAdmin = session && (
|
||||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase()) ||
|
||||
session.admin || session.is_moderator
|
||||
);
|
||||
|
||||
// If request was by sequential numeric ID (/123) and item visibility > 0 (unlisted/private):
|
||||
// Block numeric enumeration unless viewer is owner/admin
|
||||
if (isNumeric && actitem.visibility > 0 && !isOwnerOrAdmin) {
|
||||
return {
|
||||
success: false,
|
||||
message: "404 - upload not found"
|
||||
};
|
||||
}
|
||||
|
||||
// If item is Private (visibility === 2):
|
||||
// Direct link only allowed for owner/admin
|
||||
if (actitem.visibility === 2 && !isOwnerOrAdmin) {
|
||||
return {
|
||||
success: false,
|
||||
is_private: true,
|
||||
message: "403 - private upload"
|
||||
};
|
||||
}
|
||||
|
||||
if (user_id) {
|
||||
if (actitem && user_id) {
|
||||
db`
|
||||
insert into user_video_views (user_id, video_id, view_count, last_viewed)
|
||||
values (${user_id}, ${itemid}, 1, now())
|
||||
@@ -804,12 +583,15 @@ export default {
|
||||
last_viewed = now()
|
||||
`.catch(e => console.error('Failed to track view:', e));
|
||||
}
|
||||
// Guest global filter check for public items (unlisted items requested by direct link/slug bypass guest rating blocks)
|
||||
if (!session && getGlobalfilter() && (actitem.visibility || 0) === 0) {
|
||||
const filteredItem = await db`
|
||||
select 1 from tags_assign where item_id = ${itemid} and (${db.unsafe(getGlobalfilter())}) limit 1
|
||||
|
||||
if (!actitem) {
|
||||
// Item not found or filtered out - check if it exists but was filtered (for OG meta tags)
|
||||
if (!session && getGlobalfilter()) {
|
||||
const unfilteredItem = await db`
|
||||
select id from items where id = ${itemid} and active = true limit 1
|
||||
`;
|
||||
if (filteredItem.length > 0) {
|
||||
if (unfilteredItem[0]) {
|
||||
// Item exists but was filtered - return minimal data for OG tags with blurred thumbnail
|
||||
const hallSlug = hall && typeof hall === 'object' ? hall.slug : hall;
|
||||
return {
|
||||
success: false,
|
||||
@@ -824,6 +606,11 @@ export default {
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: "Sorry, this post is currently not visible."
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fetch Next/Prev/Start/End/Cheat in parallel
|
||||
@@ -831,11 +618,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 === 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
|
||||
@@ -846,7 +633,7 @@ export default {
|
||||
where
|
||||
${buildConditions()}
|
||||
${whereClause}
|
||||
group by items.id, items.slug
|
||||
group by items.id
|
||||
${orderBy}
|
||||
limit ${limit}
|
||||
`;
|
||||
@@ -861,7 +648,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
|
||||
@@ -870,7 +657,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``}
|
||||
@@ -897,7 +683,7 @@ export default {
|
||||
// Cheat array should include current item and neighbors, sorted
|
||||
const cheat = [itemid, ...cheatItems.map(i => i.id)].sort((a, b) => a - b);
|
||||
|
||||
const tags = await lib.getTags(itemid, session);
|
||||
const tags = await lib.getTags(itemid);
|
||||
const itemHalls = await db`select h.name, h.slug from halls h join halls_assign ha on ha.hall_id = h.id where ha.item_id = ${itemid}`;
|
||||
const userHallsForItem = user_id
|
||||
? await db`select uh.name, uh.slug from user_halls uh join user_halls_assign uha on uha.hall_id = uh.id where uha.item_id = ${itemid} and uh.user_id = ${user_id}`
|
||||
@@ -919,7 +705,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
|
||||
@@ -932,24 +718,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
|
||||
@@ -959,7 +745,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);
|
||||
}
|
||||
}
|
||||
@@ -982,29 +768,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 restriction check for public uploads (visibility === 0)
|
||||
// Direct links to public unrated/untagged posts are allowed for guests.
|
||||
if (!session && !isOwnerOrAdmin && (actitem.visibility || 0) === 0) {
|
||||
let guestBlocked = false;
|
||||
if (isNsfw && !cfg.websrv.public_nsfw) guestBlocked = true;
|
||||
else if (isNsfl) guestBlocked = true;
|
||||
|
||||
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.
|
||||
@@ -1017,7 +780,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,
|
||||
@@ -1034,11 +797,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: {
|
||||
@@ -1050,8 +808,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,
|
||||
@@ -1110,21 +866,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)],
|
||||
@@ -1132,14 +884,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;
|
||||
|
||||
@@ -1179,9 +924,9 @@ 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 ratingsArr = (Array.isArray(ratings) && ratings.length > 0) ? ratings : null;
|
||||
const multiRatingSQL = ratingsArr ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
const modequery = computeBaseMode(mode, ratings, session);
|
||||
const multiRatingSQL = (Array.isArray(ratings) && ratings.length > 0) ? lib.getMultiRatingMode(ratings) : null;
|
||||
const baseMode = multiRatingSQL ?? lib.getMode(mode ?? 0);
|
||||
const modequery = baseMode;
|
||||
|
||||
let item;
|
||||
|
||||
@@ -1193,7 +938,6 @@ export default {
|
||||
WHERE
|
||||
${db.unsafe(modequery)}
|
||||
AND items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
AND items.title ILIKE ${'%' + titleQuery + '%'}
|
||||
AND items.title IS NOT NULL
|
||||
${mimeSQL}
|
||||
@@ -1216,7 +960,6 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and "user".user ilike ${user}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||
group by items.id
|
||||
@@ -1256,7 +999,6 @@ export default {
|
||||
where
|
||||
${db.unsafe(modequery)}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${tagFilter}
|
||||
${user ? db`and items.username ilike ${user}` : db``}
|
||||
${hall ? db`and items.id in (select item_id from halls_assign ha join halls h on h.id = ha.hall_id where h.slug = ${hall})` : db``}
|
||||
@@ -1279,7 +1021,6 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and h.slug = ${hall}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${!session && getGlobalfilter() ? db`and not exists (select 1 from tags_assign where item_id = items.id and (${db.unsafe(getGlobalfilter())}))` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
@@ -1296,7 +1037,6 @@ export default {
|
||||
${db.unsafe(modequery)}
|
||||
and uha.hall_id = ${userHallId}
|
||||
and items.active = true
|
||||
and coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
order by random()
|
||||
@@ -1305,9 +1045,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
|
||||
@@ -1323,7 +1063,6 @@ export default {
|
||||
${useTagIdOpt ? db`INNER JOIN tags_assign ta ON ta.item_id = items.id AND ta.tag_id = ${tagId}` : db``}
|
||||
${checkFilter ? db`LEFT JOIN tags_assign filter_ta ON filter_ta.item_id = items.id AND filter_ta.tag_id IN ${db(nsfpIds)}` : db``}
|
||||
WHERE items.active = true
|
||||
AND coalesce(items.visibility, 0) = 0
|
||||
${mimeSQL}
|
||||
${checkFilter ? db`AND filter_ta.tag_id IS NULL` : db``}
|
||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||
@@ -1349,8 +1088,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`
|
||||
@@ -1358,12 +1096,12 @@ export default {
|
||||
c.id, c.parent_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name,
|
||||
(SELECT count(*) FROM comments r WHERE r.parent_id = c.id) as reply_count
|
||||
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
|
||||
@@ -1477,7 +1215,7 @@ export default {
|
||||
c.id, c.parent_id, c.item_id, c.content, c.created_at, c.vote_score, c.is_deleted,
|
||||
COALESCE(c.is_pinned, false) as is_pinned,
|
||||
c.video_time,
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat
|
||||
u.user as username, u.id as user_id, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name
|
||||
FROM comments c
|
||||
JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
@@ -1506,12 +1244,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;
|
||||
@@ -1519,10 +1256,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);
|
||||
}
|
||||
@@ -1839,14 +1575,8 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
computeBaseMode,
|
||||
getGlobalfilter,
|
||||
processMentions,
|
||||
processEmbeds,
|
||||
computeXdScore,
|
||||
xdScoreMeta,
|
||||
// Bust the count cache (call after a new upload is accepted so page totals stay accurate)
|
||||
clearCountCache: () => countCache.clear()
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
@@ -282,10 +282,6 @@ export default (router, tpl) => {
|
||||
totals: await lib.countf0cks(),
|
||||
session: req.session,
|
||||
manual_approval: getManualApproval(),
|
||||
min_tags: getMinTags(),
|
||||
trusted_uploads: getTrustedUploads(),
|
||||
registration_open: getRegistrationOpen(),
|
||||
registration_web_toggle_enabled: cfg.websrv.open_registration_web_toggle !== false,
|
||||
log_user_ips: getLogUserIps(),
|
||||
hash_user_ips: getHashUserIps(),
|
||||
enable_cleanup: getEnableCleanup(),
|
||||
@@ -323,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' });
|
||||
@@ -655,77 +651,6 @@ export default (router, tpl) => {
|
||||
return res.writeHead(302, { "Location": "/admin" }).end();
|
||||
});
|
||||
|
||||
// Config Manager API GET
|
||||
router.get(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
|
||||
const origin = req.headers?.origin || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
const isDev = cfg.main?.development === true;
|
||||
if (!isDev && (!req.session || !req.session.admin)) {
|
||||
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
|
||||
}
|
||||
|
||||
try {
|
||||
const configPath = path.resolve(process.cwd(), "config.json");
|
||||
const raw = await fs.readFile(configPath, "utf-8");
|
||||
const json = JSON.parse(raw);
|
||||
if (res.json) return res.json({ success: true, config: json });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, config: json }));
|
||||
} catch (err) {
|
||||
if (res.json) return res.json({ success: false, msg: err.message });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
// Config Manager API POST
|
||||
router.post(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
|
||||
const origin = req.headers?.origin || '*';
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||
|
||||
const isDev = cfg.main?.development === true;
|
||||
if (!isDev && (!req.session || !req.session.admin)) {
|
||||
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
|
||||
}
|
||||
|
||||
try {
|
||||
let updatedConfig = req.post?.config || req.json?.config || req.post;
|
||||
if (typeof updatedConfig === 'string') {
|
||||
updatedConfig = JSON.parse(updatedConfig);
|
||||
}
|
||||
if (!updatedConfig || typeof updatedConfig !== 'object') {
|
||||
throw new Error('Invalid configuration payload');
|
||||
}
|
||||
|
||||
const configPath = path.resolve(process.cwd(), "config.json");
|
||||
|
||||
// Write formatted JSON to config.json on disk
|
||||
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
// Mutate in-memory cfg object so changes apply immediately
|
||||
Object.assign(cfg, updatedConfig);
|
||||
|
||||
if (getEnableItemSlugs()) {
|
||||
ensureAllItemsHaveSlugs();
|
||||
}
|
||||
|
||||
// Audit log entry (if session available)
|
||||
if (req.session?.id) {
|
||||
await audit.log(req.session.id, 'update_config_file', 'system', 0, { updatedKeys: Object.keys(updatedConfig) });
|
||||
}
|
||||
|
||||
const response = { success: true, message: 'Configuration saved to config.json' };
|
||||
if (res.json) return res.json(response);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] Config Save Error:', err);
|
||||
const response = { success: false, msg: err.message };
|
||||
if (res.json) return res.json(response, 400);
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
|
||||
}
|
||||
});
|
||||
|
||||
router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => {
|
||||
if (!getEnableCleanup()) {
|
||||
return res.redirect("/admin");
|
||||
@@ -736,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
|
||||
};
|
||||
@@ -750,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);
|
||||
}
|
||||
|
||||
@@ -780,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.');
|
||||
@@ -806,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
|
||||
@@ -822,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
|
||||
@@ -831,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})`;
|
||||
@@ -849,16 +764,12 @@ export default (router, tpl) => {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
|
||||
await db`DELETE FROM tags_assign WHERE item_id = ${item.id}`;
|
||||
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
|
||||
count++;
|
||||
} catch (e) {
|
||||
console.error(`[CLEANUP] Failed to delete item ${item.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any remaining orphan tag assignments for deleted or inactive items
|
||||
await db`DELETE FROM tags_assign WHERE item_id IN (SELECT id FROM items WHERE is_deleted = true OR active = false)`;
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
@@ -1517,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, {
|
||||
|
||||
@@ -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') {
|
||||
@@ -32,16 +32,15 @@ export default (router, tpl) => {
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
const reqMode = query.mode !== undefined ? +query.mode : req.mode;
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
|
||||
const 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: reqMode,
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
ratings: ratingsArr,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
@@ -171,8 +170,7 @@ export default (router, tpl) => {
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid,
|
||||
slug: data.item?.slug || null
|
||||
id: itemid
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -196,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);
|
||||
@@ -207,16 +205,9 @@ export default (router, tpl) => {
|
||||
|
||||
const page = parseInt(query.page) || 1;
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
const ratingsRaw = query.ratings || req.cookies.ratings;
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
|
||||
const sortParam = query.sort || req.cookies.sort || 'newest';
|
||||
const timeframeParam = query.timeframe || req.cookies.timeframe || 'all';
|
||||
const ocParam = query.oc === '1' || query.oc === 'true';
|
||||
const pinnedParam = query.pinned === '1' || query.pinned === 'true';
|
||||
const hasCommentsParam = query.has_comments === '1' || query.has_comments === 'true' || query.hasComments === 'true';
|
||||
const layoutParam = query.layout || req.cookies.layout || 'grid';
|
||||
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: page,
|
||||
tag: query.tag || null,
|
||||
@@ -226,16 +217,10 @@ export default (router, tpl) => {
|
||||
userHallOwner: query.userHallOwner || null,
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
sort: sortParam,
|
||||
timeframe: timeframeParam,
|
||||
oc: ocParam,
|
||||
pinned: pinnedParam,
|
||||
hasComments: hasCommentsParam,
|
||||
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,
|
||||
@@ -246,19 +231,6 @@ export default (router, tpl) => {
|
||||
});
|
||||
|
||||
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({
|
||||
@@ -272,8 +244,7 @@ export default (router, tpl) => {
|
||||
// Render just the thumbnail items
|
||||
const itemsHtml = tpl.render('snippets/items-grid', {
|
||||
items: data.items,
|
||||
link: data.link,
|
||||
layout: layoutParam
|
||||
link: data.link
|
||||
}, req);
|
||||
|
||||
// Render pagination
|
||||
@@ -296,27 +267,6 @@ export default (router, tpl) => {
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false
|
||||
}, req);
|
||||
|
||||
// Render filter bar
|
||||
const activeRatings = ratingsArr || ['sfw', 'nsfw', 'untagged'];
|
||||
const activeMimes = (query.mime || req.cookies.mime || '').split(',').filter(Boolean);
|
||||
|
||||
const filterBarHtml = tpl.render('snippets/filter-bar', {
|
||||
layout: layoutParam,
|
||||
sort: sortParam,
|
||||
timeframe: timeframeParam,
|
||||
oc: ocParam ? '1' : '0',
|
||||
pinned: pinnedParam ? '1' : '0',
|
||||
hasComments: hasCommentsParam ? '1' : '0',
|
||||
rating_sfw: activeRatings.includes('sfw'),
|
||||
rating_nsfw: activeRatings.includes('nsfw'),
|
||||
rating_nsfl: activeRatings.includes('nsfl'),
|
||||
rating_untagged: activeRatings.includes('untagged'),
|
||||
mime_image: activeMimes.includes('image'),
|
||||
mime_video: activeMimes.includes('video'),
|
||||
mime_audio: activeMimes.includes('audio'),
|
||||
mime_flash: activeMimes.includes('flash')
|
||||
}, req);
|
||||
|
||||
const hasMore = data.pagination.next !== null;
|
||||
|
||||
return res.reply({
|
||||
@@ -330,7 +280,6 @@ export default (router, tpl) => {
|
||||
success: true,
|
||||
html: itemsHtml,
|
||||
titleHtml: titleHtml,
|
||||
filterBarHtml: filterBarHtml,
|
||||
pagination: paginationHtml,
|
||||
hasMore: hasMore,
|
||||
nextPage: data.pagination.next,
|
||||
|
||||
@@ -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,17 +700,10 @@ export default router => {
|
||||
};
|
||||
|
||||
const excludedTags = req.session?.excluded_tags || [];
|
||||
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const sessionUser = req.session?.user ? req.session.user.toLowerCase() : null;
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (sessionUser
|
||||
? db`and (coalesce("items".visibility, 0) = 0 or lower("items".username) = ${sessionUser})`
|
||||
: db`and coalesce("items".visibility, 0) = 0`);
|
||||
|
||||
const newest = (await db`select max(id) as id from "items"`)[0]?.id || 0;
|
||||
const oldest = (await db`select min(id) as id from "items"`)[0]?.id || 0;
|
||||
const modequery = f0cklib.computeBaseMode(opt.mode, null, req.session);
|
||||
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`
|
||||
select "items".id, "items".mime, coalesce("tags_assign".tag_id, 0) as tag_id
|
||||
@@ -729,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}`
|
||||
@@ -745,25 +727,34 @@ 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);
|
||||
});
|
||||
|
||||
group.get(/\/item\/(?<id>[a-zA-Z0-9_-]{11}|\d+)$/, async (req, res) => {
|
||||
const rawIdOrSlug = req.params.id;
|
||||
const isNumeric = /^\d+$/.test(rawIdOrSlug);
|
||||
const id = isNumeric ? +rawIdOrSlug : (await db`SELECT id FROM items WHERE slug = ${rawIdOrSlug} LIMIT 1`)[0]?.id;
|
||||
if (!id) {
|
||||
return res.json({ success: false, msg: 'no items found' });
|
||||
}
|
||||
group.get(/\/item\/(?<id>[0-9]+)$/, async (req, res) => {
|
||||
const id = +req.params.id;
|
||||
|
||||
const item = await db`
|
||||
select *
|
||||
from "items"
|
||||
where id = ${id} and active = true
|
||||
where id = ${+id} and active = true
|
||||
limit 1
|
||||
`;
|
||||
const next = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id > ${+id} and active = true
|
||||
order by id
|
||||
limit 1
|
||||
`;
|
||||
const prev = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id < ${+id} and active = true
|
||||
order by id desc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
@@ -774,74 +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'
|
||||
});
|
||||
}
|
||||
|
||||
const effMode = req.query.mode !== undefined ? +req.query.mode : (req.mode ?? 3);
|
||||
const ratingsRaw = req.cookies?.ratings || req.query.ratings;
|
||||
const ratingsArr = (effMode === 3 || effMode === 2) ? null : (ratingsRaw ? (Array.isArray(ratingsRaw) ? ratingsRaw : decodeURIComponent(ratingsRaw).split(/[|,]/)).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||
|
||||
let guestTagFilter = db``;
|
||||
if (!session) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id = ${nsflId})`;
|
||||
} else {
|
||||
guestTagFilter = db`and not exists (select 1 from tags_assign where item_id = items.id and tag_id in (2, ${nsflId}))`;
|
||||
}
|
||||
}
|
||||
|
||||
let modeCondition = db``;
|
||||
if (effMode === 2) {
|
||||
modeCondition = db`and not exists (select 1 from tags_assign where item_id = items.id)`;
|
||||
} else if (effMode === 0) {
|
||||
modeCondition = db`and (id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))`;
|
||||
} else if (effMode === 1) {
|
||||
modeCondition = db`and id in (select item_id from tags_assign where tag_id = 2)`;
|
||||
} else if (effMode === 4) {
|
||||
modeCondition = db`and id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`;
|
||||
} else if (ratingsArr && ratingsArr.length > 0) {
|
||||
modeCondition = db`and ${db.unsafe(lib.getMultiRatingMode(ratingsArr))}`;
|
||||
}
|
||||
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (session && session.user
|
||||
? db`and (coalesce(visibility, 0) = 0 or lower(username) = ${session.user.toLowerCase()})`
|
||||
: db`and coalesce(visibility, 0) = 0`);
|
||||
|
||||
const next = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id > ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
|
||||
order by id
|
||||
limit 1
|
||||
`;
|
||||
const prev = await db`
|
||||
select id
|
||||
from "items"
|
||||
where id < ${id} and active = true ${visibilityFilter} ${guestTagFilter} ${modeCondition}
|
||||
order by id desc
|
||||
limit 1
|
||||
`;
|
||||
|
||||
const rows = {
|
||||
...actitem,
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
...item[0],
|
||||
...{
|
||||
next: next[0]?.id ?? null,
|
||||
prev: prev[0]?.id ?? null
|
||||
}
|
||||
};
|
||||
|
||||
return res.json({
|
||||
@@ -1109,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`
|
||||
@@ -1154,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
|
||||
@@ -1256,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -19,7 +19,7 @@ export default router => {
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
tags: await lib.getTags(+req.params.postid, req.session)
|
||||
tags: await lib.getTags(+req.params.postid)
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3,11 +3,9 @@ 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";
|
||||
import f0cklib from "../../routeinc/f0cklib.mjs";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// In-memory job progress map (keyed by jobId string)
|
||||
@@ -139,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) => {
|
||||
@@ -436,11 +369,6 @@ export default router => {
|
||||
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
|
||||
const filename = `yt:${videoId}`;
|
||||
|
||||
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
@@ -452,14 +380,11 @@ export default router => {
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
title: title,
|
||||
visibility: targetVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: targetExpiresAt
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -504,26 +429,6 @@ export default router => {
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast new_item event for live grid updates (only if auto-approved)
|
||||
if (!isApprovalRequired) {
|
||||
f0cklib.clearCountCache();
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
username: req.session.user,
|
||||
display_name: req.session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] YouTube new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
msg: isApprovalRequired ? 'YouTube video embedded! Pending admin approval.' : 'YouTube video embedded!',
|
||||
@@ -532,9 +437,6 @@ export default router => {
|
||||
});
|
||||
} else {
|
||||
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
|
||||
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
const session = {
|
||||
id: req.session.id,
|
||||
user: req.session.user,
|
||||
@@ -772,8 +674,6 @@ export default router => {
|
||||
await fs.unlink(source).catch(() => { });
|
||||
|
||||
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
@@ -786,14 +686,11 @@ export default router => {
|
||||
username: session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
title: title,
|
||||
visibility: targetVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: targetExpiresAt
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||
title: title
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
@@ -838,17 +735,15 @@ export default router => {
|
||||
username: session.user,
|
||||
display_name: session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
is_oc: !!is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
|
||||
@@ -47,6 +47,25 @@ function buildBgHostRegex() {
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// GET /chat — Dedicated site chat page
|
||||
router.get('/chat', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: 'Not found' });
|
||||
}
|
||||
if (!req.session) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
if (req.session.banned) {
|
||||
return res.reply({ code: 403, body: 'Access denied: You are banned' });
|
||||
}
|
||||
return res.html(tpl.render('chat', {
|
||||
session: req.session,
|
||||
hidePagination: true,
|
||||
link: { main: '/chat', path: '/chat' },
|
||||
domain: cfg.main.url.domain
|
||||
}, req));
|
||||
});
|
||||
|
||||
// GET /api/chat — fetch recent messages
|
||||
router.get('/api/chat', async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
@@ -208,7 +227,7 @@ export default (router, tpl) => {
|
||||
});
|
||||
|
||||
// DELETE /api/chat/:id — admin: delete a single message
|
||||
router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
|
||||
if (!cfg.websrv.enable_global_chat) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
@@ -70,7 +69,7 @@ export default (router, tpl) => {
|
||||
comments,
|
||||
is_subscribed,
|
||||
is_locked,
|
||||
user_id: req.session ? req.session.id : null,
|
||||
user_id: req.session ? req.session.user : null,
|
||||
is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -173,10 +171,10 @@ export default (router, tpl) => {
|
||||
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
|
||||
// Build mode SQL — replace items.id alias with i.id used in the activity query
|
||||
const modequery = f0cklib.computeBaseMode(mode, ratingsArr, req.session).replace(/items\.id/g, 'i.id');
|
||||
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
|
||||
@@ -593,24 +588,16 @@ export default (router, tpl) => {
|
||||
// Large comments would silently drop the notification. The client fetches
|
||||
// the full content via _silentSync; the NOTIFY only needs to trigger the update.
|
||||
const notifyBody = content.length > 500 ? content.substring(0, 500) + '…' : content;
|
||||
const uo = await db`SELECT banner_file, banner_position, banner_size, banner_repeat FROM user_options WHERE user_id = ${req.session.id}`;
|
||||
const bannerOpt = uo[0] || {};
|
||||
|
||||
const livePayload = {
|
||||
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,
|
||||
user_id: req.session.id,
|
||||
avatar: req.session.avatar,
|
||||
avatar_file: req.session.avatar_file,
|
||||
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
|
||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
|
||||
created_at: new Date().toISOString(),
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.display_name || null,
|
||||
@@ -623,7 +610,8 @@ export default (router, tpl) => {
|
||||
db.notify('comments', JSON.stringify(livePayload));
|
||||
|
||||
// 2. Sidebar activity update
|
||||
const itemVisibility = itemQuery[0]?.visibility ?? 0;
|
||||
// Compute is_long using the full content (not the truncated notifyBody) so the
|
||||
// sidebar renders the correct clamped state immediately on first paint.
|
||||
const activityIsLong = content.length > 120
|
||||
|| content.split('\n').length > 2
|
||||
|| activityFiles.length > 0
|
||||
@@ -638,10 +626,6 @@ export default (router, tpl) => {
|
||||
item_rating_label: ratingLabel,
|
||||
avatar: req.session.avatar,
|
||||
avatar_file: req.session.avatar_file,
|
||||
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
|
||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
|
||||
username: req.session.user,
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.display_name || null,
|
||||
@@ -664,14 +648,8 @@ export default (router, tpl) => {
|
||||
success: true,
|
||||
comment: {
|
||||
...newComment[0],
|
||||
user_id: req.session.id,
|
||||
username_color: req.session.username_color || null,
|
||||
content,
|
||||
files: activityFiles,
|
||||
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
|
||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null
|
||||
files: activityFiles
|
||||
},
|
||||
xd_score: xdRow?.xd_score ?? null,
|
||||
is_new_subscription
|
||||
@@ -1017,26 +995,14 @@ 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 isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const sessionUser = req.session ? (req.session.user || '').toLowerCase() : null;
|
||||
const sessionUserId = req.session ? req.session.id : null;
|
||||
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (req.session
|
||||
? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
|
||||
: db`AND COALESCE(i.visibility, 0) = 0`);
|
||||
|
||||
const comments = await db`
|
||||
SELECT
|
||||
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[])
|
||||
@@ -1044,10 +1010,6 @@ export default (router, tpl) => {
|
||||
u.user as username,
|
||||
uo.avatar,
|
||||
uo.avatar_file,
|
||||
uo.banner_file,
|
||||
uo.banner_position,
|
||||
uo.banner_size,
|
||||
uo.banner_repeat,
|
||||
uo.username_color,
|
||||
uo.display_name
|
||||
FROM comments c
|
||||
@@ -1057,7 +1019,6 @@ export default (router, tpl) => {
|
||||
WHERE c.is_deleted = false
|
||||
AND i.active = true
|
||||
AND i.is_deleted = false
|
||||
${visibilityFilter}
|
||||
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``}
|
||||
@@ -1066,15 +1027,9 @@ export default (router, tpl) => {
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
// Normalize item_slug based on getEnableItemSlugs()
|
||||
const processedCommentsList = comments.map(c => ({
|
||||
...c,
|
||||
item_slug: getEnableItemSlugs() ? c.item_slug : null
|
||||
}));
|
||||
|
||||
// Fetch comment file attachments
|
||||
const filesMap = new Map();
|
||||
if (processedCommentsList.length > 0) {
|
||||
if (comments.length > 0) {
|
||||
const commentIds = comments.map(c => c.id);
|
||||
try {
|
||||
const files = await db`
|
||||
|
||||
@@ -437,9 +437,8 @@ export default (router) => {
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
original_filename: original_filename || null,
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||
original_filename: original_filename || null
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ export default (router, tpl) => {
|
||||
ratings: ratingsArr,
|
||||
mime: mime,
|
||||
fav: false,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
user_id: req.session?.id,
|
||||
random: isRandom
|
||||
});
|
||||
@@ -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) {
|
||||
@@ -227,16 +226,6 @@ export default (router, tpl) => {
|
||||
// Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX
|
||||
const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag;
|
||||
|
||||
const layout = req.query?.layout || req.url.qs?.layout || req.cookies.layout || req.session?.layout || 'grid';
|
||||
const sort = req.query?.sort || req.url.qs?.sort || req.cookies.sort || 'newest';
|
||||
const timeframe = req.query?.timeframe || req.url.qs?.timeframe || 'all';
|
||||
const oc = req.query?.oc || req.url.qs?.oc || null;
|
||||
const pinned = req.query?.pinned || req.url.qs?.pinned || null;
|
||||
const hasComments = req.query?.has_comments || req.url.qs?.has_comments || null;
|
||||
|
||||
const ratingsRaw = req.query?.ratings || req.url.qs?.ratings || req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null;
|
||||
|
||||
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
|
||||
user: req.params.user,
|
||||
tag: reqTag,
|
||||
@@ -246,10 +235,9 @@ export default (router, tpl) => {
|
||||
hall: req.params.hall,
|
||||
fav: req.params.mode == 'favs',
|
||||
mode: req.mode,
|
||||
ratings: ratingsArr,
|
||||
session: req.session,
|
||||
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
|
||||
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),
|
||||
@@ -257,52 +245,12 @@ export default (router, tpl) => {
|
||||
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,
|
||||
sort,
|
||||
timeframe,
|
||||
oc,
|
||||
pinned,
|
||||
hasComments
|
||||
tagger: req.url.qs?.tagger || null
|
||||
});
|
||||
const activeRatings = ratingsArr || ['sfw', 'nsfw', 'untagged'];
|
||||
|
||||
const rawMimeVal = req.query?.mime || req.url.qs?.mime || req.cookies.mime || req.params.mime || '';
|
||||
const activeMimes = String(rawMimeVal).split(',').filter(Boolean);
|
||||
|
||||
data.layout = layout;
|
||||
data.sort = sort;
|
||||
data.timeframe = timeframe;
|
||||
data.oc = oc;
|
||||
data.pinned = pinned;
|
||||
data.hasComments = hasComments;
|
||||
|
||||
data.rating_sfw = activeRatings.includes('sfw');
|
||||
data.rating_nsfw = activeRatings.includes('nsfw');
|
||||
data.rating_nsfl = activeRatings.includes('nsfl');
|
||||
data.rating_untagged = activeRatings.includes('untagged');
|
||||
|
||||
data.mime_image = activeMimes.includes('image');
|
||||
data.mime_video = activeMimes.includes('video');
|
||||
data.mime_audio = activeMimes.includes('audio');
|
||||
data.mime_flash = activeMimes.includes('flash');
|
||||
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
|
||||
console.log(`[${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 = [];
|
||||
@@ -426,8 +374,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');
|
||||
@@ -452,11 +398,11 @@ export default (router, tpl) => {
|
||||
|
||||
// Specific route for direct item links: /user/:user/:itemid
|
||||
// This avoids ambiguity with the profile route
|
||||
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>[a-zA-Z0-9_-]+)$/, handleGenericRoute);
|
||||
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
|
||||
|
||||
// Generic router for everything else (Index, Tags, standard User Grids)
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
|
||||
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
|
||||
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
|
||||
/* </routing-refactor> */
|
||||
|
||||
router.get(/^\/(about)$/, (req, res) => {
|
||||
|
||||
@@ -114,7 +114,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
const f0ck = await db`
|
||||
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
|
||||
select i.dest, i.mime, i.username, i.id, ta.tag_id
|
||||
from "items" i
|
||||
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
|
||||
where i.id = ${id} and i.active = false
|
||||
@@ -156,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, uo.banner_file, uo.banner_position, uo.banner_size, uo.banner_repeat, i.mime, i.slug as item_slug, COALESCE(i.visibility, 0) as visibility, LOWER(i.username) as item_username,
|
||||
SELECT 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
|
||||
@@ -135,34 +134,17 @@ db.listen('activity', async (payload) => {
|
||||
data.username = details.username;
|
||||
data.avatar = details.avatar;
|
||||
data.avatar_file = details.avatar_file;
|
||||
data.banner_file = data.banner_file || details.banner_file || null;
|
||||
data.banner_position = data.banner_position || details.banner_position || null;
|
||||
data.banner_size = data.banner_size || details.banner_size || null;
|
||||
data.banner_repeat = data.banner_repeat || details.banner_repeat || null;
|
||||
data.mime = details.mime;
|
||||
data.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;
|
||||
data.visibility = details.visibility;
|
||||
data.item_username = details.item_username;
|
||||
} else {
|
||||
data.username = 'System';
|
||||
data.visibility = 0;
|
||||
data.item_username = null;
|
||||
}
|
||||
|
||||
// Broadcast to clients permitted to view this activity
|
||||
// Broadcast to ALL connected clients
|
||||
for (const client of clients) {
|
||||
const itemVisibility = data.visibility ?? 0;
|
||||
const isPublic = itemVisibility === 0;
|
||||
const isCommentAuthor = client.userId && client.userId === data.user_id;
|
||||
const isItemOwner = client.username && data.item_username && client.username.toLowerCase() === data.item_username.toLowerCase();
|
||||
const isAdminOrMod = client.admin || client.is_moderator;
|
||||
|
||||
if (isPublic || isCommentAuthor || isItemOwner || isAdminOrMod) {
|
||||
client.send({ type: 'activity', data });
|
||||
}
|
||||
client.send({ type: 'activity', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Activity broadcast error:', e);
|
||||
@@ -228,13 +210,9 @@ db.listen('motd', (payload) => {
|
||||
db.listen('new_item', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
const itemVis = data.visibility || 0;
|
||||
console.log(`[SSE] Broadcasting new_item (id: ${data.id}, visibility: ${itemVis}) to connected clients`);
|
||||
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
const isOwner = client.username && data.username && client.username.toLowerCase() === data.username.toLowerCase();
|
||||
if (itemVis === 0 || isOwner) {
|
||||
client.send({ type: 'new_item', data });
|
||||
}
|
||||
client.send({ type: 'new_item', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('New item broadcast error:', e);
|
||||
@@ -389,7 +367,7 @@ export default (router, tpl) => {
|
||||
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
|
||||
const notifications = typeFilter
|
||||
? await db`
|
||||
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -414,7 +392,7 @@ export default (router, tpl) => {
|
||||
OFFSET ${offset}
|
||||
`
|
||||
: await db`
|
||||
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -448,7 +426,7 @@ export default (router, tpl) => {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||
return { ...n, reason };
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -465,7 +443,7 @@ export default (router, tpl) => {
|
||||
|
||||
try {
|
||||
const notifications = await db`
|
||||
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
COALESCE(u.user, 'System') as from_user,
|
||||
COALESCE(uo.display_name, '') as from_display_name,
|
||||
COALESCE(u.id, 0) as from_user_id,
|
||||
@@ -502,7 +480,7 @@ export default (router, tpl) => {
|
||||
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
|
||||
reason = data.reason || reason;
|
||||
}
|
||||
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
|
||||
return { ...n, reason };
|
||||
});
|
||||
|
||||
return res.reply({
|
||||
@@ -610,8 +588,6 @@ export default (router, tpl) => {
|
||||
avatar_file: req.session?.avatar_file || null,
|
||||
avatar: req.session?.avatar || null,
|
||||
username_color: req.session?.username_color || null,
|
||||
admin: !!req.session?.admin,
|
||||
is_moderator: !!req.session?.is_moderator,
|
||||
receive_system_notifications: req.session?.receive_system_notifications !== false,
|
||||
receive_user_notifications: req.session?.receive_user_notifications !== false,
|
||||
do_not_disturb: req.session?.do_not_disturb === true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Serve the scroller page
|
||||
@@ -116,13 +115,10 @@ export default (router, tpl) => {
|
||||
try {
|
||||
const slug = '%' + lib.slugify(q) + '%';
|
||||
const rows = await db`
|
||||
SELECT t.tag, t.normalized, COUNT(DISTINCT items.id) as uses
|
||||
SELECT t.tag, t.normalized, COUNT(ta.item_id) as uses
|
||||
FROM tags t
|
||||
JOIN tags_assign ta ON ta.tag_id = t.id
|
||||
JOIN items ON items.id = ta.item_id
|
||||
WHERE t.id > 2
|
||||
AND items.active = true
|
||||
AND items.is_deleted = false
|
||||
AND lower(t.normalized) ILIKE ${slug}
|
||||
GROUP BY t.tag, t.normalized
|
||||
ORDER BY uses DESC
|
||||
@@ -164,7 +160,7 @@ export default (router, tpl) => {
|
||||
const excludeIds = qs.exclude
|
||||
? qs.exclude.split(',').map(n => parseInt(n, 10)).filter(n => !isNaN(n) && n > 0).slice(0, 500)
|
||||
: [];
|
||||
const modeQuery = f0cklib.computeBaseMode(mode ?? 0, null, req.session);
|
||||
const modeQuery = lib.getMode(mode ?? 0);
|
||||
const nsfp = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
// anchor= is a specific item ID to include first in the batch (used for hash-based deep links)
|
||||
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
|
||||
@@ -240,13 +236,6 @@ export default (router, tpl) => {
|
||||
${sessionId ? db`EXISTS (SELECT 1 FROM favorites WHERE favorites.item_id = items.id AND favorites.user_id = ${sessionId})` : db`false`} AS is_faved
|
||||
`;
|
||||
const sid = req.session ? +req.session.id : null;
|
||||
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
|
||||
const sessionUser = req.session?.user ? req.session.user.toLowerCase() : null;
|
||||
const visibilityFilter = isOwnerOrAdmin
|
||||
? db``
|
||||
: (sessionUser
|
||||
? db`AND (COALESCE(items.visibility, 0) = 0 OR LOWER(items.username) = ${sessionUser})`
|
||||
: db`AND COALESCE(items.visibility, 0) = 0`);
|
||||
|
||||
try {
|
||||
let rows;
|
||||
@@ -279,7 +268,6 @@ export default (router, tpl) => {
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
${visibilityFilter}
|
||||
${excludeSwfSQL}
|
||||
${excludePdfSQL}
|
||||
${excludeArchiveSQL}
|
||||
@@ -304,7 +292,6 @@ export default (router, tpl) => {
|
||||
WHERE
|
||||
${db.unsafe(modeQuery)}
|
||||
AND items.active = true
|
||||
${visibilityFilter}
|
||||
${excludeSwfSQL}
|
||||
${excludePdfSQL}
|
||||
${excludeArchiveSQL}
|
||||
|
||||
@@ -24,16 +24,9 @@ export default (router, tpl) => {
|
||||
`;
|
||||
|
||||
// Get custom avatar file and banner file if exists
|
||||
let userOptions = null;
|
||||
try {
|
||||
userOptions = (await db`
|
||||
select avatar_file, banner_file, banner_position, banner_size from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
} catch (err) {
|
||||
userOptions = (await db`
|
||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
}
|
||||
const userOptions = (await db`
|
||||
select avatar_file, banner_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0];
|
||||
|
||||
// Get full user info
|
||||
const user = (await db`
|
||||
|
||||
@@ -34,7 +34,7 @@ export default (router, tpl) => {
|
||||
const subs = await db`
|
||||
SELECT
|
||||
s.created_at as sub_date,
|
||||
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
|
||||
i.id, i.dest, i.mime, i.username as uploader_name
|
||||
FROM comment_subscriptions s
|
||||
JOIN items i ON s.item_id = i.id
|
||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||
@@ -45,7 +45,6 @@ export default (router, tpl) => {
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
slug: i.slug || null,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
@@ -109,7 +108,7 @@ export default (router, tpl) => {
|
||||
const subs = await db`
|
||||
SELECT
|
||||
s.created_at as sub_date,
|
||||
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
|
||||
i.id, i.dest, i.mime, i.username as uploader_name
|
||||
FROM comment_subscriptions s
|
||||
JOIN items i ON s.item_id = i.id
|
||||
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
|
||||
@@ -119,7 +118,6 @@ export default (router, tpl) => {
|
||||
|
||||
const items = subs.map(i => ({
|
||||
id: i.id,
|
||||
slug: i.slug || null,
|
||||
user: i.uploader_name || 'System',
|
||||
sub_created: new Date(i.sub_date).toLocaleString(),
|
||||
thumb: `/t/${i.id}.webp`
|
||||
|
||||
@@ -31,10 +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 i.is_deleted = false
|
||||
AND COALESCE(i.visibility, 0) = 0
|
||||
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
@@ -9,24 +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) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||
} else {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && mode === 2) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
const modequery = baseMode;
|
||||
const modequery = lib.getMode(mode);
|
||||
|
||||
let restrictedFilter = db``;
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
@@ -52,8 +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 items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND t.id NOT IN (1, 2)
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
@@ -77,8 +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 items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
${userExcludeFilter}
|
||||
|
||||
@@ -92,7 +92,7 @@ export default (router, tpl) => {
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: req.params.page,
|
||||
mode: req.mode,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
@@ -138,7 +138,7 @@ export default (router, tpl) => {
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: req.params.itemid,
|
||||
mode: req.mode,
|
||||
session: req.session,
|
||||
session: !!req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
userHall: slug,
|
||||
@@ -260,7 +260,7 @@ export default (router, tpl) => {
|
||||
FROM items i
|
||||
JOIN user_halls_assign uha ON uha.item_id = i.id
|
||||
${modeFilter}
|
||||
WHERE uha.hall_id = ${hall.id} AND i.active = true AND COALESCE(i.visibility, 0) = 0
|
||||
WHERE uha.hall_id = ${hall.id} AND i.active = true
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 3
|
||||
`;
|
||||
|
||||
@@ -9,24 +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) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
|
||||
if (cfg.websrv.public_nsfw) {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
|
||||
} else {
|
||||
baseMode = cfg.websrv.public_untagged
|
||||
? `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`
|
||||
: "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
}
|
||||
} else if (!cfg.websrv.public_untagged && mode === 2) {
|
||||
baseMode = "1 = 0";
|
||||
}
|
||||
}
|
||||
const modequery = baseMode;
|
||||
const modequery = lib.getMode(mode);
|
||||
|
||||
let restrictedFilter = db``;
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
@@ -52,8 +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 items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND t.id NOT IN (1, 2)
|
||||
AND ta.user_id = ${userId}
|
||||
AND ${db.unsafe(modequery)}
|
||||
@@ -78,8 +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 items.is_deleted = false
|
||||
AND COALESCE(items.visibility, 0) = 0
|
||||
AND ta.user_id = ${userId}
|
||||
AND ${db.unsafe(modequery)}
|
||||
${restrictedFilter}
|
||||
|
||||
@@ -26,17 +26,14 @@ export default new class {
|
||||
* @returns {string}
|
||||
*/
|
||||
getRealIP(req) {
|
||||
let rawIp = req.headers['cf-connecting-ip'] ||
|
||||
let ip = req.headers['cf-connecting-ip'] ||
|
||||
req.headers['true-client-ip'] ||
|
||||
req.headers['x-client-ip'] ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.headers['x-forwarded-for'] ||
|
||||
req.socket?.remoteAddress;
|
||||
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
||||
req.socket.remoteAddress;
|
||||
|
||||
if (!rawIp) return "unknown";
|
||||
|
||||
// If the header contains a comma-separated proxy chain (e.g. "IP1, IP2"), extract the client IP (first element)
|
||||
let ip = String(rawIp).split(',')[0].trim();
|
||||
if (!ip) return "unknown";
|
||||
|
||||
// Handle IPv6 loopback and mapped IPv4
|
||||
if (ip === "::1") ip = "127.0.0.1";
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import cfg from "./config.mjs";
|
||||
import db from "./sql.mjs";
|
||||
import lib from "./lib.mjs";
|
||||
|
||||
let manual_approval = true;
|
||||
let min_tags = 3;
|
||||
@@ -16,37 +14,9 @@ let enable_pdf = false;
|
||||
let enable_cleanup = false;
|
||||
let cleanup_start_date = '';
|
||||
let cleanup_end_date = '';
|
||||
let cleanup_include_engaged = false;
|
||||
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
|
||||
export const setShitpostMode = (val) => {}; // No-op, strictly config-based
|
||||
|
||||
export const getEnableExpiringUploads = () => {
|
||||
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getEnableItemSlugs = () => {
|
||||
if (cfg.enable_item_slugs === false || cfg.websrv?.enable_item_slugs === false) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
export const ensureAllItemsHaveSlugs = async () => {
|
||||
try {
|
||||
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
|
||||
if (!rows || rows.length === 0) return;
|
||||
console.log(`[SLUG_BACKFILL] Found ${rows.length} item(s) missing slugs. Backfilling...`);
|
||||
for (const row of rows) {
|
||||
const newSlug = lib.generateSlug(11);
|
||||
await db`UPDATE items SET slug = ${newSlug} WHERE id = ${row.id} AND (slug IS NULL OR slug = '')`;
|
||||
}
|
||||
console.log(`[SLUG_BACKFILL] Successfully backfilled ${rows.length} item slug(s).`);
|
||||
} catch (err) {
|
||||
console.error('[SLUG_BACKFILL] Error during slug backfill:', err.message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const getEnableCleanup = () => {
|
||||
if (cfg.websrv.enable_cleanup === false) return false;
|
||||
return enable_cleanup;
|
||||
@@ -59,9 +29,6 @@ export const setCleanupStartDate = (val) => cleanup_start_date = val || '';
|
||||
export const getCleanupEndDate = () => cleanup_end_date;
|
||||
export const setCleanupEndDate = (val) => cleanup_end_date = val || '';
|
||||
|
||||
export const getCleanupIncludeEngaged = () => cleanup_include_engaged;
|
||||
export const setCleanupIncludeEngaged = (val) => cleanup_include_engaged = !!val;
|
||||
|
||||
export const getEnablePdf = () => enable_pdf;
|
||||
export const setEnablePdf = (val) => enable_pdf = !!val;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where id >= ${randomId} and active = true and coalesce(visibility, 0) = 0
|
||||
where id >= ${randomId} and active = true
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
@@ -43,7 +43,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where active = true and coalesce(visibility, 0) = 0
|
||||
where active = true
|
||||
order by id asc
|
||||
limit 1
|
||||
`;
|
||||
@@ -54,7 +54,7 @@ export default async bot => {
|
||||
rows = await db`
|
||||
select id, mime, username, size
|
||||
from "items"
|
||||
where active = true and coalesce(visibility, 0) = 0 and
|
||||
where
|
||||
${args.map(a => a.charAt(0) === "!"
|
||||
? db`username not ilike ${a.slice(1)}`
|
||||
: db`username ilike ${a}`
|
||||
|
||||
@@ -752,9 +752,8 @@ export default async bot => {
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval(),
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
@@ -859,9 +858,8 @@ export default async bot => {
|
||||
userchannel: e.channel,
|
||||
usernetwork: e.network,
|
||||
stamp: ~~(new Date() / 1000),
|
||||
active: !getManualApproval(),
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'slug')}
|
||||
active: !getManualApproval()
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active')}
|
||||
`;
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
|
||||
302
src/index.mjs
302
src/index.mjs
@@ -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)`);
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -434,31 +393,6 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for database & schema initialization to complete (auto-bootstrap if tables are missing)
|
||||
let dbReady = false;
|
||||
let dbAttempts = 0;
|
||||
while (!dbReady && dbAttempts < 30) {
|
||||
try {
|
||||
dbAttempts++;
|
||||
const tableCheck = await db`SELECT 1 FROM information_schema.tables WHERE table_name = 'items' LIMIT 1`;
|
||||
if (tableCheck.length === 0) {
|
||||
console.log('[BOOT] Database tables missing! Initializing schema from migrations/f0ckm_schema.sql...');
|
||||
await db.file('./migrations/f0ckm_schema.sql');
|
||||
console.log('[BOOT] Database schema initialization complete!');
|
||||
}
|
||||
dbReady = true;
|
||||
} catch (e) {
|
||||
if (dbAttempts === 1) {
|
||||
console.log(`[BOOT] Waiting for database connection... (${e.message})`);
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
if (dbReady) {
|
||||
console.log(`[BOOT] Database schema ready (took ${dbAttempts} attempt(s))`);
|
||||
} else {
|
||||
console.error('[BOOT] Database schema check timed out after 30 attempts!');
|
||||
}
|
||||
const timeout = 10000; // 10s module load timeout
|
||||
|
||||
// Ensure clients are resolved (Safe-guard for Docker/Node versions)
|
||||
@@ -494,27 +428,9 @@ process.on('uncaughtException', err => {
|
||||
self._trigger.set(trigger.name, new self.trigger(trigger));
|
||||
});
|
||||
|
||||
// Auto-migrate schema: ensure user_options & items columns exist
|
||||
const runMigration = async (query) => {
|
||||
try { await query; } catch (e) { /* ignore if table doesn't exist yet */ }
|
||||
};
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover'`);
|
||||
await runMigration(db`ALTER TABLE user_options ADD COLUMN IF NOT EXISTS banner_repeat character varying(50) DEFAULT 'repeat'`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS width integer DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS height integer DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS original_filename text DEFAULT NULL`);
|
||||
await runMigration(db`ALTER TABLE items ADD COLUMN IF NOT EXISTS title text DEFAULT NULL`);
|
||||
|
||||
// Initial halls cache (only if halls are enabled)
|
||||
if (cfg.websrv.halls_enabled !== false) {
|
||||
try {
|
||||
await updateHallsCache();
|
||||
} catch (e) {
|
||||
// ignore during early init
|
||||
}
|
||||
await updateHallsCache();
|
||||
}
|
||||
|
||||
// Log feature flags
|
||||
@@ -540,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.
|
||||
@@ -748,7 +617,7 @@ process.on('uncaughtException', err => {
|
||||
user = [_cachedRow];
|
||||
} else {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user".banned, "user".ban_reason, "user".ban_expires, "user".force_password_change, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".mode, "user_options".theme, "user_options".fullscreen, "user_options".excluded_tags, "user_options".avatar, "user_options".avatar_file, "user_options".banner_file, "user_options".banner_position, "user_options".banner_size, "user_options".banner_repeat, "user_options".show_motd, "user_options".strict_mode, "user_options".show_background, "user_options".use_new_layout, "user_options".username_color, "user_options".font, "user_options".disable_autoplay, "user_options".disable_swiping, "user_options".favorites_private, "user_options".hide_fav_badge, "user_options".default_upload_visibility, "user_options".description, "user_options".display_name, COALESCE("user_options".min_xd_score, 0) as min_xd_score, "user_options".ruffle_volume, "user_options".ruffle_background, "user_options".quote_emojis, "user_options".embed_youtube_in_comments, "user_options".hide_koepfe, "user_options".language, "user_options".use_alternative_infobox, "user_options".use_alternative_steuerung, "user_options".receive_system_notifications, "user_options".receive_user_notifications, "user_options".do_not_disturb, "user_options".comment_display_mode, "user_options".force_comment_display_mode
|
||||
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
|
||||
@@ -932,47 +801,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' }));
|
||||
@@ -994,7 +845,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
|
||||
@@ -1199,11 +1050,11 @@ process.on('uncaughtException', err => {
|
||||
let gitHash = process.env.GIT_HASH || 'unknown';
|
||||
if (gitHash === 'unknown') {
|
||||
try {
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim();
|
||||
const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim();
|
||||
gitHash = `${branch}-${hash}`;
|
||||
} catch (e) {
|
||||
// Not a git repository
|
||||
// console.warn('Could not get git hash:', e.message);
|
||||
}
|
||||
}
|
||||
console.log('Git hash:', gitHash);
|
||||
@@ -1315,12 +1166,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);
|
||||
@@ -1406,9 +1251,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,
|
||||
@@ -1450,7 +1292,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,
|
||||
@@ -1462,16 +1303,10 @@ process.on('uncaughtException', err => {
|
||||
default_font: cfg.websrv.default_font || "",
|
||||
site_description: cfg.websrv.description || "The webs dumpster",
|
||||
enable_nsfl: !!cfg.enable_nsfl,
|
||||
enable_private_uploads: cfg.enable_private_uploads !== false,
|
||||
get enable_expiring_uploads() { return getEnableExpiringUploads(); },
|
||||
get enable_item_slugs() { return getEnableItemSlugs(); },
|
||||
default_upload_visibility: (typeof cfg.default_upload_visibility === 'number' ? cfg.default_upload_visibility : (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0)),
|
||||
allow_user_upload_visibility: cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false,
|
||||
nsfl_tag_id: cfg.nsfl_tag_id || 3,
|
||||
scroller_mime_cats: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.filter(c => ['video','image','audio'].includes(c)) : ['video','image','audio'],
|
||||
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
||||
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
||||
expose_repost_links_to_guests: !!(cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links),
|
||||
get private_messages() { return getPrivateMessages(); },
|
||||
get dm_attachments() { return getDmAttachments(); },
|
||||
get dm_unencrypted() { return getDmUnencrypted(); },
|
||||
@@ -1480,7 +1315,6 @@ process.on('uncaughtException', err => {
|
||||
get enable_cleanup() { return getEnableCleanup(); },
|
||||
get cleanup_start_date() { return getCleanupStartDate(); },
|
||||
get cleanup_end_date() { return getCleanupEndDate(); },
|
||||
get cleanup_include_engaged() { return getCleanupIncludeEngaged(); },
|
||||
matrix_enabled: cfg.clients.find(c => c.type === 'matrix')?.enabled || false,
|
||||
telegram_enabled: cfg.clients.find(c => c.type === 'tg')?.enabled || false,
|
||||
ts: Date.now(),
|
||||
@@ -1569,50 +1403,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 always get false — the alternative infobox is a logged-in user preference only
|
||||
const useAltInfobox = (req && req.session && typeof req.session.use_alternative_infobox === 'boolean')
|
||||
? req.session.use_alternative_infobox
|
||||
: (req && !req.session
|
||||
? false
|
||||
: (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_banner_enabled: (cfg.websrv.user_banner_enabled !== false) && (cfg.websrv.comment_banner_enabled !== false),
|
||||
comment_banner_max_height: typeof cfg.websrv.comment_banner_max_height === 'number' ? cfg.websrv.comment_banner_max_height : 300,
|
||||
comment_display_mode: (activeReq && activeReq.session && typeof activeReq.session.comment_display_mode === 'number')
|
||||
? 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))
|
||||
@@ -1624,24 +1447,20 @@ 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.csrf_token || '';
|
||||
globals.csrf_token = data.csrf_token;
|
||||
data.max_file_size = lib.formatSize(cfg.main.maxfilesize * (activeReq.session?.admin ? cfg.main.adminmultiplier : 1));
|
||||
data.max_file_size_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;
|
||||
globals.csrf_token = data.csrf_token || '';
|
||||
}
|
||||
|
||||
return originalRender.call(tpl, view, data, (data.url && data.url !== req?.url) ? undefined : req);
|
||||
@@ -1674,11 +1493,6 @@ process.on('uncaughtException', err => {
|
||||
setTimeout(cleanupStaleSessions, 30_000);
|
||||
setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
||||
|
||||
// Expiring uploads background purge (every 30s)
|
||||
setTimeout(purgeExpiredUploads, 10_000);
|
||||
setInterval(purgeExpiredUploads, 30_000);
|
||||
|
||||
|
||||
// ── Inactivity ban — permanently ban accounts that haven't logged in for N days
|
||||
// Set websrv.inactivity_ban_days = 0 (or omit) to disable this feature entirely.
|
||||
const INACTIVITY_BAN_DAYS = parseInt(cfg.websrv.inactivity_ban_days) || 0;
|
||||
|
||||
@@ -78,7 +78,6 @@ export async function handleKoepfeUpload(req, res) {
|
||||
throw new Error('Unsupported format');
|
||||
}
|
||||
|
||||
await fs.mkdir(cfg.paths.koepfe, { recursive: true });
|
||||
const newName = crypto.randomBytes(8).toString('hex') + ext;
|
||||
const targetPath = path.join(cfg.paths.koepfe, newName);
|
||||
|
||||
|
||||
@@ -6,11 +6,9 @@ import { applyWordFilter } from "./inc/wordfilter.mjs";
|
||||
import queue from "./inc/queue.mjs";
|
||||
import path from "path";
|
||||
import https from "https";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf, getEnableItemSlugs } from "./inc/settings.mjs";
|
||||
import { getManualApproval, getMinTags, getTrustedUploads, getBypassDuplicateCheck, getEnablePdf } from "./inc/settings.mjs";
|
||||
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
|
||||
import f0cklib from "./inc/routeinc/f0cklib.mjs";
|
||||
import { calculateExpiresAt } from "./inc/routes/apiv2/upload.mjs";
|
||||
|
||||
|
||||
// Derive archive MIME types from cfg.mimes — any application/* that isn't swf or pdf.
|
||||
// Adding a new archive type to config.json is sufficient; no code change needed.
|
||||
@@ -21,26 +19,6 @@ const ARCHIVE_MIMES = new Set(
|
||||
);
|
||||
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
|
||||
|
||||
const autoTagsFromUrl = (urlString) => {
|
||||
const tags = [];
|
||||
try {
|
||||
const { hostname } = new URL(urlString);
|
||||
const host = hostname.replace(/:\d+$/, '').toLowerCase();
|
||||
const parts = host.split('.');
|
||||
const shortSlds = new Set(['co', 'com', 'net', 'org', 'gov', 'edu', 'ac', 'or', 'ne']);
|
||||
let domain;
|
||||
if (parts.length >= 3 && shortSlds.has(parts[parts.length - 2])) {
|
||||
domain = parts.slice(-3).join('.');
|
||||
} else {
|
||||
domain = parts.slice(-2).join('.');
|
||||
}
|
||||
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
|
||||
tags.push('youtube');
|
||||
}
|
||||
} catch (e) {}
|
||||
return [...new Set(tags)];
|
||||
};
|
||||
|
||||
// Helper for JSON response
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
@@ -56,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)
|
||||
@@ -67,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.
|
||||
@@ -129,7 +101,15 @@ export const handleUpload = async (req, res, self) => {
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
let parts = {};
|
||||
|
||||
// Robust boundary extraction (handles both quoted and unquoted boundaries)
|
||||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
|
||||
if (!contentType || !contentType.includes('multipart/form-data') || !boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||
|
||||
// Determine max file size early for collectBody
|
||||
let effectiveMaxBytes = cfg.main.maxfilesize || (150 * 1024 * 1024);
|
||||
@@ -144,106 +124,31 @@ export const handleUpload = async (req, res, self) => {
|
||||
throw bodyErr;
|
||||
}
|
||||
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
if (!boundaryMatch) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid multipart boundary' }, 400);
|
||||
}
|
||||
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
||||
parts = parseMultipart(body, boundary);
|
||||
} else if (contentType.includes('application/json')) {
|
||||
try {
|
||||
parts = JSON.parse(body.toString());
|
||||
} catch (e) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid JSON body' }, 400);
|
||||
}
|
||||
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
||||
const params = new URLSearchParams(body.toString());
|
||||
for (const [key, value] of params.entries()) {
|
||||
parts[key] = value;
|
||||
}
|
||||
} else {
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
const parts = parseMultipart(body, boundary);
|
||||
|
||||
// Validate required fields
|
||||
let file = (typeof parts.file === 'object' && parts.file !== null && parts.file.data) ? parts.file : null;
|
||||
let inputUrl = (typeof parts.url === 'string' && parts.url.trim()) ? parts.url.trim() : null;
|
||||
|
||||
if (!inputUrl && typeof parts.file === 'string' && /^https?:\/\//i.test(parts.file.trim())) {
|
||||
inputUrl = parts.file.trim();
|
||||
}
|
||||
|
||||
if (inputUrl) {
|
||||
file = null;
|
||||
try {
|
||||
const parsed = new URL(inputUrl);
|
||||
if (parsed.searchParams.has('igsh')) {
|
||||
parsed.searchParams.delete('igsh');
|
||||
inputUrl = parsed.toString();
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const file = parts.file;
|
||||
const rating = parts.rating;
|
||||
const tagsRaw = parts.tags;
|
||||
const comment = parts.comment ? String(parts.comment).trim() : '';
|
||||
const rawTitle = parts.title ? String(parts.title).trim() : '';
|
||||
const comment = parts.comment ? parts.comment.trim() : '';
|
||||
const rawTitle = parts.title ? parts.title.trim() : '';
|
||||
const title = rawTitle.length > 0 ? rawTitle.substring(0, 500) : null;
|
||||
|
||||
const is_oc = (parts.is_oc === true || parts.is_oc === 'true' || parts.is_oc === '1');
|
||||
const is_shitpost = (parts.is_shitpost === true || parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
|
||||
const is_oc = (parts.is_oc === 'true' || parts.is_oc === '1');
|
||||
|
||||
if (!file && !inputUrl) {
|
||||
return sendJson(res, { success: false, msg: 'No file or URL provided' }, 400);
|
||||
}
|
||||
|
||||
if (inputUrl && cfg.websrv.web_url_upload === false) {
|
||||
return sendJson(res, { success: false, msg: 'URL uploads are disabled' }, 403);
|
||||
}
|
||||
|
||||
// Parse visibility: Header 'X-Upload-Visibility' or body field 'visibility' or user default preference
|
||||
let targetVisibility = 0;
|
||||
if (cfg.enable_private_uploads !== false) {
|
||||
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
|
||||
? cfg.default_upload_visibility
|
||||
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
|
||||
|
||||
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
|
||||
|
||||
if (!allowUserOverride) {
|
||||
targetVisibility = sysDefault;
|
||||
} else {
|
||||
const rawVisHeader = req.headers['x-upload-visibility'];
|
||||
const rawVisBody = parts.visibility;
|
||||
const visVal = (rawVisHeader || rawVisBody || '').toString().trim().toLowerCase();
|
||||
|
||||
if (visVal === 'private' || visVal === '2') {
|
||||
targetVisibility = 2;
|
||||
} else if (visVal === 'unlisted' || visVal === '1') {
|
||||
targetVisibility = 1;
|
||||
} else if (visVal === 'public' || visVal === '0') {
|
||||
targetVisibility = 0;
|
||||
} else {
|
||||
targetVisibility = (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
|
||||
? req.session.default_upload_visibility
|
||||
: sysDefault;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawExpiry = req.headers['x-upload-expiry'] || parts.expiry || parts.expires_at;
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const targetExpiresAt = calculateExpiresAt(rawExpiry, nowStamp);
|
||||
|
||||
// Always generate a unique item slug for the database
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
const is_shitpost = (parts.is_shitpost === 'true' || parts.is_shitpost === '1') || cfg.websrv.shitpost_mode === true;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
if (!file || !file.data) {
|
||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// In shitpost mode, rating is optional — null means no rating tag is assigned (truly untagged).
|
||||
// If shitpost_require_rating is configured to true, a rating is strictly required.
|
||||
const effectiveRating = (rating && ['sfw', 'nsfw', 'nsfl'].includes(rating)) ? rating : null;
|
||||
|
||||
if (!is_shitpost && !effectiveRating) {
|
||||
@@ -258,20 +163,20 @@ export const handleUpload = async (req, res, self) => {
|
||||
return sendJson(res, { success: false, msg: 'NSFL mode is currently disabled' }, 400);
|
||||
}
|
||||
|
||||
const userTags = tagsRaw ? String(tagsRaw).split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||
const autoTags = (inputUrl && !file) ? autoTagsFromUrl(inputUrl) : [];
|
||||
const tags = [...new Set([...userTags, ...autoTags])];
|
||||
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
|
||||
const minTags = getMinTags();
|
||||
// In shitpost mode, tags are optional by default — unless shitpost_min_tags is configured.
|
||||
const shitpostMinTags = is_shitpost ? (parseInt(cfg.websrv.shitpost_min_tags) || 0) : 0;
|
||||
if (!is_shitpost && minTags > 0 && userTags.length < minTags) {
|
||||
if (!is_shitpost && minTags > 0 && tags.length < minTags) {
|
||||
return sendJson(res, { success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
|
||||
}
|
||||
if (is_shitpost && shitpostMinTags > 0 && userTags.length < shitpostMinTags) {
|
||||
if (is_shitpost && shitpostMinTags > 0 && tags.length < shitpostMinTags) {
|
||||
return sendJson(res, { success: false, msg: `At least ${shitpostMinTags} tag${shitpostMinTags !== 1 ? 's' : ''} required` }, 400);
|
||||
}
|
||||
|
||||
// Validate MIME type for attached file
|
||||
// Validate MIME type
|
||||
// cfg.allowedMimes entries can be category prefixes ("image", "video", "audio")
|
||||
// OR exact MIME types ("application/pdf"). Entries with "/" are matched exactly.
|
||||
const allowedCats = Array.isArray(cfg.allowedMimes)
|
||||
? cfg.allowedMimes.map(c => c.toLowerCase())
|
||||
: null;
|
||||
@@ -282,19 +187,27 @@ export const handleUpload = async (req, res, self) => {
|
||||
)
|
||||
)
|
||||
: Object.keys(cfg.mimes);
|
||||
let mime = file.contentType;
|
||||
|
||||
if (file && !inputUrl) {
|
||||
let mime = file.contentType;
|
||||
if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') &&
|
||||
file.filename && file.filename.toLowerCase().endsWith('.swf')) {
|
||||
mime = 'application/x-shockwave-flash';
|
||||
}
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
||||
}
|
||||
// Browsers often don't know the SWF MIME type and send application/octet-stream or nothing.
|
||||
// Normalize it here based on extension so the allowedMimes check doesn't spuriously reject.
|
||||
// The server-side `file --mime-type` check on line ~248 is the authoritative validation.
|
||||
if ((mime === 'application/octet-stream' || !mime || mime === 'application/x-www-form-urlencoded') &&
|
||||
file.filename && file.filename.toLowerCase().endsWith('.swf')) {
|
||||
mime = 'application/x-shockwave-flash';
|
||||
}
|
||||
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, { success: false, msg: `Invalid file type: ${mime}` }, 400);
|
||||
}
|
||||
|
||||
// Size was already validated by collectBody (effectiveMaxBytes)
|
||||
const size = file.data.length;
|
||||
|
||||
let manualApproval = getManualApproval();
|
||||
|
||||
// Enforce manual approval for untrusted users (configurable threshold)
|
||||
// Admins and moderators are exempt from this check
|
||||
const trustedThreshold = getTrustedUploads();
|
||||
if (trustedThreshold > 0 && !req.session.admin && !req.session.is_moderator) {
|
||||
try {
|
||||
@@ -305,13 +218,18 @@ export const handleUpload = async (req, res, self) => {
|
||||
AND is_deleted = false
|
||||
`;
|
||||
if (parseInt(totalUploads[0].count) < trustedThreshold) {
|
||||
console.log(`[UPLOAD] Forcing manual approval for new user: ${req.session.user} (Upload count: ${totalUploads[0].count}/${trustedThreshold})`);
|
||||
manualApproval = true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] Failed to check total upload count:', err);
|
||||
// Default to manual approval on error for safety if we are unsure
|
||||
manualApproval = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Rate Limit Check (if manual approval is disabled)
|
||||
// Admins and moderators are exempt from rate limiting
|
||||
if (!manualApproval && !req.session.admin && !req.session.is_moderator) {
|
||||
const twelveHoursAgo = ~~(Date.now() / 1000) - (12 * 3600);
|
||||
const uploadCount = await db`
|
||||
@@ -330,130 +248,6 @@ export const handleUpload = async (req, res, self) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for YouTube Embeds when url is provided without a file attachment
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
const ytMatch = (!file && inputUrl) ? inputUrl.match(ytRegex) : null;
|
||||
|
||||
if (ytMatch && cfg.websrv.enable_youtube_upload !== false) {
|
||||
const videoId = ytMatch[1];
|
||||
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
|
||||
const filename = `yt:${videoId}`;
|
||||
|
||||
const [{ id: itemid }] = await db`
|
||||
insert into items ${db({
|
||||
src: ytUrl,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
size: 0,
|
||||
checksum: `yt_${videoId}_${Date.now()}`,
|
||||
phash: null,
|
||||
username: req.session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: nowStamp,
|
||||
active: !manualApproval,
|
||||
is_oc: !!is_oc,
|
||||
title: title,
|
||||
visibility: targetVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: targetExpiresAt
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) {}
|
||||
|
||||
try {
|
||||
await queue.genThumbnail(filename, 'video/youtube', itemid, ytUrl, manualApproval);
|
||||
} catch (err) {
|
||||
const tDir = manualApproval ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '20', '-annotate', '0', 'YouTube', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
if (effectiveRating) {
|
||||
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
|
||||
await queue.genBlurredThumbnail(itemid, manualApproval).catch(() => {});
|
||||
}
|
||||
|
||||
for (const tagName of tags) {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`insert into tags ${db({ tag: tagName }, 'tag')}`;
|
||||
tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
}
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
|
||||
}
|
||||
|
||||
if (comment && comment.length > 0) {
|
||||
try {
|
||||
const filteredComment = await applyWordFilter(comment);
|
||||
await db`INSERT INTO comments ${db({ item_id: itemid, user_id: req.session.id, content: filteredComment })}`;
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
if (is_oc) {
|
||||
const ocTags = ['oc', 'original content'];
|
||||
for (const ocName of ocTags) {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`insert into tags ${db({ tag: ocName }, 'tag')}`;
|
||||
tagRow = await db`select id from tags where normalized = slugify(${ocName}) limit 1`;
|
||||
}
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: req.session.id })} on conflict do nothing`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!manualApproval) {
|
||||
f0cklib.clearCountCache();
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
username: req.session.user,
|
||||
display_name: req.session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] YouTube new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const itemRoute = itemSlug ? `/${itemSlug}` : `/${itemid}`;
|
||||
const successMsg = manualApproval ? 'Upload successful! Your upload is pending admin approval.' : 'Upload successful! Your upload is now live.';
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
msg: successMsg,
|
||||
itemid: itemid,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility,
|
||||
manual_approval: manualApproval,
|
||||
redirect: !manualApproval ? itemRoute : null,
|
||||
url: !manualApproval ? `${cfg.main.url.full}${itemRoute}` : `${cfg.main.url.full}/`,
|
||||
file_url: null,
|
||||
dest: filename,
|
||||
mime: 'video/youtube',
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
display_name: req.session.display_name || null,
|
||||
username: req.session.user
|
||||
});
|
||||
}
|
||||
|
||||
// Check repost link if uploading via URL (non-YouTube)
|
||||
if (!file && inputUrl && !getBypassDuplicateCheck()) {
|
||||
const repostLink = await queue.checkrepostlink(inputUrl);
|
||||
if (repostLink) {
|
||||
return sendJson(res, { success: false, msg: 'This URL has already been uploaded', repost: repostLink }, 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate UUID & Base Paths
|
||||
const uuid = await queue.genuuid();
|
||||
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
@@ -464,87 +258,8 @@ export const handleUpload = async (req, res, self) => {
|
||||
await fs.mkdir(path.join(cfg.paths.pending, 't'), { recursive: true });
|
||||
await fs.mkdir(path.join(cfg.paths.pending, 'ca'), { recursive: true });
|
||||
|
||||
if (file && file.data) {
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
} else if (inputUrl) {
|
||||
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
|
||||
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web', '--newline', '--no-colors'];
|
||||
const isInstagram = /instagram\.com/i.test(inputUrl);
|
||||
let downloadedFile = null;
|
||||
|
||||
try {
|
||||
const { stdout } = await queue.spawn('yt-dlp', [
|
||||
...proxyArgs, ...ytdlpArgs,
|
||||
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
|
||||
inputUrl,
|
||||
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
|
||||
'--postprocessor-args', 'ffmpeg:-bitexact',
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath',
|
||||
'--merge-output-format', 'mp4'
|
||||
], { quiet: true });
|
||||
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err1) {
|
||||
if (isInstagram) {
|
||||
return sendJson(res, { success: false, msg: 'Failed to download Instagram URL' }, 400);
|
||||
}
|
||||
try {
|
||||
const { stdout } = await queue.spawn('yt-dlp', [
|
||||
...proxyArgs, ...ytdlpArgs,
|
||||
inputUrl,
|
||||
'--max-filesize', `${effectiveMaxBytes / 1024}k`,
|
||||
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
|
||||
'--print', 'after_move:filepath'
|
||||
], { quiet: true });
|
||||
downloadedFile = stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
|
||||
} catch (err2) {
|
||||
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.curl_tmp`);
|
||||
let referer = inputUrl;
|
||||
try {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
let host = parsedUrl.hostname;
|
||||
if (host.includes('imgur.com')) host = 'imgur.com';
|
||||
referer = `${parsedUrl.protocol}//${host}/`;
|
||||
} catch (e) {}
|
||||
|
||||
const curlArgs = [
|
||||
'-s', '-S', '-f', '-L', inputUrl, '-o', fallbackTmp,
|
||||
'--max-filesize', `${effectiveMaxBytes}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300',
|
||||
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'--referer', referer,
|
||||
'-H', 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
|
||||
'-H', 'Accept-Language: en-US,en;q=0.9'
|
||||
];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
try {
|
||||
await queue.spawn('curl', curlArgs);
|
||||
downloadedFile = fallbackTmp;
|
||||
} catch (curlErr) {
|
||||
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let exists = false;
|
||||
if (downloadedFile) {
|
||||
try {
|
||||
await fs.access(downloadedFile);
|
||||
exists = true;
|
||||
} catch (e) {}
|
||||
}
|
||||
if (!exists) {
|
||||
return sendJson(res, { success: false, msg: 'Failed to download file from URL' }, 400);
|
||||
}
|
||||
|
||||
if (downloadedFile !== tmpPath) {
|
||||
await fs.rename(downloadedFile, tmpPath);
|
||||
}
|
||||
}
|
||||
// Save temporarily to detect actual MIME
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Verify actual MIME (second check after file-command detection)
|
||||
let actualMime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
||||
@@ -620,7 +335,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
// 4. Audio-only MP4 reclassification probe (only when needed)
|
||||
needsAudioMP4
|
||||
? (async () => {
|
||||
const origExt = file?.filename ? file.filename.split('.').pop().toLowerCase() : (inputUrl ? inputUrl.split('?')[0].split('.').pop().toLowerCase() : '');
|
||||
const origExt = file.filename.split('.').pop().toLowerCase();
|
||||
if (['m4a', 'aac'].includes(origExt)) {
|
||||
return 'audio/mp4'; // Extension-based reclassification
|
||||
}
|
||||
@@ -723,18 +438,10 @@ export const handleUpload = async (req, res, self) => {
|
||||
|
||||
|
||||
// Insert
|
||||
const size = file?.data ? file.data.length : (await fs.stat(destPath).then(s => s.size).catch(() => 0));
|
||||
let originalFilename = file ? (file.filename || null) : null;
|
||||
if (!originalFilename && inputUrl) {
|
||||
try {
|
||||
const parsedName = path.basename(new URL(inputUrl).pathname);
|
||||
if (parsedName && parsedName !== '/') originalFilename = parsedName;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const originalFilename = file.filename || null;
|
||||
await db`
|
||||
insert into items ${db({
|
||||
src: inputUrl || '',
|
||||
src: '',
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
size: size,
|
||||
@@ -743,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);
|
||||
@@ -880,7 +584,7 @@ export const handleUpload = async (req, res, self) => {
|
||||
}
|
||||
|
||||
// Auto-tag SWF uploads with "Flash" and "SWF"
|
||||
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
|
||||
if (mime === 'application/x-shockwave-flash' || mime === 'application/vnd.adobe.flash.movie') {
|
||||
const swfTags = ['Flash', 'SWF'];
|
||||
for (const tagname of swfTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
@@ -974,31 +678,29 @@ 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)
|
||||
@@ -1011,31 +713,27 @@ export const handleUpload = async (req, res, self) => {
|
||||
username: req.session.user,
|
||||
display_name: req.session.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility
|
||||
is_oc: !!is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD] new_item notify failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1070,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,
|
||||
|
||||
@@ -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>
|
||||
@@ -117,34 +117,22 @@
|
||||
status.style.color = 'var(--accent)';
|
||||
|
||||
try {
|
||||
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token) || '{{ csrf_token }}';
|
||||
if (!csrfToken || csrfToken.startsWith('{')) {
|
||||
status.textContent = 'CSRF token missing. Please refresh the page.';
|
||||
status.style.color = '#d9534f';
|
||||
return;
|
||||
}
|
||||
const res = await fetch('/admin/settings', {
|
||||
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 ? (approvalToggle.checked ? 'on' : 'off') : 'off',
|
||||
manual_approval: approvalToggle.checked ? 'on' : 'off',
|
||||
...(registrationToggle ? { registration_open: registrationToggle.checked ? 'on' : 'off' } : {}),
|
||||
min_tags: minTagsInput ? minTagsInput.value : '3',
|
||||
trusted_uploads: trustedUploadsInput ? trustedUploadsInput.value : '3',
|
||||
csrf_token: csrfToken
|
||||
min_tags: minTagsInput.value,
|
||||
trusted_uploads: trustedUploadsInput.value,
|
||||
csrf_token: '{{ csrf_token }}'
|
||||
}).toString()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 403) {
|
||||
throw new Error('CSRF validation failed or session expired. Please reload the page.');
|
||||
}
|
||||
throw new Error('Server returned ' + res.status);
|
||||
}
|
||||
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
9
views/chat.html
Normal file
9
views/chat.html
Normal file
@@ -0,0 +1,9 @@
|
||||
@include(snippets/header)
|
||||
<div class="pagewrapper">
|
||||
<div id="main" class="chat-page">
|
||||
<div id="site-chat-container" class="site-chat-container">
|
||||
<!-- Site chat widget is mounted here by globalchat.js -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@include(snippets/footer)
|
||||
@@ -1,46 +1,9 @@
|
||||
<div class="index-layout-wrapper">
|
||||
<div class="index-container">
|
||||
@include(snippets/page-title)
|
||||
|
||||
<div class="posts layout-{{ layout || 'grid' }}" id="posts-container" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}" data-layout="{{ layout || 'grid' }}">
|
||||
<div class="posts" data-current-page="{{ pagination.current }}" data-has-more="{{ pagination.next ? 'true' : 'false' }}">
|
||||
@each(items as item)
|
||||
|
||||
@if(layout == 'stream')
|
||||
<!-- Stream / Vertical Feed Layout -->
|
||||
<div class="stream-card {{ item.is_pinned ? 'is-pinned' : '' }} {{ item.has_notification ? 'has-notif' : '' }}" data-id="{{ item.id }}">
|
||||
<div class="stream-card-header">
|
||||
<a href="/user/{{ item.username.toLowerCase() }}" class="stream-author">
|
||||
<span class="stream-username" @if(item.author_color)style="color: {{ item.author_color }}"@endif>{!! item.display_name || item.username !!}</span>
|
||||
</a>
|
||||
<div class="stream-meta">
|
||||
@if(item.is_pinned)
|
||||
<span class="badge-pin"><i class="fa-solid fa-thumbtack"></i> Pinned</span>
|
||||
@endif
|
||||
@if(item.is_oc)
|
||||
<span class="badge-oc">OC</span>
|
||||
@endif
|
||||
@if(enable_xd_score && item.xd_tier > 0)
|
||||
<span class="thumb-xd-indicator xd-tier-{{ item.xd_tier }}">xD: {{ item.xd_score }}</span>
|
||||
@endif
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-permalink"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-media-wrapper">
|
||||
<img src="/t/{{ item.id }}.webp" alt="Media thumbnail" class="stream-media-img" loading="lazy">
|
||||
<span class="stream-media-badge">{{ item.mime.split('/')[1].replace('youtube', 'YT').replace('x-shockwave-flash', 'FLASH').toUpperCase() }}</span>
|
||||
</a>
|
||||
|
||||
<div class="stream-card-footer">
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-action-btn">
|
||||
<i class="fa-regular fa-comments"></i> View Post & Comments
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@else
|
||||
<!-- Grid & Masonry Layout (Default) -->
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||
<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>
|
||||
@@ -54,8 +17,6 @@
|
||||
</div>
|
||||
<p></p>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@endeach
|
||||
</div>
|
||||
<div id="footbar">
|
||||
|
||||
@@ -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>
|
||||
@@ -177,7 +175,7 @@
|
||||
<span class="tags-inner">
|
||||
@if(typeof item.tags !== "undefined")
|
||||
@each(item.tags as tag)
|
||||
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
||||
<span tooltip="{!! tag.display_name || tag.user !!}" class="badge {{ tag.badge }}">
|
||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
||||
</span>
|
||||
@endeach
|
||||
@@ -192,18 +190,14 @@
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</a>
|
||||
@if(can_manage_item)
|
||||
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
<button class="rating-btn {{ item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')) }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</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)
|
||||
@@ -236,45 +230,79 @@
|
||||
|
||||
</div>
|
||||
|
||||
@include(snippets/info-modal)
|
||||
|
||||
|
||||
<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 id="info-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-body" style="padding: 20px 0; text-align: left;">
|
||||
<table class="info-table">
|
||||
@if(enable_item_title && can_manage_item)
|
||||
<tr class="info-title-row">
|
||||
<th>Title</th>
|
||||
<td>
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
</td>
|
||||
</tr>
|
||||
@elseif(enable_item_title && item.title)
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>{!! item.title !!}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
</tr>
|
||||
@if(item_has_dimensions)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
|
||||
<td>{{ item.width }} × {{ item.height }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
|
||||
<td><code>{{ item.mime }}</code></td>
|
||||
</tr>
|
||||
@if(item.checksum)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
|
||||
<tr class="info-repost-row">
|
||||
<th>Repost</th>
|
||||
<td>
|
||||
@each(item.reposts as rp)
|
||||
@if(rp.match_type === 'phash')
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
|
||||
@else
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
|
||||
<td>
|
||||
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
|
||||
{{ t('info_modal.view_file') || 'View File' }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@if(item.src.short)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
</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 class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
</a>
|
||||
@if(can_manage_item)
|
||||
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" data-item-id="{{ item.id }}" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
<button class="rating-btn {{ item_rating_class }}" id="a_toggle" title="Toggle Rating"><i class="fa-solid fa-arrow-right-arrow-left"></i></button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@@ -42,7 +42,7 @@
|
||||
<span class="badge badge-dark" id="tags" style="display: flex; flex-wrap: wrap; gap: 5px; background: transparent; padding: 0; text-align: left; white-space: normal;">
|
||||
@if(typeof item.tags !== "undefined")
|
||||
@each(item.tags as tag)
|
||||
<span @if(session && (tag.display_name || tag.user)) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
||||
<span @if(session) tooltip="{!! tag.display_name || tag.user !!}" @endif class="badge {{ tag.badge }}">
|
||||
<a href="/tag/{{ tag.normalized }}">{!! tag.tag !!}</a>@if(is_mod_or_admin) <a class="removetag" href="#"><i class="fa-solid fa-xmark"></i></a>@endif
|
||||
</span>
|
||||
@endeach
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="item-main-content">
|
||||
|
||||
<div class="_204863">
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}{{ link.suffix }}</div>
|
||||
<div class="location">{{ link.mainDisplay || link.main }}{{ item.id }}{{ link.suffix }}</div>
|
||||
<div class="gapLeft"></div>
|
||||
</div>
|
||||
@if(enable_item_title)
|
||||
@@ -119,25 +119,20 @@
|
||||
</div>
|
||||
<div class="blahlol">
|
||||
<span class="badge badge-dark">
|
||||
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="id-link" data-item-id="{{ item.id }}">{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
<a href="/{{ item.id }}" class="id-link">{{ item.id }}</a> — [<a id="a_username" data-username="{{ item.username || '' }}" @if(session) data-author-id="{{ item.author_id || '' }}" @endif href="/user/{{ item_username_lower }}" @if(session && item.author_id) tooltip="ID: {{ item.author_id }}" @endif @if(item.author_color) style="color: {{ item.author_color }}" @endif>{!! item.author_display_name || item.username || 'unknown' !!}</a>] <span id="oc-badge-container">@if(item.is_oc) — <span class="oc-badge" tooltip="Original Content">OC</span>@endif</span>
|
||||
</span>@if(halls_enabled && item.primaryHall) — @endif
|
||||
@if(halls_enabled && item.primaryHall)
|
||||
<span class="badge hall-badge-wrap">
|
||||
<a href="/h/{{ item.primaryHall.slug }}" class="hall-badge-primary"><i class="fa-solid fa-layer-group"></i> {{ item.primaryHall.name }}</a>@if(item.otherHalls && item.otherHalls.length)<span class="hall-overflow-pill">+{{ item.otherHalls.length }}<span class="hall-overflow-tooltip">@each(item.otherHalls as oh)<a href="/h/{{ oh.slug }}">{{ oh.name }}</a>@endeach</span></span>@endif
|
||||
</span> —
|
||||
@endif
|
||||
<span class="badge badge-dark"><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||
@if(item.expires_at)
|
||||
— <span class="badge badge-warning" style="background: rgba(255, 170, 0, 0.2); color: #ffaa00; border: 1px solid rgba(255, 170, 0, 0.4);" tooltip="Self-destructs {{ item.expires_in }}" flow="up"><i class="fa-solid fa-clock"></i> Expiring ({{ item.expires_in }})</span>
|
||||
@endif
|
||||
|
||||
|
||||
<span class="badge badge-dark"><a href="/{{ item.id }}" class="timestamp-link"><time class="timeago" tooltip="{{ item.timestamp.timefull }}">{{item.timestamp.timeago }}</time></a></span>
|
||||
<div class="gapRight">
|
||||
@if(session)
|
||||
@if(user_has_favorited)
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-solid fa-heart" id="a_favo" title="Favorite"></i>
|
||||
@else
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" data-item-id="{{ item.id }}" title="Favorite"></i>
|
||||
<i class="iconset fa-regular fa-heart" id="a_favo" title="Favorite"></i>
|
||||
@endif
|
||||
<i class="iconset fa-solid fa-circle-info" id="a_info" data-item-id="{{ item.id }}" title="{{ t('info_modal.button_title') || 'Post & File Info' }}"></i>
|
||||
<i class="iconset {{ isSubscribed ? 'fa-solid' : 'fa-regular' }} fa-bell" id="subscribe-btn" data-item-id="{{ item.id }}" title="{{ isSubscribed ? 'Subscribed' : 'Subscribe' }}"></i>
|
||||
@@ -162,11 +157,7 @@
|
||||
</div>
|
||||
<span id="favs" @if(!item.favorites.length) hidden@endif style="margin-top: 5px;">
|
||||
@each(item.favorites as fav)
|
||||
@if(fav.hide_fav_badge && (!session || session.user !== fav.user))
|
||||
<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>
|
||||
|
||||
@@ -181,45 +172,79 @@
|
||||
|
||||
</div>
|
||||
|
||||
@include(snippets/info-modal)
|
||||
|
||||
|
||||
<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 id="info-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-body" style="padding: 20px 0; text-align: left;">
|
||||
<table class="info-table">
|
||||
@if(enable_item_title && can_manage_item)
|
||||
<tr class="info-title-row">
|
||||
<th>Title</th>
|
||||
<td>
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button id="info-title-save" class="info-title-save-btn"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
</td>
|
||||
</tr>
|
||||
@elseif(enable_item_title && item.title)
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<td>{!! item.title !!}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.file_size') || 'File Size' }}</th>
|
||||
<td>{{ item.size }}</td>
|
||||
</tr>
|
||||
@if(item_has_dimensions)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.dimensions') || 'Dimensions' }}</th>
|
||||
<td>{{ item.width }} × {{ item.height }}</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.mime_type') || 'MIME Type' }}</th>
|
||||
<td><code>{{ item.mime }}</code></td>
|
||||
</tr>
|
||||
@if(item.checksum)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.sha256') || 'SHA-256 Hash' }}</th>
|
||||
<td><code style="word-break: break-all;">{{ item.checksum.split('_bypass_')[0] }}</code></td>
|
||||
</tr>
|
||||
@endif
|
||||
@if(item.is_repost || (item.reposts && item.reposts.length > 0))
|
||||
<tr class="info-repost-row">
|
||||
<th>Repost</th>
|
||||
<td>
|
||||
@each(item.reposts as rp)
|
||||
@if(rp.match_type === 'phash')
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px; opacity: 0.75;" tooltip="Visually similar (perceptual hash)" flow="up">~#{{ rp.id }}</a>
|
||||
@else
|
||||
<a href="/{{ rp.id }}" style="margin-right: 4px;" tooltip="Exact duplicate (checksum)" flow="up">#{{ rp.id }}</a>
|
||||
@endif
|
||||
@endeach
|
||||
</td>
|
||||
</tr>
|
||||
@endif
|
||||
<tr>
|
||||
<th>{{ t('info_modal.direct_url') || 'Direct URL' }}</th>
|
||||
<td>
|
||||
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank">
|
||||
{{ t('info_modal.view_file') || 'View File' }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@if(item.src.short)
|
||||
<tr>
|
||||
<th>{{ t('info_modal.source') || 'Source' }}</th>
|
||||
<td><a href="{{ item.src.long }}" target="_blank">{{ item.src.short }}</a></td>
|
||||
</tr>
|
||||
@endif
|
||||
</table>
|
||||
</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 class="modal-actions" style="display: flex; justify-content: flex-end; gap: 10px;">
|
||||
<button class="btn-secondary" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@
|
||||
<tbody>
|
||||
@each(favotop as favo)
|
||||
<tr>
|
||||
<td><a href="/{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}">#{{ (enable_item_slugs && favo.slug) ? favo.slug : favo.id }}</a></td>
|
||||
<td><a href="/{{ favo.item_id }}">#{{ favo.item_id }}</a></td>
|
||||
<td>{{ favo.favs }} <span style="opacity: 0.5; font-size: 0.8em;">{{ t('ranking.favs') }}</span></td>
|
||||
</tr>
|
||||
@endeach
|
||||
@@ -109,7 +109,7 @@
|
||||
<tbody>
|
||||
@each(xdtop as item)
|
||||
<tr>
|
||||
<td><a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</a></td>
|
||||
<td><a href="/{{ item.id }}">#{{ item.id }}</a></td>
|
||||
<td>
|
||||
<span class="xd-score-badge xd-tier-{{ item.xd_tier }}" tooltip="xD Score: {{ item.xd_score }} pts" flow="up">
|
||||
{{ item.xd_label }} <span class="xd-score-num">{{ item.xd_score }}</span>
|
||||
|
||||
@@ -2,24 +2,24 @@
|
||||
<div class="pagewrapper">
|
||||
<div id="main">
|
||||
<div class="settings">
|
||||
<h1><i class="fa-solid fa-sliders"></i> {{ t('settings.title') }}</h1>
|
||||
<h1>{{ t('settings.title') }}</h1>
|
||||
|
||||
<!-- Quick navigation -->
|
||||
<nav id="settings-quicknav" aria-label="Settings sections">
|
||||
<a href="#profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</a>
|
||||
<a href="#preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</a>
|
||||
<a href="#profile">{{ t('settings.profile') }}</a>
|
||||
<a href="#preferences">{{ t('settings.preferences') }}</a>
|
||||
@if(enable_data_export)
|
||||
<a href="#export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') }}</a>
|
||||
<a href="#export">{{ t('settings.export_data_title') }}</a>
|
||||
@endif
|
||||
<a href="#account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</a>
|
||||
<a href="#account">{{ t('settings.account') }}</a>
|
||||
@if(matrix_enabled || telegram_enabled)
|
||||
<a href="#linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</a>
|
||||
<a href="#linked">{{ t('settings.linked_accounts') }}</a>
|
||||
@endif
|
||||
@if(enable_user_api_keys)
|
||||
<a href="#apikey"><i class="fa-solid fa-key"></i> API Key</a>
|
||||
<a href="#apikey">API Key</a>
|
||||
@endif
|
||||
@if(enable_user_invites)
|
||||
<a href="#invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</a>
|
||||
<a href="#invites">{{ t('invites.section_title') }}</a>
|
||||
@endif
|
||||
</nav>
|
||||
<style>
|
||||
@@ -75,186 +75,187 @@
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════ PROFILE ═══════════════════════════════ -->
|
||||
<h2 id="profile"><i class="fa-solid fa-user-pen"></i> {{ t('settings.profile') }}</h2>
|
||||
|
||||
<p class="text-muted" style="margin-bottom: 16px; font-size: 0.9em; display: flex; align-items: center; gap: 8px;">
|
||||
<i class="fa-solid fa-wand-magic-sparkles" style="color: var(--accent);"></i> Click directly on your profile card below to edit your avatar, banner, name, color, or bio.
|
||||
</p>
|
||||
|
||||
<div class="direct-edit-profile-card-wrapper" style="position: relative; max-width: 750px; margin-bottom: 30px;">
|
||||
|
||||
<!-- Interactive User Card -->
|
||||
<div class="user-infobox-block editable-profile-card" id="live-profile-preview-box"
|
||||
style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
|
||||
|
||||
<!-- Avatar with Hover Overlay -->
|
||||
<div class="user-infobox-avatar editable-avatar-container" id="avatar-choose-btn" title="Click to change avatar">
|
||||
<a href="#" onclick="event.preventDefault(); document.getElementById('avatar-file-input').click();" style="display:block; position:relative; width:100%; height:100%;">
|
||||
@if(avatar_file)
|
||||
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/{{ avatar_file }}" />
|
||||
@elseif(session.avatar && session.avatar > 0)
|
||||
<img id="live-preview-avatar" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp" />
|
||||
@else
|
||||
<img id="live-preview-avatar" class="avatar-preview-img" src="/a/default.png" style="border-color: #444;" />
|
||||
@endif
|
||||
<div class="avatar-hover-overlay">
|
||||
<i class="fa-solid fa-camera"></i>
|
||||
</div>
|
||||
</a>
|
||||
<h2 id="profile">{{ t('settings.profile') }}</h2>
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.avatar') }}</legend>
|
||||
<div class="avatar-settings-wrapper">
|
||||
<div class="avatar-preview-container">
|
||||
<div class="avatar-preview-label">{{ t('settings.current_avatar') }}</div>
|
||||
@if(avatar_file)
|
||||
<button type="button" class="avatar-quick-remove" id="avatar-remove-btn" title="Remove custom avatar">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
<a href="/user/{!! session.user !!}" target="_blank">
|
||||
<img id="avatar-preview" class="avatar-preview-img" src="/a/{{ avatar_file }}">
|
||||
</a>
|
||||
@elseif(session.avatar && session.avatar > 0)
|
||||
<a href="/user/{!! session.user !!}" target="_blank">
|
||||
<img id="avatar-preview" class="avatar-preview-img" src="/t/{{ session.avatar }}.webp">
|
||||
</a>
|
||||
@else
|
||||
<div id="avatar-preview" class="avatar-preview-img avatar-placeholder">?</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="user-infobox-info">
|
||||
<div class="user-infobox-header">
|
||||
<div class="user-infobox-username-container" style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||||
<div class="avatar-upload-section">
|
||||
<p class="avatar-hint">{{ t('settings.avatar_hint') }}</p>
|
||||
|
||||
<!-- Editable Display Name -->
|
||||
<div class="inline-edit-username-wrapper">
|
||||
<span id="live-preview-username" class="user-infobox-username" contenteditable="true" spellcheck="false" title="Click to edit display name">{!! session.display_name || session.user !!}</span>
|
||||
<i class="fa-solid fa-pen" style="font-size: 0.65em; opacity: 0.5; margin-left: 2px;" title="Editable"></i>
|
||||
</div>
|
||||
|
||||
<!-- Color Picker Badge -->
|
||||
<div class="inline-color-picker-wrapper" style="display: inline-flex; align-items: center;">
|
||||
<label for="username_color_picker" class="color-picker-badge" id="color-picker-badge" style="background: {{ session.username_color || 'var(--accent)' }};" title="Click to change username color">
|
||||
<i class="fa-solid fa-palette"></i>
|
||||
</label>
|
||||
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}" style="position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;">
|
||||
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" style="display: none;">
|
||||
</div>
|
||||
|
||||
<!-- Hidden Backing Save Buttons for JS event trigger -->
|
||||
<button type="button" id="btn-update-display-name" class="inline-save-btn" style="display: none;"><i class="fa-solid fa-check"></i> Save Name</button>
|
||||
<button type="button" id="btn-save-username-color" style="display: none;">Save Color</button>
|
||||
<button type="button" id="btn-reset-username-color" style="display: none;">Reset</button>
|
||||
|
||||
</div>
|
||||
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
|
||||
<div class="avatar-upload-wrapper">
|
||||
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
|
||||
<button type="button" id="avatar-choose-btn" class="button">{{ t('settings.choose_file') }}</button>
|
||||
<span id="avatar-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="user-infobox-body">
|
||||
|
||||
<!-- Editable Description -->
|
||||
<div class="inline-edit-description-wrapper">
|
||||
<div class="user-infobox-description" id="live-preview-description" contenteditable="true" spellcheck="false" title="Click to edit description" data-placeholder="Click to write a bio...">
|
||||
{!! session.description || '' !!}
|
||||
</div>
|
||||
<div class="description-edit-controls" id="desc-edit-controls" style="display: flex; margin-top: 10px; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||||
<button type="button" id="btn-save-description" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-floppy-disk"></i> {{ t('settings.save_description') }}</button>
|
||||
<button type="button" id="btn-clear-description" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> {{ t('settings.clear') }}</button>
|
||||
@if(user_banner_enabled)
|
||||
<button type="button" id="banner-choose-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-camera"></i> Change Banner</button>
|
||||
@if(banner_file)
|
||||
<button type="button" id="banner-edit-btn" class="button button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-arrows-up-down-left-right"></i> Reposition</button>
|
||||
<button type="button" id="banner-remove-btn" class="button button-danger button-sm" style="padding: 4px 10px; font-size: 0.8em;"><i class="fa-solid fa-trash"></i> Remove Banner</button>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-infobox-actions">
|
||||
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
|
||||
<div class="avatar-progress-wrapper" id="avatar-progress-wrapper" style="display: none;">
|
||||
<div class="avatar-progress-bar">
|
||||
<div class="avatar-progress-fill" id="avatar-progress-fill"></div>
|
||||
</div>
|
||||
<span class="avatar-progress-text" id="avatar-progress-text">0%</span>
|
||||
</div>
|
||||
|
||||
<div class="avatar-upload-actions">
|
||||
<button type="button" id="avatar-upload-btn" class="button" disabled>{{ t('settings.upload_btn') }}</button>
|
||||
@if(avatar_file)
|
||||
<button type="button" id="avatar-remove-btn" class="button button-danger">{{ t('settings.remove_custom') }}</button>
|
||||
@endif
|
||||
</div>
|
||||
<div id="avatar-upload-status" class="avatar-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
|
||||
<!-- Hidden Backing Elements for JavaScript compatibility -->
|
||||
<input type="file" id="avatar-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
|
||||
<span id="avatar-filename" style="display: none;"></span>
|
||||
<button type="button" id="avatar-upload-btn" style="display: none;"></button>
|
||||
<div id="avatar-progress-wrapper" style="display: none;">
|
||||
<div id="avatar-progress-fill"></div>
|
||||
<span id="avatar-progress-text"></span>
|
||||
@if(enable_profile_description)
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.custom_description') }}</legend>
|
||||
<div class="setting-item">
|
||||
<textarea id="profile_description" class="input" placeholder="{{ t('settings.description_placeholder') }}"
|
||||
maxlength="2000">{!! session.description || '' !!}</textarea>
|
||||
<div class="profile-settings-actions">
|
||||
<button type="button" id="btn-save-description" class="button">{{ t('settings.save_description') }}</button>
|
||||
<button type="button" id="btn-clear-description" class="button button-danger">{{ t('settings.clear') }}</button>
|
||||
</div>
|
||||
<div id="description-status" class="avatar-status"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@endif
|
||||
|
||||
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" style="display: none;">
|
||||
<span id="banner-filename" style="display: none;"></span>
|
||||
<button type="button" id="banner-upload-btn" style="display: none;"></button>
|
||||
<div id="banner-progress-wrapper" style="display: none;">
|
||||
<div id="banner-progress-fill"></div>
|
||||
<span id="banner-progress-text"></span>
|
||||
@if(user_banner_enabled)
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Profile Banner</legend>
|
||||
<div class="avatar-settings-wrapper">
|
||||
<div class="avatar-preview-container" style="width: 360px; max-width: 100%;">
|
||||
<div class="avatar-preview-label">{{ t('settings.current_banner') }}</div>
|
||||
@if(banner_file)
|
||||
<a href="/user/{!! session.user !!}" target="_blank" style="display:block; width: 100%;">
|
||||
<div id="banner-preview" class="banner-preview-img" style="width:100%;aspect-ratio:400/140;border-radius:4px;border:1px solid var(--nav-border-color);background-image:url('/a/{{ banner_file }}');background-size:{{ banner_size || 'cover' }};background-position:{{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }};background-repeat:no-repeat;"></div>
|
||||
</a>
|
||||
@else
|
||||
<div id="banner-preview" class="banner-preview-img banner-placeholder" style="width:100%;aspect-ratio:400/140;display:flex;align-items:center;justify-content:center;border:1px dashed var(--nav-border-color);border-radius:4px;color:var(--text-muted);font-size:0.85em;">{{ t('settings.no_banner_set') }}</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="avatar-upload-section">
|
||||
<p class="avatar-hint">{{ t('settings.banner_hint') }}</p>
|
||||
<div class="avatar-upload-wrapper">
|
||||
<input type="file" id="banner-file-input" accept="image/gif,image/jpeg,image/png,image/webp" hidden>
|
||||
<button type="button" id="banner-choose-btn" class="button">{{ t('settings.choose_file') }}</button>
|
||||
<span id="banner-filename" class="avatar-filename">{{ t('settings.no_file_selected') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="avatar-progress-wrapper" id="banner-progress-wrapper" style="display: none;">
|
||||
<div class="avatar-progress-bar">
|
||||
<div class="avatar-progress-fill" id="banner-progress-fill"></div>
|
||||
</div>
|
||||
<span class="avatar-progress-text" id="banner-progress-text">0%</span>
|
||||
</div>
|
||||
|
||||
<div class="avatar-upload-actions">
|
||||
<button type="button" id="banner-upload-btn" class="button" disabled>Upload Banner</button>
|
||||
@if(banner_file)
|
||||
<button type="button" id="banner-edit-btn" class="button">Edit Position</button>
|
||||
<button type="button" id="banner-remove-btn" class="button button-danger">Remove Banner</button>
|
||||
@endif
|
||||
</div>
|
||||
<div id="banner-upload-status" class="avatar-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@endif
|
||||
|
||||
<input type="text" id="display_name_input" value="{!! session.display_name || '' !!}" style="display: none;">
|
||||
@if(enable_profile_description)
|
||||
<textarea id="profile_description" style="display: none;">{!! session.description || '' !!}</textarea>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.username_color') }}</legend>
|
||||
<div class="setting-item">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="color" id="username_color_picker" value="{{ session.username_color || '#ffffff' }}"
|
||||
style="width: 50px; height: 30px; padding: 0; border: 1px solid var(--nav-border-color); cursor: pointer; background: none;">
|
||||
<input type="text" id="username_color_hex" value="{{ session.username_color || '#ffffff' }}" maxlength="7"
|
||||
placeholder="#ffffff" class="input"
|
||||
style="width: 90px; font-family: monospace; font-size: 0.9em; padding: 4px 8px; height: 30px;">
|
||||
<button type="button" id="btn-save-username-color" class="button"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save_color') }}</button>
|
||||
<button type="button" id="btn-reset-username-color" class="button button-secondary"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.reset') }}</button>
|
||||
</div>
|
||||
<small class="text-muted">{{ t('settings.username_color_hint') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.display_name') }}</legend>
|
||||
<div class="setting-item">
|
||||
<div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
|
||||
<input type="text" id="display_name_input" class="input"
|
||||
placeholder="{{ t('settings.display_name_placeholder') }}" value="{!! session.display_name || '' !!}"
|
||||
maxlength="32" style="max-width: 200px; height: 30px; font-size: 0.9em;">
|
||||
<button type="button" id="btn-update-display-name" class="button"
|
||||
style="padding: 5px 10px; font-size: 0.85em;">{{ t('settings.save') }}</button>
|
||||
</div>
|
||||
<div id="display-name-status" class="avatar-status"></div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">Live Profile Preview</legend>
|
||||
<div class="setting-item" style="max-width: 750px;">
|
||||
<div class="user-infobox-block" id="live-profile-preview-box" style="--author-accent: {{ session.username_color || 'var(--accent)' }}; --author-border: {{ session.username_color || 'var(--accent)' }}; @if(banner_file && user_banner_enabled) --author-banner: url('/a/{{ banner_file }}?t={{ Date.now() }}'); --author-banner-position: {{ banner_position === 'center' ? 'center top' : (banner_position || 'center top') }}; --author-banner-size: {{ banner_size || 'cover' }}; @endif">
|
||||
<div class="user-infobox-avatar">
|
||||
<a href="#">
|
||||
@if(avatar_file)
|
||||
<img id="live-preview-avatar" src="/a/{{ avatar_file }}" />
|
||||
@else
|
||||
<img id="live-preview-avatar" src="/a/default.png" style="border-color: #444;" />
|
||||
@endif
|
||||
</a>
|
||||
</div>
|
||||
<div class="user-infobox-info">
|
||||
<div class="user-infobox-header">
|
||||
<div class="user-infobox-username-container">
|
||||
<a id="live-preview-username" href="#" class="user-infobox-username">{!! session.display_name || session.user !!}</a>
|
||||
</div>
|
||||
<span class="user-infobox-timestamp"><a href="#" class="timestamp-link"><time>just now</time></a></span>
|
||||
</div>
|
||||
<div class="user-infobox-body">
|
||||
<div class="user-infobox-description">
|
||||
{!! session.description || 'Welcome to my profile! This is a live preview.' !!}
|
||||
</div>
|
||||
<div class="user-infobox-actions">
|
||||
<i class="iconset fa-solid fa-heart" title="Favorite"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- ═══════════════════════════════ PREFERENCES ═══════════════════════════════ -->
|
||||
<h2 id="preferences"><i class="fa-solid fa-sliders"></i> {{ t('settings.preferences') }}</h2>
|
||||
<h2 id="preferences">{{ t('settings.preferences') }}</h2>
|
||||
<div class="preferences-settings-wrapper">
|
||||
|
||||
<!-- 1. Appearance & Theme -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-palette"></i> {{ t('settings.appearance_section') }}</legend>
|
||||
|
||||
<div class="setting-item" style="margin-top: 10px;">
|
||||
<label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;">
|
||||
@each(themes as t)
|
||||
<option value="{{ t }}" @if(theme===t) selected @endif>{{ t }}</option>
|
||||
@endeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="website_font_select" class="input" style="flex: 1; max-width: 300px;">
|
||||
<option value="">{{ t('settings.font_default') }}</option>
|
||||
@each(fonts as font)
|
||||
<option value="{{ font.file }}" @if(session.font===font.file) selected @endif>{{ font.name }}</option>
|
||||
@endeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(allow_language_change)
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
|
||||
<select id="language_select" class="input" style="padding: 6px 10px; max-width: 300px;">
|
||||
<option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
|
||||
<option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
|
||||
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
|
||||
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
|
||||
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}</option>
|
||||
</select>
|
||||
<small class="text-muted" style="display: block; margin-top: 4px;">{{ t('settings.language_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
|
||||
<span>{{ t('settings.enable_bg_blur') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.ui_section') }}</legend>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="show_motd_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="show_motd_toggle" @if(session.show_motd !==false) checked @endif>
|
||||
<span>{{ t('settings.show_motd') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 2. Interface & Layout -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-desktop"></i> {{ t('settings.ui_section') }}</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="use_new_layout_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="use_new_layout_toggle" @if(session.use_new_layout===true) checked @endif>
|
||||
@@ -262,7 +263,6 @@
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.modern_layout_hint') }}</small>
|
||||
</div>
|
||||
|
||||
@if(!session.use_new_layout)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="alternative_infobox_toggle"
|
||||
@@ -272,6 +272,8 @@
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.alternative_infobox_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
@if(!session.use_new_layout)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="alternative_steuerung_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
@@ -282,6 +284,64 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="wheel_nav_toggle">
|
||||
<span>{{ t('settings.scroll_nav') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="image_expand_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="image_expand_toggle">
|
||||
<span>{{ t('settings.image_expand_on_click') || 'Expand images inline on click' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.image_expand_on_click_hint') || 'Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.' }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
<label for="f_for_fullscreen_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="f_for_fullscreen_toggle">
|
||||
<span>{{ t('settings.f_for_fullscreen') || 'F is for Fullscreen' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.f_for_fullscreen_hint') || 'Pressing F will toggle fullscreen in the video player instead of favoriting the post.' }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="disable_autoplay_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
|
||||
<span>{{ t('settings.disable_autoplay') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping===true) checked @endif>
|
||||
<span>{{ t('settings.disable_swiping') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="show_background_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="show_background_toggle" @if(session.show_background !==false) checked @endif>
|
||||
<span>{{ t('settings.enable_bg_blur') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.enable_bg_blur_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !==false) checked @endif>
|
||||
<span>{{ t('settings.render_emojis') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="embed_youtube_in_comments_toggle"
|
||||
style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
|
||||
<span>{{ t('settings.embed_yt') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
|
||||
</div>
|
||||
@if(show_koepfe)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="hide_koepfe_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
@@ -291,64 +351,28 @@
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.hide_koepfe_hint') }}</small>
|
||||
</div>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
<!-- 3. Playback & Navigation Controls -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-play"></i> Playback & Navigation</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="disable_autoplay_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_autoplay_toggle" @if(session.disable_autoplay===true) checked @endif>
|
||||
<span>{{ t('settings.disable_autoplay') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_autoplay_hint') }}</small>
|
||||
</div>
|
||||
|
||||
@if(allow_language_change)
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="disable_swiping_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="disable_swiping_toggle" @if(session.disable_swiping===true) checked @endif>
|
||||
<span>{{ t('settings.disable_swiping') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.disable_swiping_hint') }}</small>
|
||||
<label for="language_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.language') }}</label>
|
||||
<select id="language_select" class="input" style="padding: 6px 10px; max-width: 220px;">
|
||||
<option value="" @if(!session.language) selected @endif>{{ t('settings.language_default') }}</option>
|
||||
<option value="en" @if(session.language==='en' ) selected @endif>{{ t('settings.language_en') }}</option>
|
||||
<option value="de" @if(session.language==='de' ) selected @endif>{{ t('settings.language_de') }}</option>
|
||||
<option value="nl" @if(session.language==='nl' ) selected @endif>{{ t('settings.language_nl') }}</option>
|
||||
<option value="zange" @if(session.language==='zange' ) selected @endif>{{ t('settings.language_zange') }}
|
||||
</option>
|
||||
</select>
|
||||
<br><small class="text-muted">{{ t('settings.language_hint') }}</small>
|
||||
</div>
|
||||
|
||||
@endif
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="wheel_nav_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="wheel_nav_toggle">
|
||||
<span>{{ t('settings.scroll_nav') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.scroll_nav_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="image_expand_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="image_expand_toggle">
|
||||
<span>{{ t('settings.image_expand_on_click') || 'Expand images inline on click' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.image_expand_on_click_hint') || 'Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="f_for_fullscreen_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="f_for_fullscreen_toggle">
|
||||
<span>{{ t('settings.f_for_fullscreen') || 'F is for Fullscreen' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.f_for_fullscreen_hint') || 'Pressing F will toggle fullscreen in the video player instead of favoriting the post.' }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 4. Comments & Discussion -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-comments"></i> Comments & Discussion</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="comment_display_mode_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.comment_display_mode') }}</label>
|
||||
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 300px;" @if(session.force_comment_display_mode) disabled @endif>
|
||||
<select id="comment_display_mode_select" class="input" style="padding: 6px 10px; max-width: 220px;" @if(session.force_comment_display_mode) disabled @endif>
|
||||
<option value="0" @if(session.comment_display_mode==0) selected @endif>{{ t('settings.comment_display_tree') }}</option>
|
||||
<option value="1" @if(session.comment_display_mode==1) selected @endif>{{ t('settings.comment_display_linear') }}</option>
|
||||
</select>
|
||||
<small class="text-muted" style="display: block; margin-top: 4px;">
|
||||
<br><small class="text-muted">
|
||||
@if(session.force_comment_display_mode)
|
||||
<strong>{{ t('settings.forced_mode_notice') || 'This setting is managed by an administrator.' }}</strong>
|
||||
@else
|
||||
@@ -356,68 +380,6 @@
|
||||
@endif
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="quote_emojis_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="quote_emojis_toggle" @if(session.quote_emojis !==false) checked @endif>
|
||||
<span>{{ t('settings.render_emojis') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.render_emojis_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="embed_youtube_in_comments_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="embed_youtube_in_comments_toggle" @if(session.embed_youtube_in_comments !==false) checked @endif>
|
||||
<span>{{ t('settings.embed_yt') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.embed_yt_hint') }}</small>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 5. Content Preferences & Blurring -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-eye-slash"></i> {{ t('settings.content_preferences_section') }}</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="blur_sfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_sfw_toggle">
|
||||
<span>{{ t('settings.blur_sfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_sfw_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_nsfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfw_toggle">
|
||||
<span>{{ t('settings.blur_nsfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfw_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_nsfl_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfl_toggle">
|
||||
<span>{{ t('settings.blur_nsfl') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfl_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_untagged_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_untagged_toggle">
|
||||
<span>{{ t('settings.blur_untagged') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_untagged_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_detail_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_detail_toggle">
|
||||
<span>{{ t('settings.blur_detail') || 'Click to reveal item on detail page' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_detail_hint') || 'Require clicking to reveal blurred media on the post detail page instead of showing it immediately.' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="hide_item_ratings_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="hide_item_ratings_toggle">
|
||||
@@ -427,45 +389,49 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 6. Privacy & Upload Defaults -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-user-shield"></i> Privacy & Defaults</legend>
|
||||
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.content_preferences_section') }}</legend>
|
||||
<div class="setting-item">
|
||||
<label for="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 for="blur_nsfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfw_toggle">
|
||||
<span>{{ t('settings.blur_nsfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.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>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfw_hint') }}</small>
|
||||
</div>
|
||||
|
||||
@if(allow_user_upload_visibility !== false)
|
||||
<div class="setting-item" style="margin-top: 18px;">
|
||||
<label for="default_upload_visibility_select" style="display: block; font-weight: bold; margin-bottom: 5px;">
|
||||
<span>{{ t('settings.default_upload_visibility') }}</span>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_nsfl_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_nsfl_toggle">
|
||||
<span>{{ t('settings.blur_nsfl') }}</span>
|
||||
</label>
|
||||
<select id="default_upload_visibility_select" class="form-control" style="width: 220px; padding: 6px 10px; background: var(--bg-color); color: var(--text-color); border: 1px solid var(--nav-border-color); border-radius: 4px;">
|
||||
<option value="0" @if(session.default_upload_visibility === 0 || (!session.default_upload_visibility && session.default_upload_visibility !== 0)) selected @endif>{{ t('visibility.public') }}</option>
|
||||
<option value="1" @if(session.default_upload_visibility === 1) selected @endif>{{ t('visibility.unlisted') }}</option>
|
||||
<option value="2" @if(session.default_upload_visibility === 2) selected @endif>{{ t('visibility.private') }}</option>
|
||||
</select>
|
||||
<small class="text-muted" style="display: block; margin-top: 5px;">{{ t('settings.default_upload_visibility_hint') }}</small>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_nsfl_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_sfw_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_sfw_toggle">
|
||||
<span>{{ t('settings.blur_sfw') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_sfw_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label for="blur_untagged_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_untagged_toggle">
|
||||
<span>{{ t('settings.blur_untagged') }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_untagged_hint') }}</small>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 15px; border-top: 1px dashed var(--nav-border-color); padding-top: 15px;">
|
||||
<label for="blur_detail_toggle" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="blur_detail_toggle">
|
||||
<span>{{ t('settings.blur_detail') || 'Click to reveal item on detail page' }}</span>
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.blur_detail_hint') || 'Require clicking to reveal blurred media on the post detail page instead of showing it immediately.' }}</small>
|
||||
</div>
|
||||
@endif
|
||||
</fieldset>
|
||||
|
||||
<!-- 7. Notifications -->
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-bell"></i> {{ t('settings.notifications_section') }}</legend>
|
||||
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.notifications_section') }}</legend>
|
||||
<div class="setting-item">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-system-notifications" @if(session.receive_system_notifications !==false) checked @endif>
|
||||
@@ -473,7 +439,6 @@
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_system_notifications_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-receive-user-notifications" @if(session.receive_user_notifications !==false) checked @endif>
|
||||
@@ -481,7 +446,6 @@
|
||||
</label>
|
||||
<small class="text-muted" style="margin-left: 25px;">{{ t('settings.receive_user_notifications_hint') }}</small>
|
||||
</div>
|
||||
|
||||
<div class="setting-item" style="margin-top: 15px;">
|
||||
<label class="checkbox-container" style="cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="chk-do-not-disturb" @if(session.do_not_disturb===true) checked @endif>
|
||||
@@ -491,10 +455,36 @@
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- 8. Flash / SWF Settings (if enabled) -->
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.appearance_section') }}</legend>
|
||||
<div class="setting-item" style="margin-top: 20px;">
|
||||
<label for="website_font_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.website_font') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="website_font_select" class="input" style="flex: 1; max-width: 300px;">
|
||||
<option value="">{{ t('settings.font_default') }}</option>
|
||||
@each(fonts as font)
|
||||
<option value="{{ font.file }}" @if(session.font===font.file) selected @endif>{{ font.name }}</option>
|
||||
@endeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-item" style="margin-top: 20px;">
|
||||
<label for="website_theme_select" style="display: block; margin-bottom: 5px; font-weight: bold;">{{ t('settings.theme') }}</label>
|
||||
<div style="display: flex; align-items: center; gap: 10px;">
|
||||
<select id="website_theme_select" class="input" style="flex: 1; max-width: 300px;">
|
||||
@each(themes as t)
|
||||
<option value="{{ t }}" @if(theme===t) selected @endif>{{ t }}</option>
|
||||
@endeach
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@if(enable_swf)
|
||||
<fieldset style="border: 1px solid var(--nav-border-color); padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; background: rgba(0,0,0,0.06);">
|
||||
<legend style="width: auto; padding: 0 8px; font-size: 1.05em; font-weight: bold;"><i class="fa-solid fa-bolt"></i> {{ t('settings.flash_section') }}</legend>
|
||||
<fieldset
|
||||
style="border: 1px solid var(--nav-border-color); padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||
<legend style="width: auto; padding: 0 5px; font-size: 1.1em; font-weight: bold;">{{ t('settings.flash_section') }}</legend>
|
||||
|
||||
<div class="setting-item">
|
||||
<label for="ruffle_background_toggle"
|
||||
@@ -509,11 +499,10 @@
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
@if(enable_data_export)
|
||||
<!-- ═══════════════════════════════ EXPORT ═══════════════════════════════ -->
|
||||
<h2 id="export"><i class="fa-solid fa-file-export"></i> {{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
||||
<div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<h2 id="export">{{ t('settings.export_data_title') || 'Export Data' }}</h2>
|
||||
<div class="export-settings-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<p>{{ t('settings.export_data_desc') || 'Download a copy of your data. This process happens entirely in your browser to protect your privacy and save server resources.' }}</p>
|
||||
|
||||
<div class="setting-item" style="margin-bottom: 15px;">
|
||||
@@ -552,11 +541,10 @@
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- ═══════════════════════════════ ACCOUNT ═══════════════════════════════ -->
|
||||
<h2 id="account"><i class="fa-solid fa-shield-halved"></i> {{ t('settings.account') }}</h2>
|
||||
<h2 id="account">{{ t('settings.account') }}</h2>
|
||||
<div class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<table class="table account-info-table"
|
||||
style="margin-bottom: 30px; border-collapse: separate; border-spacing: 0 5px;">
|
||||
<tbody>
|
||||
@@ -582,7 +570,7 @@
|
||||
<div class="account-actions-grid"
|
||||
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 30px;">
|
||||
<div class="password-change-section" style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-key"></i> {{ t('settings.change_password') }}</h4>
|
||||
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.change_password') }}</h4>
|
||||
<form id="password-change-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<input type="password" id="current_password" class="input"
|
||||
placeholder="{{ t('settings.current_password') }}" required autocomplete="current-password">
|
||||
@@ -597,7 +585,7 @@
|
||||
</div>
|
||||
|
||||
<div class="email-update-section" style="display: flex; flex-direction: column; gap: 15px;">
|
||||
<h4 style="margin-top: 0; color: var(--accent);"><i class="fa-solid fa-envelope"></i> {{ t('settings.update_email') }}</h4>
|
||||
<h4 style="margin-top: 0; color: var(--accent);">{{ t('settings.update_email') }}</h4>
|
||||
<form id="email-update-form" style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<input type="email" id="email_input" class="input" placeholder="{{ t('settings.new_email') }}"
|
||||
value="{{ email }}" required>
|
||||
@@ -612,11 +600,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(matrix_enabled || telegram_enabled)
|
||||
<!-- ═══════════════════════════════ LINKED ACCOUNTS ═══════════════════════════════ -->
|
||||
<h2 id="linked"><i class="fa-solid fa-link"></i> {{ t('settings.linked_accounts') }}</h2>
|
||||
<div class="linked-accounts-wrapper" style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<h2 id="linked">{{ t('settings.linked_accounts') }}</h2>
|
||||
<div class="linked-accounts-wrapper">
|
||||
<p>{{ t('settings.matrix_link_desc') }}</p>
|
||||
|
||||
<div id="linked-accounts-container" style="margin-bottom: 1rem;">
|
||||
@@ -652,9 +639,9 @@
|
||||
|
||||
@if(enable_user_api_keys)
|
||||
<!-- ═══════════════════════════════ API KEY ═══════════════════════════════ -->
|
||||
<h2 id="apikey"><i class="fa-solid fa-key"></i> Upload API Key</h2>
|
||||
<h2 id="apikey">Upload API Key</h2>
|
||||
<div id="api-key-section" class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
<p style="color: var(--text-muted); margin-bottom: 15px;">
|
||||
Use this key to upload files via external tools or scripts like ShareX.
|
||||
The key grants <strong>upload access only</strong> to your account.
|
||||
@@ -688,9 +675,9 @@
|
||||
|
||||
@if(enable_user_invites)
|
||||
<!-- ═══════════════════════════════ INVITES ═══════════════════════════════ -->
|
||||
<h2 id="invites"><i class="fa-solid fa-ticket"></i> {{ t('invites.section_title') }}</h2>
|
||||
<h2 id="invites">{{ t('invites.section_title') }}</h2>
|
||||
<div id="invite-section" class="account-settings-wrapper"
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 6px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
style="background: rgba(0,0,0,0.1); padding: 20px; border-radius: 4px; border: 1px solid var(--nav-border-color); margin-bottom: 30px;">
|
||||
|
||||
<p id="invite-section-desc" style="color: var(--text-muted); margin-bottom: 15px;">{{ t('invites.section_desc') }}</p>
|
||||
|
||||
@@ -802,6 +789,8 @@
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
// Read all translated strings from server-rendered data attributes.
|
||||
// Attributes must be lowercase kebab-case: data-foo-bar → dataset.fooBar
|
||||
const ds = document.getElementById('invite-i18n')?.dataset || {};
|
||||
const T = (key, vars) => {
|
||||
let str = ds[key] || key;
|
||||
@@ -836,6 +825,7 @@
|
||||
}
|
||||
|
||||
if (data.is_admin) {
|
||||
// Swap description and hide criteria/slots UI for admins
|
||||
const desc = document.getElementById('invite-section-desc');
|
||||
if (desc) desc.textContent = T('adminDesc');
|
||||
const grid = document.getElementById('invite-criteria-grid');
|
||||
@@ -843,6 +833,7 @@
|
||||
if (grid) grid.style.display = 'none';
|
||||
if (statusLine) statusLine.style.display = 'none';
|
||||
} else {
|
||||
// Update criteria grid
|
||||
const criteriaMap = {
|
||||
'ic-uploads': data.criteria.uploads,
|
||||
'ic-age': data.criteria.age_days,
|
||||
@@ -860,15 +851,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Eligibility badge
|
||||
const badge = document.getElementById('invite-eligible-badge');
|
||||
badge.textContent = data.eligible ? T('eligible') : T('notEligible');
|
||||
badge.style.color = data.eligible ? '#51cf66' : '#ff6b6b';
|
||||
|
||||
// Slots info
|
||||
document.getElementById('invite-slots-info').textContent =
|
||||
T('slotsUsed', { used: data.slots_consumed, total: data.slots_total });
|
||||
|
||||
// Generate button state
|
||||
genBtn.disabled = !data.eligible || (!data.is_admin && data.slots_available <= 0);
|
||||
|
||||
// Token list
|
||||
if (!data.tokens || data.tokens.length === 0) {
|
||||
tokenList.innerHTML = '<span class="text-muted" style="font-size:0.9em;">' + T('noInvites') + '</span>';
|
||||
} else {
|
||||
|
||||
@@ -1,153 +1,56 @@
|
||||
<div id="excluded-tags-overlay" style="display: none;">
|
||||
<div class="filter-modal-container search-container" id="filter-modal-box">
|
||||
<div class="filter-modal-header">
|
||||
<h3 class="filter-modal-title"><i class="fa-solid fa-sliders"></i> {{ t('filter.title') || 'Content Filters' }}</h3>
|
||||
<div id="excluded-tags-close" title="Close Filters">×</div>
|
||||
<div id="excluded-tags-close">×</div>
|
||||
<div class="search-container">
|
||||
<div id="nav_excluded_tags_list"></div>
|
||||
<div class="nav-exclude">
|
||||
<input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') }}" autocomplete="off" enterkeyhint="enter">
|
||||
<div id="nav_exclude_suggestions" class="tag-suggestions"></div>
|
||||
</div>
|
||||
|
||||
<div class="filter-modal-body">
|
||||
<!-- Section 1: View Layout -->
|
||||
<div class="modal-filter-group">
|
||||
<label class="modal-filter-label"><i class="fa-solid fa-table-cells"></i> {{ t('filter.layout') || 'Layout' }}</label>
|
||||
<div class="filter-btn-group layout-group">
|
||||
<button class="filter-btn layout-btn {{ (!layout || layout == 'grid') ? 'active' : '' }}" data-layout="grid" title="Classic Grid Layout">
|
||||
<i class="fa-solid fa-border-all"></i> <span class="btn-text">Grid</span>
|
||||
</button>
|
||||
<button class="filter-btn layout-btn {{ (layout == 'masonry') ? 'active' : '' }}" data-layout="masonry" title="Fluid Masonry Layout">
|
||||
<i class="fa-solid fa-cubes"></i> <span class="btn-text">Masonry</span>
|
||||
</button>
|
||||
<button class="filter-btn layout-btn {{ (layout == 'stream') ? 'active' : '' }}" data-layout="stream" title="Vertical Stream / Feed Layout">
|
||||
<i class="fa-solid fa-list-ul"></i> <span class="btn-text">Stream</span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- modes -->
|
||||
<div class="mode-filter">
|
||||
<button id="nav-shuffle-btn" class="shuffle-btn" title="Random Mode — shuffle all items"><span class="shuffle-icon">( - _ - )</span> {{ t('filter.random_mode') }}</button>
|
||||
<div class="rating-selector" id="rating-selector">
|
||||
<button class="rating-toggle-btn" data-rating="sfw" id="rating-btn-sfw" title="SFW">SFW</button>
|
||||
<button class="rating-toggle-btn" data-rating="nsfw" id="rating-btn-nsfw" title="NSFW">NSFW</button>
|
||||
@if(enable_nsfl)
|
||||
<button class="rating-toggle-btn" data-rating="nsfl" id="rating-btn-nsfl" title="NSFL">NSFL</button>
|
||||
@endif
|
||||
@if(session && session.admin || session && session.is_moderator)
|
||||
<button class="rating-toggle-btn" data-rating="untagged" id="rating-btn-untagged" title="Untagged">UNT</button>
|
||||
@endif
|
||||
<button class="rating-toggle-btn rating-toggle-all" id="rating-btn-all" title="Show All Ratings">ALL</button>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Sorting & Timeframe -->
|
||||
<div class="modal-filter-group">
|
||||
<label class="modal-filter-label"><i class="fa-solid fa-arrow-down-short-wide"></i> {{ t('filter.sort') || 'Sort' }}</label>
|
||||
<div class="filter-btn-group sort-group">
|
||||
<button class="filter-btn sort-btn {{ (!sort || sort == 'newest') ? 'active' : '' }}" data-sort="newest" title="Newest First">
|
||||
<i class="fa-solid fa-clock"></i> Newest
|
||||
</button>
|
||||
<button class="filter-btn sort-btn {{ (sort == 'oldest') ? 'active' : '' }}" data-sort="oldest" title="Oldest First">
|
||||
<i class="fa-solid fa-clock-rotate-left"></i> Oldest
|
||||
</button>
|
||||
<button class="filter-btn sort-btn {{ (sort == 'popular' || sort == 'top' || sort == 'xd') ? 'active' : '' }}" data-sort="popular" title="Highest xD Score">
|
||||
<i class="fa-solid fa-fire"></i> Top xD
|
||||
</button>
|
||||
<button class="filter-btn sort-btn {{ (sort == 'comments') ? 'active' : '' }}" data-sort="comments" title="Most Discussed">
|
||||
<i class="fa-solid fa-comments"></i> Most Comments
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-timeframe-subgroup {{ (sort == 'popular' || sort == 'top' || sort == 'xd' || sort == 'comments') ? 'visible' : '' }}" id="filter-timeframe-section" style="margin-top: 8px;">
|
||||
<div class="filter-btn-group timeframe-group">
|
||||
<button class="filter-btn timeframe-btn {{ (!timeframe || timeframe == 'all') ? 'active' : '' }}" data-timeframe="all">All</button>
|
||||
<button class="filter-btn timeframe-btn {{ (timeframe == 'day') ? 'active' : '' }}" data-timeframe="day">24h</button>
|
||||
<button class="filter-btn timeframe-btn {{ (timeframe == 'week') ? 'active' : '' }}" data-timeframe="week">Week</button>
|
||||
<button class="filter-btn timeframe-btn {{ (timeframe == 'month') ? 'active' : '' }}" data-timeframe="month">Month</button>
|
||||
<button class="filter-btn timeframe-btn {{ (timeframe == 'year') ? 'active' : '' }}" data-timeframe="year">Year</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Rating Selection -->
|
||||
<div class="modal-filter-group">
|
||||
<label class="modal-filter-label"><i class="fa-solid fa-shield-halved"></i> {{ t('filter.rating') || 'Rating' }}</label>
|
||||
<div class="rating-selector filter-toggles rating-group" id="rating-selector">
|
||||
<button class="rating-toggle-btn rating-sfw {{ rating_sfw ? 'active' : '' }}" data-rating="sfw" id="rating-btn-sfw" title="Safe For Work">
|
||||
<i class="fa-solid fa-circle-check"></i> SFW
|
||||
</button>
|
||||
<button class="rating-toggle-btn rating-nsfw {{ rating_nsfw ? 'active' : '' }}" data-rating="nsfw" id="rating-btn-nsfw" title="Not Safe For Work">
|
||||
<i class="fa-solid fa-triangle-exclamation"></i> NSFW
|
||||
</button>
|
||||
@if(enable_nsfl)
|
||||
<button class="rating-toggle-btn rating-nsfl {{ rating_nsfl ? 'active' : '' }}" data-rating="nsfl" id="rating-btn-nsfl" title="Not Safe For Life">
|
||||
<i class="fa-solid fa-skull"></i> NSFL
|
||||
</button>
|
||||
@endif
|
||||
@if(session && (session.admin || session.is_moderator))
|
||||
<button class="rating-toggle-btn rating-untagged {{ rating_untagged ? 'active' : '' }}" data-rating="untagged" id="rating-btn-untagged" title="Untagged">
|
||||
<i class="fa-solid fa-question"></i> Untagged
|
||||
</button>
|
||||
@endif
|
||||
<button class="rating-toggle-btn rating-toggle-all {{ (!rating_sfw && !rating_nsfw && !rating_nsfl && !rating_untagged) || (rating_sfw && rating_nsfw && rating_untagged) ? 'active' : '' }}" id="rating-btn-all" data-rating="" title="Show All Ratings">
|
||||
<i class="fa-solid fa-border-all"></i> ALL
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Media Types -->
|
||||
<div class="modal-filter-group">
|
||||
<label class="modal-filter-label"><i class="fa-solid fa-photo-film"></i> {{ t('filter.media_types') || 'Media Types' }}</label>
|
||||
<div class="filter-toggles mime-group">
|
||||
<button class="filter-toggle-pill mime-pill {{ mime_image ? 'active' : '' }}" data-mime="image" title="Images">
|
||||
<i class="fa-regular fa-image"></i> Image
|
||||
</button>
|
||||
<button class="filter-toggle-pill mime-pill {{ mime_video ? 'active' : '' }}" data-mime="video" title="Videos">
|
||||
<i class="fa-solid fa-film"></i> Video
|
||||
</button>
|
||||
<button class="filter-toggle-pill mime-pill {{ mime_audio ? 'active' : '' }}" data-mime="audio" title="Audio / Music">
|
||||
<i class="fa-solid fa-music"></i> Audio
|
||||
</button>
|
||||
@if(enable_swf)
|
||||
<button class="filter-toggle-pill mime-pill {{ mime_flash ? 'active' : '' }}" data-mime="flash" title="Flash Animations / Games">
|
||||
<i class="fa-solid fa-bolt"></i> Flash
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 5: Attributes & Flags -->
|
||||
<div class="modal-filter-group">
|
||||
<label class="modal-filter-label"><i class="fa-solid fa-tags"></i> {{ t('filter.attributes') || 'Attributes' }}</label>
|
||||
<div class="filter-toggles">
|
||||
<button class="filter-toggle-pill {{ (oc == '1' || oc == 'true') ? 'active' : '' }}" id="toggle-oc-btn" data-toggle="oc" title="Original Content Only">
|
||||
<i class="fa-solid fa-certificate"></i> OC Only
|
||||
</button>
|
||||
<button class="filter-toggle-pill {{ (hasComments == '1' || hasComments == 'true') ? 'active' : '' }}" id="toggle-comments-btn" data-toggle="has_comments" title="Posts with Comments Only">
|
||||
<i class="fa-solid fa-comment-dots"></i> Discussed
|
||||
</button>
|
||||
<button id="nav-shuffle-btn" class="filter-toggle-pill shuffle-btn" title="Random Mode — shuffle items">
|
||||
<i class="fa-solid fa-shuffle"></i> Random Mode
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 6: Excluded Tags Blacklist -->
|
||||
<div class="modal-filter-group">
|
||||
<label class="modal-filter-label"><i class="fa-solid fa-ban"></i> {{ t('filter.excluded_tags') || 'Excluded Tags' }}</label>
|
||||
<div id="nav_excluded_tags_list" style="margin-bottom: 8px;"></div>
|
||||
<div class="nav-exclude">
|
||||
<input type="text" id="nav_exclude_tag_input" class="input" placeholder="{{ t('filter.tag_placeholder') || 'Type a tag to exclude...' }}" autocomplete="off" enterkeyhint="enter">
|
||||
<div id="nav_exclude_suggestions" class="tag-suggestions"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(session && enable_xd_score)
|
||||
<!-- Section 7: xD Score Filter -->
|
||||
<div class="modal-filter-group nav-xd-filter">
|
||||
<label class="modal-filter-label"><i class="fa-solid fa-gauge-high"></i> {{ t('filter.min_xd_score') }}</label>
|
||||
<div class="nav-xd-controls">
|
||||
<input type="range" id="nav_min_xd_score" min="0" max="100" step="1" value="{{ session.min_xd_score || 0 }}" class="xd-slider" style="display:none;" aria-hidden="true">
|
||||
<div id="nav_xd_slider_container" class="nav-xd-custom-slider"></div>
|
||||
<span id="nav_xd_val" class="xd-slider-val">{{ session.min_xd_score || 0 }}</span>
|
||||
<span id="nav_xd_tier_label" class="xd-score-badge" style="display:none; font-size: 0.75em;"></span>
|
||||
</div>
|
||||
<button id="nav_xd_save_btn" class="mode-btn" style="margin-top: 6px; width: 100%;">{{ t('filter.apply_filter') }}</button>
|
||||
<div id="nav_xd_status" style="font-size: 0.8em; color: #9f0; min-height: 1em; margin-top: 3px;"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer Actions -->
|
||||
<div class="filter-modal-footer">
|
||||
<button class="filter-reset-btn" id="filter-reset-btn" title="Reset All Filters to Default">
|
||||
<i class="fa-solid fa-rotate-left"></i> Reset All
|
||||
</button>
|
||||
<button class="filter-apply-btn" id="filter-modal-done-btn">
|
||||
<i class="fa-solid fa-check"></i> Done
|
||||
</button>
|
||||
<!-- mimes -->
|
||||
@if(show_mime_picker)
|
||||
<div class="nav-mime-filter nav-mime-menu" id="nav-mime-menu">
|
||||
@if(scroller_mime_cats && scroller_mime_cats.includes('video'))
|
||||
<label class="nav-mime-item"><input type="checkbox" value="video" id="mime-video">{{ t('filter.video') }}</label>
|
||||
@endif
|
||||
@if(scroller_mime_cats && scroller_mime_cats.includes('audio'))
|
||||
<label class="nav-mime-item"><input type="checkbox" value="audio" id="mime-audio">{{ t('filter.audio') }}</label>
|
||||
@endif
|
||||
@if(scroller_mime_cats && scroller_mime_cats.includes('image'))
|
||||
<label class="nav-mime-item"><input type="checkbox" value="image" id="mime-image">{{ t('filter.image') }}</label>
|
||||
@endif
|
||||
@if(enable_swf)
|
||||
<label class="nav-mime-item"><input type="checkbox" value="flash" id="mime-flash">{{ t('filter.flash') }}</label>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if(session && enable_xd_score)
|
||||
<!-- xD score filter -->
|
||||
<div class="nav-xd-filter">
|
||||
<label class="nav-xd-label">{{ t('filter.min_xd_score') }}</label>
|
||||
<div class="nav-xd-controls">
|
||||
<input type="range" id="nav_min_xd_score" min="0" max="100" step="1" value="{{ session.min_xd_score || 0 }}" class="xd-slider" style="display:none;" aria-hidden="true">
|
||||
<div id="nav_xd_slider_container" class="nav-xd-custom-slider"></div>
|
||||
<span id="nav_xd_val" class="xd-slider-val">{{ session.min_xd_score || 0 }}</span>
|
||||
<span id="nav_xd_tier_label" class="xd-score-badge" style="display:none; font-size: 0.75em;"></span>
|
||||
</div>
|
||||
<button id="nav_xd_save_btn" class="mode-btn" style="margin-top: 6px; width: 100%;">{{ t('filter.apply_filter') }}</button>
|
||||
<div id="nav_xd_status" style="font-size: 0.8em; color: #9f0; min-height: 1em; margin-top: 3px;"></div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<!-- Filter bar has been integrated into excluded-tags-modal.html -->
|
||||
@@ -379,9 +379,6 @@
|
||||
enable_nsfl: @if(enable_nsfl) true @else false @endif,
|
||||
user: @if(session) "{{ session.user }}" @else null @endif,
|
||||
display_name: @if(session && session.display_name) "{!! session.display_name !!}" @else null @endif,
|
||||
username_color: @if(session && session.username_color) "{!! session.username_color !!}" @else null @endif,
|
||||
id: @if(session && session.id) {{ session.id }} @else null @endif,
|
||||
user_id: @if(session && session.id) {{ session.id }} @else null @endif,
|
||||
is_admin: @if(session && session.admin) true @else false @endif,
|
||||
is_moderator: @if(session && session.is_moderator) true @else false @endif,
|
||||
enable_swf: @if(enable_swf) true @else false @endif,
|
||||
@@ -394,14 +391,9 @@
|
||||
embed_youtube_in_comments: @if(session && session.embed_youtube_in_comments !== false) true @else false @endif,
|
||||
avatar: @if(session && session.avatar) {{ session.avatar }} @else null @endif,
|
||||
avatar_file: @if(session && session.avatar_file) "{{ session.avatar_file }}" @else null @endif,
|
||||
banner_file: @if(session && session.banner_file) "{{ session.banner_file }}" @else null @endif,
|
||||
banner_position: @if(session && session.banner_position) "{{ session.banner_position }}" @else null @endif,
|
||||
banner_size: @if(session && session.banner_size) "{{ session.banner_size }}" @else null @endif,
|
||||
banner_repeat: @if(session && session.banner_repeat) "{{ session.banner_repeat }}" @else null @endif,
|
||||
receive_system_notifications: @if(session)@if(session.receive_system_notifications !== false) true @else false @endif@else true @endif,
|
||||
receive_user_notifications: @if(session)@if(session.receive_user_notifications !== false) true @else false @endif@else true @endif,
|
||||
do_not_disturb: @if(session && session.do_not_disturb) true @else false @endif,
|
||||
comment_banner_enabled: @if(comment_banner_enabled) true @else false @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,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{ lang || 'en' }}" theme="@if(typeof theme !== 'undefined'){{ theme }}@endif" res="@if(typeof fullscreen !== 'undefined'){{ fullscreen == 1 ? 'fullscreen' : '' }}@endif">
|
||||
|
||||
<head>
|
||||
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</title>@else<title>{{ domain }}</title>@endif
|
||||
@if(typeof page_meta !== 'undefined' && page_meta.title)<title>{{ domain }} - {{ page_meta.title }}</title>@elseif(typeof item !== 'undefined')<title>{{ domain }} - {{ item.id }}</title>@else<title>{{ domain }}</title>@endif
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0096ff">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
@@ -23,8 +23,6 @@
|
||||
}
|
||||
@endif
|
||||
:root {
|
||||
--comment-banner-display: @if(comment_banner_enabled) block @else none @endif;
|
||||
--comment-banner-max-height: @if(typeof comment_banner_max_height !== 'undefined'){{ comment_banner_max_height }}px@else 300px @endif;
|
||||
--favicon-url: url('@if(custom_favicon && custom_favicon.length > 0){{ custom_favicon }}@else/s/img/favicon.gif@endif');
|
||||
@if(session && session.font)
|
||||
--font: 'CustomUserFont', monospace !important;
|
||||
@@ -66,7 +64,7 @@
|
||||
@endif
|
||||
<link rel="stylesheet" href="/s/css/upload.css?v={{ ts }}">
|
||||
@endif
|
||||
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }}; window.f0ckCommentBannerEnabled = @if(comment_banner_enabled) true @else false @endif;</script>
|
||||
<script>window.f0ckThemes = {{ themes_json }}; window.f0ckDefaultTheme = "{{ default_theme }}"; window.f0ckDomain = "{{ domain }}"; window.f0ckGitHash = "{{ git_hash }}"; window.f0ckAllowedImages = {{ allowed_comment_images_json }}; window.f0ckEmbedYoutubeInComments = {{ embed_youtube_in_comments ? 'true' : 'false' }}; window.f0ckEnableYoutubeUpload = {{ enable_youtube_upload ? 'true' : 'false' }}; window.f0ckBrandImages = {{ custom_brand_images_json }}; window.f0ckMediaBase = "{{ paths_images }}"; window.f0ckShitpostMode = {{ shitpost_mode ? 'true' : 'false' }}; window.f0ckShitpostRequireRating = {{ shitpost_require_rating ? 'true' : 'false' }}; window.f0ckShitpostMinTags = {{ shitpost_min_tags || 0 }}; window.f0ckEnableItemTitle = {{ enable_item_title ? 'true' : 'false' }};</script>
|
||||
@if(!private_society || session)
|
||||
<script src="/s/js/marked.min.js" defer></script>
|
||||
<script src="/s/js/comments.js?v={{ ts }}" defer></script>
|
||||
@@ -74,18 +72,18 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
|
||||
@if(typeof item !== 'undefined')
|
||||
<link rel="canonical" href="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<link rel="canonical" href="https://{{ domain }}/{{ item.id }}" />
|
||||
<meta property="og:site_name" content="{{ domain }}" />
|
||||
<meta property="og:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="og:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="og:title" content="{{ item.id }}" />
|
||||
<meta property="og:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||
<meta property="og:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||
<meta name="description" content="{{ site_description }}" />
|
||||
<meta property="og:description" content="{{ site_description }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="twitter:card" content="summary" />
|
||||
<meta property="twitter:title" content="{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="twitter:title" content="{{ item.id }}" />
|
||||
<meta property="twitter:image" content="https://{{ domain }}{{ item.og_thumbnail }}" />
|
||||
<meta property="twitter:url" content="https://{{ domain }}/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" />
|
||||
<meta property="twitter:url" content="https://{{ domain }}/{{ item.id }}" />
|
||||
@else
|
||||
<meta property="og:site_name" content="{{ domain }}" />
|
||||
<meta property="og:title" content="@if(typeof page_meta !== 'undefined' && page_meta.title){{ page_meta.title }} - {{ domain }}@else{{ domain }}@endif" />
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
<div id="info-modal" class="modal-overlay" style="display: none;">
|
||||
<div class="modal-content infobox-modal-content">
|
||||
<!-- Header -->
|
||||
<div class="infobox-modal-header">
|
||||
<div class="infobox-header-title">
|
||||
<div class="infobox-header-text">
|
||||
<h3>{{ t('info_modal.title') || 'Post & File Details' }}</h3>
|
||||
<span class="infobox-sub-heading">ID: <code>{{ item.id }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="infobox-close-icon-btn" id="info-modal-close-icon" title="{{ t('common.close') || 'Close' }}">
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="infobox-modal-body">
|
||||
|
||||
<!-- Section 1: Post Details -->
|
||||
<div class="infobox-section">
|
||||
<div class="infobox-section-header">
|
||||
<i class="fa-solid fa-sliders"></i>
|
||||
<span>Post Configuration</span>
|
||||
</div>
|
||||
|
||||
<div class="infobox-cards-grid">
|
||||
@if(enable_item_title)
|
||||
<div class="infobox-card full-width">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-heading"></i> Title</label>
|
||||
@if(can_manage_item)
|
||||
<div class="info-title-edit-wrap">
|
||||
<input type="text" id="info-title-input" class="info-title-input" value="{!! item.title || '' !!}" placeholder="Add a title…" maxlength="500" data-item-id="{{ item.id }}" />
|
||||
<button type="button" id="info-title-save" class="info-title-save-btn" title="Save Title"><i class="fa-solid fa-check"></i></button>
|
||||
</div>
|
||||
<span id="info-title-status" class="info-title-status" style="display:none"></span>
|
||||
@else
|
||||
<div class="infobox-card-value">{!! item.title || '<span class="text-muted">No title set</span>' !!}</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-eye"></i> Visibility</label>
|
||||
<div class="infobox-card-value-row">
|
||||
<span id="info-visibility-label" class="visibility-badge vis-{{ item.visibility || 0 }}">
|
||||
@if(item.visibility === 2)
|
||||
<i class="fa-solid fa-lock" style="color: var(--color-danger, #ff4444);"></i> Private
|
||||
@elseif(item.visibility === 1)
|
||||
<i class="fa-solid fa-link" style="color: var(--color-warning, #ffbb33);"></i> Unlisted
|
||||
@else
|
||||
<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public
|
||||
@endif
|
||||
</span>
|
||||
@if(can_manage_item)
|
||||
<button type="button" id="info-visibility-edit-btn" class="infobox-action-btn" data-item-id="{{ item.id }}" data-visibility="{{ item.visibility || 0 }}">
|
||||
<i class="fa-solid fa-pen"></i> Edit
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-hourglass-half"></i> Expiration</label>
|
||||
<div class="infobox-expiry-content">
|
||||
<span id="info-expiry-label" class="expiry-status-label" title="{{ item.expires_at ? new Date(item.expires_at * 1000).toLocaleString() : '' }}">
|
||||
@if(item.expires_at)
|
||||
<i class="fa-solid fa-clock" style="color: #ffaa00;"></i> {{ item.expires_in || ('in ' + new Date(item.expires_at * 1000).toLocaleString()) }}
|
||||
@else
|
||||
<i class="fa-solid fa-clock-slash" style="color: var(--text-muted, #888);"></i> Permanent
|
||||
@endif
|
||||
</span>
|
||||
@if(can_manage_item)
|
||||
<div class="info-expiry-edit-controls">
|
||||
<select id="info-expiry-select" class="infobox-select" data-item-id="{{ item.id }}">
|
||||
<option value="permanent" {{ !item.expires_at ? 'selected' : '' }}>Permanent</option>
|
||||
<option value="30minutes">30 Minutes</option>
|
||||
<option value="1hour">1 Hour</option>
|
||||
<option value="24hours">24 Hours</option>
|
||||
<option value="1week">1 Week</option>
|
||||
<option value="1month">1 Month</option>
|
||||
</select>
|
||||
<button type="button" id="info-set-expiry-btn" class="infobox-action-btn" data-item-id="{{ item.id }}" title="Save Expiration">
|
||||
<i class="fa-solid fa-check"></i>
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Technical File Specifications -->
|
||||
<div class="infobox-section">
|
||||
<div class="infobox-section-header">
|
||||
<i class="fa-solid fa-file-code"></i>
|
||||
<span>Technical Specifications</span>
|
||||
</div>
|
||||
|
||||
<div class="infobox-cards-grid">
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-hard-drive"></i> File Size</label>
|
||||
<div class="infobox-card-value font-mono">{{ item.size }}</div>
|
||||
</div>
|
||||
|
||||
@if(item_has_dimensions)
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-ruler-combined"></i> Dimensions</label>
|
||||
<div class="infobox-card-value font-mono">{{ item.width }} × {{ item.height }} px</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-file-lines"></i> MIME Type</label>
|
||||
<div class="infobox-card-value"><code class="mime-pill">{{ item.mime }}</code></div>
|
||||
</div>
|
||||
|
||||
<div class="infobox-card">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-arrow-up-right-from-square"></i> Direct Link</label>
|
||||
<div class="infobox-card-value">
|
||||
<a href="{{ item.mime === 'video/youtube' ? 'https://www.youtube.com/watch?v=' + item.dest.replace('yt:', '') : item.dest }}" target="_blank" class="infobox-link-pill">
|
||||
<i class="fa-solid fa-up-right-from-square"></i> {{ t('info_modal.view_file') || 'Open File' }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(item.src && item.src.short)
|
||||
<div class="infobox-card full-width">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-link"></i> Source URL</label>
|
||||
<div class="infobox-card-value">
|
||||
<a href="{{ item.src.long }}" target="_blank" class="infobox-source-link">
|
||||
<i class="fa-solid fa-globe"></i> {{ item.src.short }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(item.checksum)
|
||||
<div class="infobox-card full-width">
|
||||
<div class="infobox-card-label-row">
|
||||
<label class="infobox-card-label"><i class="fa-solid fa-fingerprint"></i> SHA-256 Hash</label>
|
||||
<button type="button" class="copy-hash-btn" id="info-copy-hash-btn" data-hash="{{ item.checksum.split('_bypass_')[0] }}" title="Copy SHA-256 Hash">
|
||||
<i class="fa-solid fa-copy"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-hash-codeblock font-mono"><code>{{ item.checksum.split('_bypass_')[0] }}</code></div>
|
||||
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Reposts & Duplicates (if present) -->
|
||||
@if(item.show_repost_row)
|
||||
<div class="infobox-section">
|
||||
<div class="infobox-section-header">
|
||||
<i class="fa-solid fa-copy"></i>
|
||||
<span>Similar & Duplicate Uploads</span>
|
||||
</div>
|
||||
<div class="infobox-reposts-list">
|
||||
@each(item.reposts as rp)
|
||||
<a href="/{{ rp.slug || rp.id }}" class="repost-pill {{ rp.match_type === 'phash' ? 'phash' : 'exact' }}" tooltip="{{ rp.match_type === 'phash' ? 'Visually similar (perceptual hash)' : 'Exact duplicate (checksum)' }}" flow="up">
|
||||
<i class="fa-solid {{ rp.match_type === 'phash' ? 'fa-clone' : 'fa-copy' }}"></i>
|
||||
<span>{{ rp.match_type === 'phash' ? '~#' : '#' }}{{ rp.slug || rp.id }}</span>
|
||||
</a>
|
||||
@endeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="infobox-modal-footer">
|
||||
@if(can_manage_item)
|
||||
<button type="button" class="btn-infobox-action" id="info-rethumb-btn" data-item-id="{{ item.id }}">
|
||||
<i class="fa-solid fa-arrows-rotate"></i> Regenerate Thumbnail
|
||||
</button>
|
||||
@endif
|
||||
<button type="button" class="btn-infobox-close" id="info-modal-close">{{ t('common.close') || 'Close' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,42 +1,5 @@
|
||||
@if(layout == 'stream')
|
||||
<!-- Stream / Vertical Feed Layout -->
|
||||
@each(items as item)
|
||||
<div class="stream-card {{ item.is_pinned ? 'is-pinned' : '' }} {{ item.has_notification ? 'has-notif' : '' }}" data-id="{{ item.id }}">
|
||||
<div class="stream-card-header">
|
||||
<a href="/user/{{ item.username.toLowerCase() }}" class="stream-author">
|
||||
<span class="stream-username" @if(item.author_color)style="color: {{ item.author_color }}"@endif>{!! item.display_name || item.username !!}</span>
|
||||
</a>
|
||||
<div class="stream-meta">
|
||||
@if(item.is_pinned)
|
||||
<span class="badge-pin"><i class="fa-solid fa-thumbtack"></i> Pinned</span>
|
||||
@endif
|
||||
@if(item.is_oc)
|
||||
<span class="badge-oc">OC</span>
|
||||
@endif
|
||||
@if(enable_xd_score && item.xd_tier > 0)
|
||||
<span class="thumb-xd-indicator xd-tier-{{ item.xd_tier }}">xD: {{ item.xd_score }}</span>
|
||||
@endif
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-permalink"><i class="fa-solid fa-arrow-up-right-from-square"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-media-wrapper">
|
||||
<img src="/t/{{ item.id }}.webp" alt="Media thumbnail" class="stream-media-img" loading="lazy">
|
||||
<span class="stream-media-badge">{{ item.mime.split('/')[1].replace('youtube', 'YT').replace('x-shockwave-flash', 'FLASH').toUpperCase() }}</span>
|
||||
</a>
|
||||
|
||||
<div class="stream-card-footer">
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="stream-action-btn">
|
||||
<i class="fa-regular fa-comments"></i> View Post & Comments
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@endeach
|
||||
|
||||
@else
|
||||
<!-- Grid & Masonry Layout (Default) -->
|
||||
@each(items as item)
|
||||
<a href="{{ link.main }}{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="{{ item.is_pinned ? 'anim-boxshadow ' : '' }}thumb lazy-thumb {{ item.has_notification ? 'has-notif' : '' }} {{ item.is_pinned ? 'is-pinned' : '' }}" data-file="{{ item.dest }}" data-mime="{{ item.mime }}" data-user="{!! item.display_name || item.username !!}" data-ext="{{ item.mime.split('/')[1].replace('youtube', 'yt').replace('x-shockwave-flash', 'flash').replace('vnd.adobe.flash.movie', 'flash').replace('x-zip-compressed', 'zip').replace('x-rar-compressed', 'rar').replace('vnd.rar', 'rar').replace('x-7z-compressed', '7z').replace('x-tar', 'tar').replace('x-bzip2', 'bz2').replace('x-xz', 'xz').toUpperCase() }}" data-mode="{{ item.tag_id == nsfl_tag_id ? 'nsfl' : (item.tag_id == 2 ? 'nsfw' : (item.tag_id == 1 ? 'sfw' : 'null')) }}" data-bg="/t/{{ item.id }}.webp" data-size="{{ enable_dynamic_thumbs ? (item.thumb_size || 1) : 1 }}">
|
||||
<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>
|
||||
@@ -51,4 +14,3 @@
|
||||
<p></p>
|
||||
</a>
|
||||
@endeach
|
||||
@endif
|
||||
@@ -26,6 +26,9 @@
|
||||
</div>
|
||||
@endif
|
||||
<a href="/tags" title="{{ t('nav.tags') }}"><i class="fa-solid fa-tags"></i></a>
|
||||
@if(enable_global_chat)
|
||||
<a href="/chat" title="{{ t('nav.chat') }}"><i class="fa-solid fa-comments"></i></a>
|
||||
@endif
|
||||
@if(abyss_enabled)
|
||||
<a href="/abyss" title="{{ t('nav.abyss') }}"><i class="fa-solid fa-dice-d6"></i></a>
|
||||
@endif
|
||||
@@ -43,11 +46,11 @@
|
||||
<div class="nav-user-dropdown">
|
||||
<button class="nav-user-btn nav-avatar-btn" id="nav-user-toggle">
|
||||
@if(session.avatar_file)
|
||||
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
|
||||
<img class="nav-avatar-img" src="/a/{{ session.avatar_file }}" onerror="this.src='/a/default.png'">
|
||||
@elseif(session.avatar && session.avatar > 0)
|
||||
<img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" onerror="this.src='/a/default.png'" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
|
||||
<img class="nav-avatar-img" src="/t/{{ session.avatar }}.webp" onerror="this.src='/a/default.png'">
|
||||
@else
|
||||
<img class="nav-avatar-img" src="/a/default.png" onclick="event.stopPropagation(); if(typeof window.loadPageAjax==='function'){ window.loadPageAjax('/user/{!! session.user.toLowerCase() !!}', true); } else { window.location.href='/user/{!! session.user.toLowerCase() !!}'; }" title="{{ session.user }}">
|
||||
<img class="nav-avatar-img" src="/a/default.png">
|
||||
@endif
|
||||
<span class="nav-avatar-name" id="nav-display-name" @if(session.username_color)style="color: {{ session.username_color }}"@endif>{!! session.display_name || session.user !!}</span>
|
||||
<span class="nav-avatar-caret">▾</span>
|
||||
@@ -166,7 +169,7 @@
|
||||
@if(custom_brand_image)
|
||||
<img id="navbar-logo" src="{{ custom_brand_image }}" alt="{{ domain }}" style="max-height: 40px; vertical-align: middle; max-width: 180px; width: auto;">
|
||||
@else
|
||||
<span class="f0ck">{{ custom_navbar_brand_text || domain }}</span>
|
||||
<span class="f0ck">{{ custom_navbar_brand_text || domain.split('.')[0] }}</span>
|
||||
@endif
|
||||
</a>
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
@each(notifications as n)
|
||||
@if(n.type === 'approve')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.onerror=null;this.src='/mod/deleted/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none';};};"/>
|
||||
</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_approved').replace('{id}', n.item_id) }}</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.admin') }}</strong></div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||
<div class="notif-msg">{{ t('notifications.upload_pending').replace('{id}', n.item_id) }}</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@@ -33,46 +33,46 @@
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'deny')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
||||
</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.system') }}</strong></div>
|
||||
<div class="notif-msg">
|
||||
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_slug || n.item_id) }}</strong>
|
||||
<strong>{{ t('notifications.upload_denied').replace('{id}', n.item_id) }}</strong>
|
||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
||||
</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'item_deleted')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
||||
</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong>{{ t('notifications.moderation') }}</strong></div>
|
||||
<div class="notif-msg">
|
||||
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_slug || n.item_id) }}</strong>
|
||||
<strong>{{ t('notifications.upload_deleted').replace('{id}', n.item_id) }}</strong>
|
||||
<div style="font-size: 0.85em; color: #ffb8b8; margin-top: 4px;">Reason: {{ n.reason }}</div>
|
||||
</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'upload_comment')
|
||||
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ n.item_id }}#c{{ n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumbnail" onerror="this.style.display='none'">
|
||||
</div>
|
||||
<div class="notif-content">
|
||||
<div class="notif-info"><strong>{{ t('notifications.new_comments') }}</strong></div>
|
||||
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_slug || n.item_id) }}</div>
|
||||
<div class="notif-msg">{{ t('notifications.on_your_upload').replace('{id}', n.item_id) }}</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'upload_success')
|
||||
<a href="/{{ n.item_slug || n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ n.item_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
|
||||
</div>
|
||||
@@ -83,7 +83,7 @@
|
||||
</div>
|
||||
</a>
|
||||
@elseif(n.type === 'upload_error')
|
||||
<a href="{{ n.item_id ? '/' + (n.item_slug || n.item_id) : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
|
||||
<a href="{{ n.item_id ? '/' + n.item_id : '#' }}" class="notif-item {{ n.is_read ? '' : 'unread' }} {{ n.item_id ? 'notif-with-thumb' : '' }}" data-id="{{ n.id }}">
|
||||
@if(n.item_id)
|
||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.onerror=null;this.src='/mod/pending/t/{{ n.item_id }}.webp';this.onerror=function(){this.style.display='none'};" />
|
||||
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
@else
|
||||
<a href="/{{ n.item_slug || n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
<a href="/{{ n.item_id }}#c{{ n.comment_id || n.reference_id }}" class="notif-item {{ n.is_read ? '' : 'unread' }} notif-with-thumb" data-id="{{ n.id }}">
|
||||
@if(n.item_id)
|
||||
<div class="notif-thumb" data-mode="{{ n.item_mode || '' }}">
|
||||
<img src="/t/{{ n.item_id }}{{ ((active_mode===0&&(n.item_mode==='nsfw'||n.item_mode==='nsfl'))||(active_mode===1&&n.item_mode==='nsfl')||(active_mode===4&&(n.item_mode==='sfw'||n.item_mode==='nsfw'))) ? '_blur' : '' }}.webp" data-orig-src="/t/{{ n.item_id }}.webp" alt="thumb" onerror="this.style.display='none'" />
|
||||
@@ -125,9 +125,9 @@
|
||||
<div class="notif-content">
|
||||
<div class="notif-user"><strong @if(n.username_color) style="color: {{ n.username_color }}" @endif>{!! n.from_user !!}</strong></div>
|
||||
<div class="notif-msg">
|
||||
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_slug || n.item_id) }}
|
||||
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_slug || n.item_id) }}
|
||||
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_slug || n.item_id) }}
|
||||
@if(n.type === 'comment_reply') {{ t('notifications.replied').replace('{id}', n.item_id) }}
|
||||
@elseif(n.type === 'subscription') {{ t('notifications.subscribed').replace('{id}', n.item_id) }}
|
||||
@elseif(n.type === 'mention') {{ t('notifications.mentioned').replace('{id}', n.item_id) }}
|
||||
@endif
|
||||
</div>
|
||||
<div class="notif-time">{{ new Date(n.created_at).toLocaleString() }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@each(items as item)
|
||||
<div class="sub-card {{ item.is_pinned ? 'anim-boxshadow is-pinned' : '' }}" id="sub-{{ item.id }}">
|
||||
<a href="/{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}" class="sub-link">
|
||||
<a href="/{{ item.id }}" class="sub-link">
|
||||
<div class="thumb-indicators">
|
||||
@if(item.is_pinned)
|
||||
<i class="fa-solid fa-thumbtack pin-indicator anim"></i>
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<img src="{{ item.thumb }}" loading="lazy" />
|
||||
<div class="sub-info">
|
||||
<span class="sub-id">#{{ (enable_item_slugs && item.slug) ? item.slug : item.id }}</span>
|
||||
<span class="sub-id">#{{ item.id }}</span>
|
||||
<span class="sub-user">{{ t('subscriptions.by_user').replace('{user}', item.user) }}</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -79,41 +79,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if(enable_private_uploads !== false && allow_user_upload_visibility !== false)
|
||||
<div class="form-section global-visibility-section">
|
||||
<label>{{ t('upload.visibility') || 'Visibility' }}</label>
|
||||
<div class="visibility-options rating-options">
|
||||
<label class="rating-option">
|
||||
<input type="radio" name="visibility" value="0" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 0) ? 'checked' : '' }}>
|
||||
<span class="rating-label sfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-globe"></i> {{ t('common.public') || 'Public' }}</span>
|
||||
</label>
|
||||
<label class="rating-option">
|
||||
<input type="radio" name="visibility" value="1" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 1) ? 'checked' : '' }}>
|
||||
<span class="rating-label nsfw" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-link"></i> {{ t('common.unlisted') || 'Unlisted' }}</span>
|
||||
</label>
|
||||
<label class="rating-option">
|
||||
<input type="radio" name="visibility" value="2" {{ ((session.default_upload_visibility !== undefined && session.default_upload_visibility !== null ? session.default_upload_visibility : default_upload_visibility) === 2) ? 'checked' : '' }}>
|
||||
<span class="rating-label nsfl" style="display: flex; align-items: center; justify-content: center; gap: 6px;"><i class="fa-solid fa-lock"></i> {{ t('common.private') || 'Private' }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if(enable_expiring_uploads !== false)
|
||||
<div class="form-section global-expiry-section">
|
||||
<label><i class="fa-solid fa-clock"></i> Expiration / Self-Destruct</label>
|
||||
<select name="expiry" class="expiry-select" style="width: 100%; padding: 10px; background: rgba(0,0,0,0.3); color: #fff; border: 1px solid var(--nav-border-color, rgba(255,255,255,0.1)); border-radius: 6px; cursor: pointer; font-size: 0.95em;">
|
||||
<option value="permanent" selected>Permanent (Never expires)</option>
|
||||
<option value="30minutes">30 Minutes</option>
|
||||
<option value="1hour">1 Hour</option>
|
||||
<option value="24hours">24 Hours</option>
|
||||
<option value="1week">1 Week</option>
|
||||
<option value="1month">1 Month</option>
|
||||
</select>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
||||
|
||||
<div class="form-section global-tag-section">
|
||||
<label>
|
||||
|
||||
@@ -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)⚡ @elseif(user.is_moderator)🛡 @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)⚡ @elseif(user.is_moderator)🛡 @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) {
|
||||
|
||||
Reference in New Issue
Block a user