1 Commits
Author SHA1 Message Date
kibi 2a6e0f44f3 fdsa 2026-08-22 20:02:28 +02:00
174 changed files with 9903 additions and 60578 deletions
-3
View File
@@ -1,9 +1,6 @@
POSTGRES_USER=f0ckm
POSTGRES_DB=f0ckm
POSTGRES_PASSWORD=f0ckm
# Secret for hashing user IPs (HMAC-SHA256). Generate once: openssl rand -hex 32
# Kept here instead of config.json so a leaked config or DB dump can't be used to reverse IP hashes.
IP_HASH_SECRET=
# --- Nginx & Tor Profiles (Optional) ---
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
# COMPOSE_PROFILES=tor
+1 -25
View File
@@ -13,31 +13,7 @@ RUN apk add --no-cache \
file \
curl \
torsocks \
exiftool \
bash \
ca-certificates
# curl-impersonate: reproduces real browser TLS/HTTP2 handshakes for outgoing 4chan requests
# (picked up automatically by src/inc/chan_http.mjs via PATH). Pinned release + SHA-256 per arch;
# unsupported architectures skip it and the app falls back to plain curl with a random User-Agent.
ARG CURL_IMPERSONATE_VERSION=v2.2.3
ARG CURL_IMPERSONATE_SHA256_X86_64=288332a313e9edd884a1575c2627844fd9f3388fcefc62982b6b4eae12828357
ARG CURL_IMPERSONATE_SHA256_AARCH64=18000c51542fe63f0acc5cd3d89fb2217e35574f4c2911aec33e6774973a0809
ARG TARGETARCH
RUN set -eux; \
case "${TARGETARCH:-$(uname -m)}" in \
amd64|x86_64) arch=x86_64; sha="$CURL_IMPERSONATE_SHA256_X86_64" ;; \
arm64|aarch64) arch=aarch64; sha="$CURL_IMPERSONATE_SHA256_AARCH64" ;; \
*) echo "curl-impersonate: no build for ${TARGETARCH:-$(uname -m)}, skipping"; exit 0 ;; \
esac; \
curl -fsSL -o /tmp/curl-impersonate.tgz \
"https://github.com/lexiforest/curl-impersonate/releases/download/${CURL_IMPERSONATE_VERSION}/curl-impersonate-${CURL_IMPERSONATE_VERSION}.${arch}-linux-musl.tar.gz"; \
echo "${sha} /tmp/curl-impersonate.tgz" | sha256sum -c -; \
mkdir -p /opt/curl-impersonate; \
tar xzf /tmp/curl-impersonate.tgz -C /opt/curl-impersonate; \
rm /tmp/curl-impersonate.tgz; \
/opt/curl-impersonate/curl-impersonate --version | head -n 1
ENV PATH="/opt/curl-impersonate:${PATH}"
exiftool
WORKDIR /opt/f0ckm
COPY . .
+2 -36
View File
@@ -4,7 +4,7 @@ Happy to finally bring you f0ckm! The long awaited imageboard solution that you
It features extensive tagging, searching, filtering and a variety of options.
The software is mostly generic, it can be modified easily via `config.yaml` (or `config.json`) to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs.
The software is mostly generic, it can be modified easily via config.json to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs.
The software comes without any warranties or entitlements of any kind! The developer is not responsible for anything you do with the use of this software.
@@ -20,12 +20,7 @@ first things
fill with for example: f0ckm (prefilled)
Copy example configuration and customize:
`cp config_example.yaml config.yaml`
`npm run config:gen`
(Or `cp config_example.json config.json` if configuring directly in JSON)
`cp config_example.json config.json`
Edit to needs, for sql you can do this:
@@ -79,13 +74,6 @@ Create admin user in dev env
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
## Configuration Management
You can manage configuration in YAML (`config.yaml`) or JSON (`config.json`):
- `npm run config:gen`: Compiles `config.yaml` to `config.json`. Automatically runs before `npm run dev` and `npm start`.
- `npm run config:watch`: Watches `config.yaml` for edits and recompiles `config.json` automatically on save.
- `npm run config:to-yaml`: Converts existing `config.json` into `config.yaml`.
## NGINX
@@ -110,25 +98,3 @@ To advertise your `.onion` address to Tor Browser users visiting your clearnet s
"onion": "http://yourgeneratedaddress.onion"
}
```
## Chat uploads (temporary file hosting for mumh5)
Upload-only API keys for external chat clients. Files are public, never appear on the imageboard, and expire (default 30 days, see `chat_upload_*` in `config.yaml`).
Manage keys (the key is shown once, only its hash is stored):
`node scripts/chat-upload-key.mjs create <name> [max_mb]`
`node scripts/chat-upload-key.mjs list`
`node scripts/chat-upload-key.mjs revoke <id>`
Upload (raw body, returns JSON with `url` and `delete_token`):
```bash
curl -X POST https://your.host/api/chat/upload \
-H "X-API-Key: cu_..." -H "X-Filename: clip.webm" -H "X-Upload-Expiry: 7d" \
--data-binary @clip.webm
```
Delete: `curl -X DELETE https://your.host/api/chat/upload/<slug> -H "X-Delete-Token: ..."`
Files are served at `/cu/<slug>/<name>` with Range support. Only sniffed images, video and audio are served inline; anything else is forced to download.
+3 -77
View File
@@ -16,11 +16,7 @@
"example.org"
],
"invite_secret": "YOUR_SECRET_HERE",
"ip_hash_secret": "",
"ip_hash_legacy_secrets": [],
"hide_comments_from_public": false,
"guest_anonymize": false,
"anon_anonymize": false,
"timezone": "UTC",
"development": true
},
@@ -33,62 +29,16 @@
],
"enable_pdf": false,
"enable_nsfl": false,
"enable_comments": true,
"enable_private_uploads": true,
"enable_expiring_uploads": true,
"default_upload_visibility": 0,
"allow_user_upload_visibility": true,
"enable_item_slugs": true,
"enable_anonymous_access": true,
"anonymous_permissions": {
"upload": false,
"comment": true,
"comment_attachments": false,
"comment_vote": true,
"poll_vote": true,
"tag": true,
"tag_vote": true,
"favorite": true,
"rate_item": false,
"filter": true,
"exclude_tags": true,
"chan": false,
"anonymize_users": false,
"allowed_modes": [
"sfw",
"nsfw",
"untagged",
"all",
"nsfl"
],
"allowed_mimes": [
"image",
"video",
"audio",
"flash",
"pdf"
]
},
"onara": false,
"nsfl_tag_id": 4,
"allowedMimes": [
"audio",
"image",
"video",
"application/x-shockwave-flash",
"application/vnd.adobe.flash.movie",
"application/zip",
"application/x-zip-compressed",
"application/x-rar-compressed",
"application/vnd.rar",
"application/x-rar",
"application/rar",
"application/x-rar-archive",
"application/x-7z-compressed",
"application/x-tar",
"application/gzip",
"application/x-bzip2",
"application/x-xz"
"video"
],
"nsfp": [
2,
@@ -101,16 +51,9 @@
"allow_language_change": true,
"cache": false,
"eps": 155,
"default_thumb_size": "m",
"background": true,
"log_user_ips": false,
"hash_user_ips": true,
"anon_hw_fingerprint": true,
"retention_ip_days": 30,
"retention_activity_log_days": 90,
"retention_login_attempts_days": 30,
"retention_sessions_days": 365,
"retention_fingerprint_days": 365,
"description": "Example Description",
"themes": [
"amoled"
@@ -131,17 +74,9 @@
"dm_attachments": true,
"dm_unencrypted": false,
"dm_attachment_expiry_days": 90,
"chat_uploads": true,
"chat_upload_max_bytes": 104857600,
"chat_upload_expiry_days": 30,
"chat_upload_max_expiry_days": 30,
"chat_upload_rate_per_minute": 30,
"chat_link_previews": true,
"halls_enabled": true,
"userhalls_enabled": true,
"square_clicker_enabled": true,
"enable_userhall_image_upload": true,
"enable_oc": true,
"abyss_enabled": true,
"meme_creator": true,
"enable_cleanup": false,
@@ -180,11 +115,7 @@
"fileupload_comments_size": 104857600,
"fileupload_comments_max": 5,
"fileupload_comments_mode": "attachment",
"fileupload_comments_mimes": [
"image",
"video",
"audio"
],
"fileupload_comments_mimes": ["image", "video", "audio"],
"show_content_warning": true,
"default_comment_display_mode": 1,
"phrases": [
@@ -214,7 +145,6 @@
"private_society_gate_location": "Frankfurt",
"private_society_gate_template": "_gate_template",
"public_nsfw": false,
"public_untagged": false,
"paths": {
"images": "/b",
"thumbnails": "/t",
@@ -290,7 +220,6 @@
"audio/aac": "m4a",
"video/x-m4v": "mp4",
"video/x-matroska": "mkv",
"video/mpeg": "mp4",
"application/x-shockwave-flash": "swf",
"application/vnd.adobe.flash.movie": "swf",
"application/pdf": "pdf",
@@ -298,9 +227,6 @@
"application/x-zip-compressed": "zip",
"application/x-rar-compressed": "rar",
"application/vnd.rar": "rar",
"application/x-rar": "rar",
"application/rar": "rar",
"application/x-rar-archive": "rar",
"application/x-7z-compressed": "7z",
"application/x-tar": "tar",
"application/gzip": "gz",
@@ -323,4 +249,4 @@
"site_key": "YOUR_RECAPTCHA_V2_SITE_KEY",
"secret_key": "YOUR_RECAPTCHA_V2_SECRET_KEY"
}
}
}
-309
View File
@@ -1,309 +0,0 @@
main:
url:
full: https://example.com
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
adminmultiplier: 3.5
upload_limit: 300
ignored:
- example.net
- example.org
invite_secret: YOUR_SECRET_HERE
# IP hashing secret. Prefer the IP_HASH_SECRET environment variable (.env) so it isn't stored next to the data.
# Falls back to invite_secret when neither is set.
ip_hash_secret: ""
# Previous IP hash secrets, only used to match bans hashed before a rotation (invite_secret is added automatically)
ip_hash_legacy_secrets: []
hide_comments_from_public: false
guest_anonymize: false
anon_anonymize: false
timezone: UTC
development: true
allowedModes:
- sfw
- nsfw
- untagged
- all
- nsfl
enable_pdf: false
enable_nsfl: false
enable_comments: true
enable_private_uploads: true
enable_expiring_uploads: true
default_upload_visibility: 0
allow_user_upload_visibility: true
enable_item_slugs: true
enable_anonymous_access: true
anonymous_permissions:
upload: false
comment: true
comment_attachments: false
comment_vote: true
poll_vote: true
tag: true
tag_vote: true
favorite: true
rate_item: false
filter: true
exclude_tags: true
chan: false
anonymize_users: false
allowed_modes:
- sfw
- nsfw
- untagged
- all
- nsfl
allowed_mimes:
- image
- video
- audio
- flash
- pdf
onara: false
nsfl_tag_id: 4
allowedMimes:
- audio
- image
- video
- application/x-shockwave-flash
- application/vnd.adobe.flash.movie
- application/zip
- application/x-zip-compressed
- application/x-rar-compressed
- application/vnd.rar
- application/x-rar
- application/rar
- application/x-rar-archive
- application/x-7z-compressed
- application/x-tar
- application/gzip
- application/x-bzip2
- application/x-xz
nsfp:
- 2
- 3
- 4
websrv:
port: "1337"
language: en
allow_language_change: true
cache: false
eps: 155
default_thumb_size: m
background: true
log_user_ips: false
hash_user_ips: true
# Device fingerprint of anonymous users (hashed in the browser, used only to enforce bans).
# false = never computed or stored; already stored fingerprints are purged. Shown on /privacy.
anon_hw_fingerprint: true
# Data retention in days (0 = keep forever). Shown to users on /privacy.
retention_ip_days: 30 # stored IPs: user_ips rows deleted, IP columns elsewhere set to NULL
retention_activity_log_days: 90 # anonymous activity log (actions, IP, fingerprints)
retention_login_attempts_days: 30 # failed/successful login attempts (hashed IP + username)
retention_sessions_days: 365 # sessions unused this long are deleted
retention_fingerprint_days: 365 # device fingerprint of anonymous identities inactive this long
description: Example Description
themes:
- amoled
theme: amoled
default_font: ""
default_layout: legacy
custom_favicon: /s/img/favicon.gif
custom_brand_image: []
custom_navbar_brand_text: ""
show_koepfe: false
hide_sidebar_default: true
koepfe: []
enable_tor_hs: true
enable_global_chat: true
enable_danmaku: true
private_messages: true
dm_attachments: true
dm_unencrypted: false
dm_attachment_expiry_days: 90
# Temporary chat file hosting for external clients (mumh5), keys via scripts/chat-upload-key.mjs
chat_uploads: true
chat_upload_max_bytes: 104857600
chat_upload_expiry_days: 30
chat_upload_max_expiry_days: 30
chat_upload_rate_per_minute: 30
# Link previews for chat clients, fetched by this server (needs a chat upload key)
chat_link_previews: true
# Optional: allowed types for chat uploads, same format as allowedMimes (defaults to allowedMimes)
# chat_upload_mimes:
# - image
# - video
# - audio
halls_enabled: true
userhalls_enabled: true
# Square Clicker: audio/video items become playable rhythm-game maps (hotkey o); false turns it off
square_clicker_enabled: true
enable_userhall_image_upload: true
enable_oc: true
abyss_enabled: true
meme_creator: true
enable_cleanup: false
enable_data_export: true
inactivity_ban_days: 60
enable_user_api_keys: true
enable_user_invites: true
user_invite_slots: 2
invite_criteria:
uploads: 10
age_days: 10
comments: 10
tags: 10
cleanup_timeframe_days: 30
web_url_upload: true
enable_youtube_upload: true
web_meta_extraction: true
bypass_duplicate_check: true
shitpost_mode: false
shitpost_require_rating: false
shitpost_min_tags: 0
protect_files: false
enable_dynamic_thumbs: false
allowed_comment_images:
- i.imgur.com
- tenor.com
- giphy.com
show_mime_picker: true
embed_youtube_in_comments: true
allow_fileupload_comments: true
allow_comment_deletion: false
enable_comment_polls: false
fileupload_comments_multifile: true
fileupload_comments_size: 104857600
fileupload_comments_max: 5
fileupload_comments_mode: attachment
fileupload_comments_mimes:
- image
- video
- audio
show_content_warning: true
default_comment_display_mode: 1
phrases:
- Hello World
ban_video: ""
enable_xd_score: false
enable_autoplay: false
enable_swiping: true
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
archive_thumb: /s/img/archive.webp
enable_item_title: true
open_registration: true
open_registration_web_toggle: false
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
public_untagged: false
paths:
images: /b
thumbnails: /t
coverarts: /ca
emojis: /emojis
memes: /memes
clients:
- type: tg
enabled: false
token: ""
pollrate: 1001
- type: matrix
enabled: false
baseUrl: https://matrix.org
token: ""
userId: "@user:matrix.org"
channels: []
upload_channel_id: ""
notification_channel_id: ""
- type: irc
enabled: false
network: example
host: irc.example.net
port: 6697
ssl: true
selfSigned: true
sasl: false
nickname: bot
username: bot
password: ""
realname: bot
channels:
- "#example"
sql:
host: localhost
port: 5432
user: f0ckm
password: f0ckm
database: f0ckm
multipleStatements: true
max: 50
admins:
- {}
mimes:
image/png: png
video/webm: webm
image/gif: gif
image/jpg: jpg
image/jpeg: jpeg
image/webp: webp
video/mp4: mp4
video/quicktime: mp4
audio/mpeg: mpg
audio/mp3: mp3
audio/ogg: ogg
audio/opus: opus
audio/flac: flac
audio/x-flac: flac
audio/mp4: m4a
audio/x-m4a: m4a
audio/aac: m4a
video/x-m4v: mp4
video/x-matroska: mkv
video/mpeg: mp4
application/x-shockwave-flash: swf
application/vnd.adobe.flash.movie: swf
application/pdf: pdf
application/zip: zip
application/x-zip-compressed: zip
application/x-rar-compressed: rar
application/vnd.rar: rar
application/x-rar: rar
application/rar: rar
application/x-rar-archive: rar
application/x-7z-compressed: 7z
application/x-tar: tar
application/gzip: gz
application/x-bzip2: bz2
application/x-xz: xz
apis: {}
smtp:
enabled: false
host: smtp.example.com
port: 465
secure: true
user: smtp_user
password: smtp_password
from: admin@example.com
mail_reset_password: false
recaptcha:
enabled: false
site_key: YOUR_RECAPTCHA_V2_SITE_KEY
secret_key: YOUR_RECAPTCHA_V2_SECRET_KEY
-4
View File
@@ -19,7 +19,6 @@ services:
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
- ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z
- ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z
- ./f0ckm-data/cu/:/opt/f0ckm/public/cu/:Z
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
@@ -35,7 +34,6 @@ services:
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
- ./f0ckm-data/s/img/navbar/:/opt/f0ckm/public/s/img/navbar/:Z
environment:
- GIT_HASH=${f0ckm_TAG:-unknown}
@@ -49,8 +47,6 @@ services:
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
- DB_NAME=${POSTGRES_DB:-f0ckm}
- NODE_ENV=production
# Dedicated secret for IP hashing (HMAC). Set in .env, keep it out of config.json and DB backups.
- IP_HASH_SECRET=${IP_HASH_SECRET:-}
ports:
- "1337:1337"
restart: unless-stopped
-34
View File
@@ -1,34 +0,0 @@
-- Migration: Add Album Support
-- Allows posts to contain multiple pictures displayed as an album gallery
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS is_album boolean DEFAULT false;
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS album_count integer DEFAULT 0;
CREATE TABLE IF NOT EXISTS public.album_items (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES public.items(id) ON DELETE CASCADE,
dest CHARACTER VARYING(60) NOT NULL,
mime CHARACTER VARYING(100) NOT NULL,
size INTEGER NOT NULL,
checksum CHARACTER VARYING(255) NOT NULL,
phash TEXT,
width INTEGER,
height INTEGER,
order_index INTEGER NOT NULL DEFAULT 0,
slug CHARACTER VARYING(60) DEFAULT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_album_items_item_id ON public.album_items(item_id, order_index ASC);
CREATE INDEX IF NOT EXISTS idx_album_items_slug ON public.album_items(slug);
CREATE TABLE IF NOT EXISTS public.album_items_tags_assign (
album_item_id INTEGER NOT NULL REFERENCES public.album_items(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES public.tags(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES public."user"(id) ON DELETE SET DEFAULT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
PRIMARY KEY (album_item_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_tag_id ON public.album_items_tags_assign(tag_id);
CREATE INDEX IF NOT EXISTS idx_album_items_tags_assign_album_item_id ON public.album_items_tags_assign(album_item_id);
-48
View File
@@ -1,48 +0,0 @@
-- Migration: Add anonymous activity log and multi-layer anonymous banning tables
CREATE TABLE IF NOT EXISTS anon_activity_log (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
fingerprint VARCHAR(128),
action VARCHAR(64) NOT NULL,
target_id INT,
ip VARCHAR(128) NOT NULL,
user_agent TEXT,
details JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_anon_act_user_id ON anon_activity_log (user_id);
CREATE INDEX IF NOT EXISTS idx_anon_act_action ON anon_activity_log (action);
CREATE INDEX IF NOT EXISTS idx_anon_act_ip ON anon_activity_log (ip);
CREATE INDEX IF NOT EXISTS idx_anon_act_created ON anon_activity_log (created_at);
CREATE TABLE IF NOT EXISTS banned_ips (
id SERIAL PRIMARY KEY,
ip VARCHAR(128) NOT NULL UNIQUE,
ip_hash VARCHAR(128),
reason TEXT DEFAULT 'Banned by administrator',
banned_by INT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_banned_ips_ip ON banned_ips (ip);
CREATE INDEX IF NOT EXISTS idx_banned_ips_hash ON banned_ips (ip_hash);
CREATE TABLE IF NOT EXISTS banned_fingerprints (
id SERIAL PRIMARY KEY,
fingerprint VARCHAR(128) NOT NULL UNIQUE,
pubkey TEXT,
reason TEXT DEFAULT 'Banned by administrator',
banned_by INT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_banned_fp_fp ON banned_fingerprints (fingerprint);
ALTER TABLE comments ADD COLUMN IF NOT EXISTS ip VARCHAR(128);
ALTER TABLE items ADD COLUMN IF NOT EXISTS uploader_ip VARCHAR(128);
ALTER TABLE anon_identities ADD COLUMN IF NOT EXISTS created_ip VARCHAR(128);
ALTER TABLE anon_identities ADD COLUMN IF NOT EXISTS last_ip VARCHAR(128);
-12
View File
@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS public.anon_identities (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
pubkey TEXT NOT NULL UNIQUE,
fingerprint TEXT NOT NULL UNIQUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_seen TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_anon_identities_pubkey ON public.anon_identities(pubkey);
CREATE INDEX IF NOT EXISTS idx_anon_identities_fingerprint ON public.anon_identities(fingerprint);
CREATE INDEX IF NOT EXISTS idx_anon_identities_user_id ON public.anon_identities(user_id);
-25
View File
@@ -1,25 +0,0 @@
-- Migration: temporary chat file hosting for external clients (mumh5)
CREATE TABLE IF NOT EXISTS public.upload_api_keys (
id serial PRIMARY KEY,
name text NOT NULL,
key_hash text NOT NULL UNIQUE,
max_bytes bigint DEFAULT NULL,
revoked boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
last_used_at timestamp with time zone
);
CREATE TABLE IF NOT EXISTS public.chat_uploads (
id bigserial PRIMARY KEY,
slug text NOT NULL UNIQUE,
key_id integer REFERENCES public.upload_api_keys(id) ON DELETE SET NULL,
original_name text DEFAULT ''::text NOT NULL,
mime text NOT NULL,
mime_hint text DEFAULT ''::text NOT NULL,
size_bytes bigint DEFAULT 0 NOT NULL,
delete_token_hash text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_uploads_expires_at ON public.chat_uploads(expires_at);
-13
View File
@@ -1,13 +0,0 @@
-- Migration: deleted_user ghost account
-- Deleted users' comments, tags, halls, reports, etc. are reassigned to this system account
-- (see /api/v2/admin/users/delete, which also creates it on first use).
-- It can never log in: password '!' matches no hash, it is not activated and it is banned.
INSERT INTO public."user" (login, "user", password, admin, is_moderator, activated, banned, ban_reason, created_at)
VALUES ('deleted_user', 'deleted_user', '!', false, false, false, true, 'System account', now())
ON CONFLICT (login) DO NOTHING;
INSERT INTO public.user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, display_name)
SELECT id, 0, 'amoled', 0, NULL, 'default.png', 'deleted user'
FROM public."user" WHERE login = 'deleted_user'
ON CONFLICT (user_id) DO NOTHING;
@@ -1,26 +0,0 @@
-- Migration: Add Hardware Fingerprint Banning
-- Adds banned_hardware_fingerprints table and links hw_fingerprint to anon_identities and anon_activity_log.
CREATE TABLE IF NOT EXISTS banned_hardware_fingerprints (
id SERIAL PRIMARY KEY,
hw_fingerprint VARCHAR(128) UNIQUE NOT NULL,
banned_by INT REFERENCES "user"(id) ON DELETE SET NULL,
reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_banned_hw_fp ON banned_hardware_fingerprints(hw_fingerprint);
CREATE INDEX IF NOT EXISTS idx_banned_hw_expires ON banned_hardware_fingerprints(expires_at);
-- Add hardware fingerprint column to anon_identities
ALTER TABLE anon_identities
ADD COLUMN IF NOT EXISTS hw_fingerprint VARCHAR(128);
CREATE INDEX IF NOT EXISTS idx_anon_identities_hw ON anon_identities(hw_fingerprint);
-- Add hardware fingerprint column to anon_activity_log
ALTER TABLE anon_activity_log
ADD COLUMN IF NOT EXISTS hw_fingerprint VARCHAR(128);
CREATE INDEX IF NOT EXISTS idx_anon_activity_hw ON anon_activity_log(hw_fingerprint);
-19
View File
@@ -1,19 +0,0 @@
-- Migration: invite_requests
-- Tracks anonymous users requesting invite tokens from admins
CREATE TABLE IF NOT EXISTS public.invite_requests (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES public."user"(id) ON DELETE SET NULL,
fingerprint VARCHAR(128) NOT NULL,
ip_hash VARCHAR(128),
reason TEXT DEFAULT '',
status VARCHAR(16) DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'denied', 'revoked')),
token_id INTEGER REFERENCES public.invite_tokens(id) ON DELETE SET NULL,
reviewed_by INTEGER REFERENCES public."user"(id) ON DELETE SET NULL,
reviewed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_invite_requests_fingerprint ON public.invite_requests(fingerprint);
CREATE INDEX IF NOT EXISTS idx_invite_requests_status ON public.invite_requests(status);
CREATE INDEX IF NOT EXISTS idx_invite_requests_pending ON public.invite_requests(created_at) WHERE (status = 'pending');
-31
View File
@@ -1,31 +0,0 @@
-- passkey_credentials: stores WebAuthn credential records for all users
-- (both registered accounts and anonymous shadow users)
CREATE TABLE IF NOT EXISTS public.passkey_credentials (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
credential_id TEXT NOT NULL UNIQUE, -- base64url-encoded credentialId
public_key_spki TEXT NOT NULL, -- base64-encoded DER SPKI (ES-256 / P-256)
sign_count BIGINT NOT NULL DEFAULT 0, -- replay-attack counter
aaguid TEXT, -- authenticator AAGUID (informational)
name TEXT, -- user-assigned label ("Bitwarden", "iPhone")
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_used TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_passkey_credential_id ON public.passkey_credentials(credential_id);
CREATE INDEX IF NOT EXISTS idx_passkey_user_id ON public.passkey_credentials(user_id);
-- Extend anon_identities to support passkey-based identities.
-- New passkey rows use credential_id; legacy SSH rows retain pubkey/fingerprint.
-- pubkey and fingerprint are made nullable to allow passkey-only rows.
ALTER TABLE public.anon_identities
ADD COLUMN IF NOT EXISTS credential_id TEXT UNIQUE;
ALTER TABLE public.anon_identities
ALTER COLUMN pubkey DROP NOT NULL;
ALTER TABLE public.anon_identities
ALTER COLUMN fingerprint DROP NOT NULL;
CREATE INDEX IF NOT EXISTS idx_anon_identities_credential_id
ON public.anon_identities(credential_id);
-1
View File
@@ -1 +0,0 @@
ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS categories text[] DEFAULT '{}';
-1
View File
@@ -1 +0,0 @@
ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS reporter_ip text;
@@ -1,6 +0,0 @@
-- Migration: remember which passkey opened a session
-- Set on passkey sign-in (registered and anonymous). The passkey a session is using can't be deleted
-- from that session, and the settings page marks it as "This session". NULL = password login or a
-- session from before this column existed.
ALTER TABLE public.user_sessions ADD COLUMN IF NOT EXISTS passkey_credential_id TEXT;
-34
View File
@@ -1,34 +0,0 @@
-- Square Clicker: user-made beatmaps for audio and video items and their scores.
-- notes: JSON array of { t: ms from song start, x: 0..1, y: 0..1 (viewport fractions), d: hold ms (0 = tap),
-- p: optional slider path [[dt ms, x, y], ...] recorded while the hold was dragged }
CREATE TABLE IF NOT EXISTS public.sqc_maps (
id serial PRIMARY KEY,
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
album_item_id integer REFERENCES album_items(id) ON DELETE CASCADE,
user_id integer REFERENCES "user"(id) ON DELETE SET NULL,
title text NOT NULL DEFAULT 'Untitled',
notes jsonb NOT NULL DEFAULT '[]'::jsonb,
note_count integer NOT NULL DEFAULT 0,
duration_ms integer NOT NULL DEFAULT 0,
plays integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT 0
);
-- album_item_id: set for maps of one entry of an album (each entry is its own track)
ALTER TABLE public.sqc_maps ADD COLUMN IF NOT EXISTS album_item_id integer REFERENCES album_items(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_sqc_maps_item ON public.sqc_maps(item_id, created_at DESC);
CREATE TABLE IF NOT EXISTS public.sqc_scores (
id serial PRIMARY KEY,
map_id integer NOT NULL REFERENCES sqc_maps(id) ON DELETE CASCADE,
user_id integer REFERENCES "user"(id) ON DELETE CASCADE,
score integer NOT NULL DEFAULT 0,
accuracy real NOT NULL DEFAULT 0,
max_combo integer NOT NULL DEFAULT 0,
hits jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at integer NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_sqc_scores_map ON public.sqc_scores(map_id, score DESC);
@@ -1,25 +0,0 @@
-- User Interest & Behavior Recommendation Affinity Tables
CREATE TABLE IF NOT EXISTS public.user_tag_affinity (
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
score real DEFAULT 0.0,
interaction_count integer DEFAULT 1,
last_interacted timestamp with time zone DEFAULT now(),
PRIMARY KEY (user_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_user_tag_affinity_user_score
ON public.user_tag_affinity(user_id, score DESC);
CREATE TABLE IF NOT EXISTS public.user_creator_affinity (
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
creator_username text NOT NULL,
score real DEFAULT 0.0,
interaction_count integer DEFAULT 1,
last_interacted timestamp with time zone DEFAULT now(),
PRIMARY KEY (user_id, creator_username)
);
CREATE INDEX IF NOT EXISTS idx_user_creator_affinity_user_score
ON public.user_creator_affinity(user_id, score DESC);
+3515 -3744
View File
File diff suppressed because it is too large Load Diff
+1 -22
View File
@@ -17,8 +17,7 @@
"jszip": "3.10.1",
"marked": "18.0.2",
"matrix-js-sdk": "^40.3.0-rc.0",
"postgres": "^3.3.4",
"yaml": "^2.9.1"
"postgres": "^3.3.4"
}
},
"node_modules/@babel/runtime": {
@@ -449,21 +448,6 @@
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
},
"dependencies": {
@@ -773,11 +757,6 @@
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="
},
"yaml": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="
}
}
}
+2 -9
View File
@@ -5,14 +5,8 @@
"main": "index.mjs",
"type": "module",
"scripts": {
"prestart": "node scripts/generate-config.mjs",
"start": "node --trace-uncaught src/index.mjs",
"predev": "node scripts/generate-config.mjs",
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch --watch-path src src/index.mjs",
"devv2": "STORAGE_DIR=/home/kibi/Projects/f0ckmv2/f0ckm/f0ckm-data DB_HOST=localhost DB_PORT=5455 node --trace-uncaught --watch --watch-path src src/index.mjs",
"config:gen": "node scripts/generate-config.mjs",
"config:watch": "node scripts/generate-config.mjs --watch",
"config:to-yaml": "node scripts/generate-config.mjs --to-yaml",
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch src/index.mjs",
"trigger": "node debug/trigger.mjs",
"autotagger": "node debug/autotagger.mjs",
"thumbnailer": "node debug/thumbnailer.mjs",
@@ -36,7 +30,6 @@
"jszip": "3.10.1",
"marked": "18.0.2",
"matrix-js-sdk": "^40.3.0-rc.0",
"postgres": "^3.3.4",
"yaml": "^2.9.1"
"postgres": "^3.3.4"
}
}
+1007 -6708
View File
File diff suppressed because it is too large Load Diff
+10 -390
View File
@@ -5,7 +5,7 @@
padding: 0;
animation: uploadReveal 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
opacity: 0;
margin: 0;
margin: 0 auto;
}
@@ -25,29 +25,17 @@
text-align: center;
}
/* Title line: title left, upload limit right (wraps under it on narrow screens) */
.upload-title {
display: flex;
align-items: baseline;
justify-content: space-between;
flex-wrap: wrap;
gap: 4px 14px;
font-size: x-large;
}
/* Upload Limit Info (inside the title line; keeps its own small, normal-case text) */
/* Upload Limit Info */
.upload-limit-info {
text-align: center;
font-size: 0.9rem;
font-weight: 400;
letter-spacing: normal;
text-transform: none;
opacity: 0.7;
}
.upload-title .upload-limit-info {
font-size: 0.8rem;
}
.upload-limit-info i {
margin-right: 0.3rem;
}
@@ -381,25 +369,6 @@
display: none !important;
}
.upload-form.shitpost-mode-active.album-mode-active .global-rating-section,
.upload-form.shitpost-mode-active.album-mode-active .global-visibility-section,
.upload-form.shitpost-mode-active.album-mode-active .global-expiry-section,
.upload-form.shitpost-mode-active.album-mode-active .global-comment-section,
.upload-form.shitpost-mode-active.album-mode-active .global-tag-section,
.upload-form.shitpost-mode-active.album-mode-active .global-oc-section,
.upload-form.shitpost-mode-active.album-mode-active .global-title-section {
display: block !important;
}
.upload-form.album-mode-active .drop-zone {
border-color: rgba(var(--accent-rgb), 0.45);
background: rgba(var(--accent-rgb), 0.03);
}
.upload-form.album-mode-active .drop-album-hint {
font-weight: 600;
}
/* Per-item Rating Switch */
.item-rating-container {
display: flex;
@@ -435,21 +404,21 @@
}
.item-rating-option input:checked + .item-rating-label.sfw {
background: var(--badge-sfw, #02500b);
background: var(--badge-sfw);
color: #fff;
border-color: var(--badge-sfw, #02500b);
border-color: var(--badge-sfw);
}
.item-rating-option input:checked + .item-rating-label.nsfw {
background: var(--badge-nsfw, #E10DC3);
background: var(--badge-nsfw);
color: #fff;
border-color: var(--badge-nsfw, #E10DC3);
border-color: var(--badge-nsfw);
}
.item-rating-option input:checked + .item-rating-label.nsfl {
background: var(--badge-nsfl, #660000);
background: var(--badge-nsfl);
color: #fff;
border-color: var(--badge-nsfl, #660000);
border-color: var(--badge-nsfl);
}
/* Visibility Container - Only checked option is colored, unchecked options are gray */
@@ -652,6 +621,7 @@
.rating-label {
display: block;
padding: 0.1rem 2rem;
border-radius: 0;
border: 2px solid transparent;
transition: all 0.2s;
@@ -990,51 +960,12 @@
font-family: monospace;
}
.global-redirect-section {
margin: 4px 0 2px 0;
}
.global-redirect-section .upload-checkbox-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.88rem;
color: rgba(255, 255, 255, 0.85);
user-select: none;
transition: color 0.15s ease;
}
.global-redirect-section .upload-checkbox-label:hover {
color: #fff;
}
.global-redirect-section input[type="checkbox"] {
accent-color: var(--accent);
cursor: pointer;
width: 15px;
height: 15px;
margin: 0;
}
.upload-status {
text-align: center;
padding: 1rem;
font-weight: 600;
}
/* Groups button + progress + status (pinned in the upload dropdown, see f0ckm.css); same spacing as the form */
.upload-footer {
display: flex;
flex-direction: column;
gap: 5px;
}
/* Only takes space when there is a message; the progress bar is display:none until an upload runs */
.upload-status:empty {
display: none;
}
.upload-status.error {
color: #ff6b6b;
}
@@ -1775,314 +1706,3 @@
}
@keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
/* ==========================================================================
ALBUM UPLOAD STYLES
========================================================================== */
.album-choice-container {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.1));
border-radius: 8px;
padding: 10px 14px;
margin-top: 12px;
gap: 10px;
flex-wrap: wrap;
}
.album-choice-title {
font-size: 0.9rem;
font-weight: 600;
color: #eee;
display: flex;
align-items: center;
gap: 7px;
}
.album-choice-title i {
color: var(--accent);
}
.album-choice-options {
display: flex;
gap: 8px;
}
.album-choice-btn {
padding: 6px 14px;
border-radius: 6px;
font-size: 0.85rem;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #ccc;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
transition: all 0.2s ease;
}
.album-choice-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
}
.album-choice-btn.active {
background: var(--accent);
color: #111;
font-weight: 700;
border-color: var(--accent);
box-shadow: 0 0 10px rgba(var(--accent-rgb, 31, 178, 176), 0.4);
}
.album-staging-container {
width: 100%;
margin-top: 14px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 14px;
}
.album-staging-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.album-staging-title {
font-size: 0.92rem;
font-weight: 600;
color: #eee;
display: flex;
align-items: center;
gap: 7px;
}
.album-staging-title i {
color: var(--accent);
}
.btn-add-album-pics {
padding: 5px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.16);
color: #eee;
font-size: 0.8rem;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.2s ease;
}
.btn-add-album-pics:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(var(--accent-rgb, 31, 178, 176), 0.1);
}
.album-staging-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(115px, 1fr));
gap: 10px;
}
.album-stage-card {
position: relative;
background: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 8px;
overflow: hidden;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.album-stage-card:hover {
border-color: rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.album-stage-card.is-cover {
border-color: var(--accent);
box-shadow: 0 0 10px rgba(var(--accent-rgb, 31, 178, 176), 0.35);
}
.album-stage-card img,
.album-stage-card video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.album-stage-audio-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
text-align: center;
color: var(--accent);
}
.album-stage-audio-preview i {
font-size: 1.8rem;
}
.album-stage-audio-name {
font-size: 0.65rem;
color: #ccc;
max-width: 95px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.album-stage-mime-badge {
position: absolute;
top: 4px;
right: 4px;
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 0.65rem;
padding: 2px 5px;
border-radius: 4px;
z-index: 5;
pointer-events: none;
}
.album-stage-badge {
position: absolute;
top: 4px;
left: 4px;
background: rgba(0, 0, 0, 0.75);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
color: #fff;
font-size: 0.7rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
z-index: 5;
border: 1px solid rgba(255, 255, 255, 0.2);
pointer-events: none;
}
.album-stage-card.is-cover .album-stage-badge {
background: var(--accent);
color: #111;
border-color: var(--accent);
font-weight: 800;
}
.album-stage-actions {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85) 60%);
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 10px 4px 4px 4px;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 6;
}
.album-stage-card:hover .album-stage-actions {
opacity: 1;
}
.album-action-btn {
width: 25px;
height: 25px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.16);
border: none;
color: #fff;
font-size: 0.72rem;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
padding: 0;
}
.album-action-btn:hover {
background: var(--accent);
color: #111;
transform: scale(1.1);
}
.album-action-btn.btn-album-remove:hover {
background: #e74c3c;
color: #fff;
}
.album-stage-card.album-stage-add-more {
border-style: dashed;
border-color: rgba(255, 255, 255, 0.2);
cursor: pointer;
flex-direction: column;
gap: 4px;
color: rgba(255, 255, 255, 0.6);
}
.album-stage-card.album-stage-add-more:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(var(--accent-rgb, 31, 178, 176), 0.08);
}
.album-add-icon {
font-size: 1.4rem;
}
.album-add-text {
font-size: 0.75rem;
font-weight: 600;
}
.album-add-sub {
font-size: 0.65rem;
opacity: 0.7;
}
.album-stage-tags-wrap {
width: 100%;
margin-top: 4px;
}
.album-stage-tags-input {
width: 100%;
background: rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
padding: 3px 6px;
font-size: 0.68rem;
color: #fff;
outline: none;
box-sizing: border-box;
transition: border-color 0.2s ease, background 0.2s ease;
}
.album-stage-tags-input:focus {
border-color: var(--accent);
background: rgba(0, 0, 0, 0.65);
}
+29 -339
View File
@@ -6,7 +6,7 @@
background-size: contain;
background-repeat: no-repeat;
background-position: center center;
touch-action: pan-y;
touch-action: none; /* Prevent pull-to-refresh and scroll while interacting */
z-index: 0;
}
@@ -14,10 +14,15 @@
display: block;
}
.v0ck:fullscreen,
.v0ck.v0ck_fullscreen,
#main:fullscreen .v0ck.v0ck_fullscreen,
#onara-modal:fullscreen .v0ck.v0ck_fullscreen {
.v0ck.v0ck_fullscreen {
max-width: none;
max-height: none;
width: 100%;
height: 100%;
background-color: black;
}
#main:fullscreen .v0ck.v0ck_fullscreen {
position: fixed;
top: 0;
left: 0;
@@ -34,10 +39,7 @@
justify-content: center;
}
.v0ck:fullscreen video,
.v0ck.v0ck_fullscreen video,
#main:fullscreen .v0ck.v0ck_fullscreen video,
#onara-modal:fullscreen .v0ck.v0ck_fullscreen video {
#main:fullscreen .v0ck.v0ck_fullscreen video {
width: 100%;
height: 100%;
object-fit: contain;
@@ -45,25 +47,10 @@
}
/* Audio in fullscreen: hide the invisible audio element, show cover art via background */
.v0ck:fullscreen audio,
.v0ck.v0ck_fullscreen audio,
#main:fullscreen .v0ck.v0ck_fullscreen audio,
#onara-modal:fullscreen .v0ck.v0ck_fullscreen audio {
#main:fullscreen .v0ck.v0ck_fullscreen audio {
display: none;
}
.v0ck canvas.audio-visualizer {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
height: 100%;
width: 100%;
pointer-events: none;
z-index: 1 !important;
}
.v0ck_overlay {
pointer-events: none;
position: absolute;
@@ -119,14 +106,14 @@
}
.v0ck_player_button svg:hover {
filter: drop-shadow(0 0 1px var(--accent));
filter: drop-shadow(0 0 9px var(--accent));
fill: #000;
stroke: var(--accent);
stroke-width: 30px;
}
.v0ck_hidden {
display: none !important;
display: none;
}
.v0ck_player_controls svg {
@@ -145,7 +132,7 @@
padding: 0;
align-items: center;
z-index: 2;
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 1%, rgba(0, 0, 0, 0) 100%);
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 20%, rgba(0, 0, 0, 0) 100%);
transition: opacity .3s, transform .3s;
flex-wrap: wrap;
transform: translateY(100%) translateY(-3px);
@@ -326,144 +313,6 @@
transition: none !important;
}
/* Comment markers (danmaku.js): one tick per timeline comment; click = jump there + show the pill.
The layer passes clicks through (normal scrubbing); each tick has a 10px hit area around a 2px line. */
.danmaku-markers {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 4;
}
.danmaku-marker {
position: absolute;
top: 0;
height: 100%;
width: 10px;
margin: 0;
padding: 0;
border: 0;
background: none;
transform: translateX(-50%);
pointer-events: auto;
cursor: pointer;
}
.danmaku-marker::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 2px;
transform: translateX(-50%);
background: #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
transition: width 0.12s, background 0.12s;
}
/* No own timestamp: placed at a random time on load */
.danmaku-marker.is-random::before {
background: rgba(255, 255, 255, 0.45);
box-shadow: none;
}
.danmaku-marker:hover::before {
width: 4px;
background: #ffd844;
}
/* Hover preview of a marker's comment (plain text), above the progress bar */
.danmaku-marker-preview {
position: absolute;
z-index: 30;
max-width: min(320px, 80%);
padding: 6px 9px;
background: rgba(0, 0, 0, 0.88);
border-left: 2px solid var(--accent);
color: #fff;
font-size: 12px;
line-height: 1.35;
pointer-events: none;
opacity: 0;
transform: translateY(4px);
transition: opacity 0.12s, transform 0.12s;
}
.danmaku-marker-preview.is-visible {
opacity: 1;
transform: none;
}
.danmaku-marker-preview .dmp-head {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
}
.danmaku-marker-preview .dmp-time {
color: rgba(255, 255, 255, 0.55);
font-variant-numeric: tabular-nums;
font-size: 11px;
}
.danmaku-marker-preview .dmp-user {
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
}
.danmaku-marker-preview .dmp-text {
overflow-wrap: anywhere;
max-height: 140px;
overflow: hidden;
}
/* Rendered like the flying pills, sized for a small preview */
.danmaku-marker-preview .dmp-text .dpill-line,
.danmaku-marker-preview .dmp-text .dpill-greentext {
display: block;
}
.danmaku-marker-preview .dmp-text .dpill-greentext {
color: #789922;
}
.danmaku-marker-preview .dmp-text .dpill-emoji {
display: inline-block;
height: 1.6em;
width: auto;
max-width: 4em;
vertical-align: middle;
object-fit: contain;
}
.danmaku-marker-preview .dmp-text :is(.dpill-img, .dpill-video) {
display: block;
max-width: 100%;
max-height: 90px;
width: auto;
height: auto;
margin-top: 4px;
object-fit: contain;
}
.danmaku-marker-preview .dmp-text .dpill-spoiler:not(.revealed) {
background: #000;
color: transparent;
}
.danmaku-marker-preview .dmp-text .dpill-spoiler:not(.revealed) * {
visibility: hidden;
}
.danmaku-marker-preview .dmp-text .dpill-blur:not(.revealed) {
filter: blur(6px);
}
/* Seek Marker Ripple */
.v0ck_seek_marker {
position: absolute;
@@ -531,14 +380,13 @@
display: inline-flex;
flex-direction: column; /* stack lines vertically */
align-items: flex-start;
padding: 2px 4px;
padding: 2px 0;
font-family: var(--font, inherit);
font-size: var(--danmaku-font-size, 35px);
font-weight: var(--danmaku-font-weight, 700);
font-size: 35px;
font-weight: 700;
line-height: 1.1;
gap: 0;
color: var(--danmaku-color, #fff);
opacity: var(--danmaku-opacity, 1);
color: #fff;
/* Multi-layer outline shadow for readability over any background */
text-shadow:
-1px -1px 0 #000,
@@ -551,48 +399,12 @@
background: none;
border: none;
text-align: left;
transition: opacity 0.15s ease;
}
/* Pill Background Variations */
.danmaku-overlay.danmaku-bg-glass .danmaku-pill {
background: rgba(10, 10, 14, 0.65);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
padding: 4px 12px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
}
.danmaku-overlay.danmaku-bg-capsule .danmaku-pill {
background: rgba(18, 18, 24, 0.85);
padding: 4px 14px;
border-radius: 999px;
border: 1px solid rgba(var(--accent-rgb, 153, 255, 0), 0.35);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
}
/* Shadow / Glow Variations */
.danmaku-overlay.danmaku-glow-neon .danmaku-pill {
text-shadow:
0 0 8px var(--danmaku-color, #99ff00),
0 0 18px var(--danmaku-color, #99ff00),
-1px -1px 0 #000,
1px 1px 0 #000;
}
.danmaku-overlay.danmaku-glow-none .danmaku-pill {
text-shadow: none;
}
.danmaku-overlay.danmaku-glow-subtle .danmaku-pill {
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.danmaku-pill .dpill-text {
font-family: var(--font, inherit);
font-size: var(--danmaku-font-size, 35px);
font-weight: var(--danmaku-font-weight, 700);
font-size: 35px;
font-weight: 700;
white-space: nowrap;
}
@@ -609,10 +421,6 @@
white-space: nowrap;
}
.danmaku-overlay.danmaku-no-greentext .danmaku-pill .dpill-greentext {
color: inherit;
}
/* Spoiler inside a flying pill — black-on-black, click to reveal */
.danmaku-pill .dpill-spoiler {
background: #000;
@@ -671,8 +479,8 @@
/* Inline image URL embedded in pill */
.danmaku-pill .dpill-img {
max-height: var(--danmaku-media-max-h, 80px);
max-width: 140px;
max-height: 80px;
max-width: 120px;
width: auto;
height: auto;
vertical-align: middle;
@@ -684,8 +492,8 @@
/* Inline video (converted GIF) in pill */
.danmaku-pill .dpill-video {
max-height: var(--danmaku-media-max-h, 80px);
max-width: 140px;
max-height: 80px;
max-width: 120px;
width: auto;
height: auto;
vertical-align: middle;
@@ -701,105 +509,6 @@
font-size: 0.9em;
}
/* ── Danmaku Debug Overlays ────────────────────────────────── */
.danmaku-lane-guides-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 2;
box-sizing: border-box;
}
.danmaku-lane-guide {
position: absolute;
left: 0;
width: 100%;
border-top: 1px dashed rgba(var(--accent-rgb, 153, 255, 0), 0.35);
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px;
font-family: monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.6);
background: rgba(0, 0, 0, 0.04);
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.danmaku-lane-guide.occupied {
background: rgba(255, 60, 60, 0.12);
border-top: 1px dashed rgba(255, 60, 60, 0.65);
color: #ff7070;
}
.danmaku-lane-badge {
background: rgba(0, 0, 0, 0.65);
border: 1px solid rgba(255, 255, 255, 0.15);
padding: 1px 5px;
border-radius: 4px;
font-weight: 700;
}
.danmaku-lane-status {
font-size: 9px;
opacity: 0.9;
}
/* Diagnostic Live HUD */
.danmaku-debug-hud {
position: absolute;
top: 14px;
left: 14px;
z-index: 10;
background: rgba(12, 12, 16, 0.88);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(var(--accent-rgb, 153, 255, 0), 0.45);
border-radius: 8px;
padding: 8px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
color: #e0e0e0;
pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
gap: 4px;
line-height: 1.3;
min-width: 170px;
}
.danmaku-debug-hud .hud-title {
color: var(--accent, #99ff00);
font-weight: 700;
font-size: 11px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
padding-bottom: 3px;
margin-bottom: 2px;
display: flex;
align-items: center;
gap: 6px;
}
.danmaku-debug-hud .hud-row {
display: flex;
justify-content: space-between;
gap: 12px;
}
.danmaku-debug-hud .hud-val {
color: #fff;
font-weight: 700;
}
.danmaku-debug-hud .hud-val.accent {
color: var(--accent, #99ff00);
}
@keyframes danmaku-fly {
from { transform: translateX(calc(100vw + 100%)); }
to { transform: translateX(calc(-100% - 200px)); }
@@ -834,27 +543,8 @@
transform: translate(-50%, 0);
}
/* Hide mouse cursor on inactivity when player controls auto-hide */
.v0ck:not(.v0ck_hover),
.v0ck:not(.v0ck_hover) * {
/* Hide mouse cursor on inactivity in fullscreen */
.v0ck.v0ck_fullscreen:not(.v0ck_hover),
.v0ck.v0ck_fullscreen:not(.v0ck_hover) * {
cursor: none !important;
}
/* Volume: pointer over the whole volume area, not only the 5px slider strip, including the slider's
thumb/track parts (Firefox doesn't hand the input's cursor to them). Only while the controls are
shown, so the idle cursor auto-hide above still applies. */
.v0ck.v0ck_hover .v0ck_volume_group,
.v0ck.v0ck_hover .v0ck_volume_group *,
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"] {
cursor: pointer !important;
}
/* Vendor pseudo-elements in separate rules: one unknown pseudo-element drops a whole selector list */
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-thumb,
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-runnable-track {
cursor: pointer !important;
}
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-moz-range-thumb,
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-moz-range-track {
cursor: pointer !important;
}
}
+71 -582
View File
@@ -6,20 +6,14 @@
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const usernameEl = document.querySelector("a#a_username");
const currentSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
? window.albumGallery.getCurrentSubf0ck()
: null;
const subf0ck_id = currentSub ? (currentSub.slug || currentSub.id) : (tagsContainer?.dataset?.subf0ckSlug || tagsContainer?.dataset?.subf0ckId || null);
return {
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
subf0ck_id,
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
poster: (usernameEl?.dataset?.username || '').trim() || null,
@@ -56,13 +50,6 @@
const tagsContainer = document.querySelector("#tags");
if (!tagsContainer) return;
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
const canManage = !!(
document.querySelector('#tags[data-can-manage="true"]') ||
(window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) ||
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
);
// Only remove existing dynamically generated tags
[...inner.querySelectorAll(".badge")].forEach(tag => {
@@ -72,110 +59,34 @@
}
});
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
const tagsCopy = [...cleanTags];
tagsCopy.reverse().forEach(tag => {
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized);
let contentEl;
if (isRating) {
contentEl = document.createElement("span");
contentEl.className = "rating-label";
contentEl.textContent = tag.tag;
} else {
contentEl = document.createElement("a");
contentEl.href = "/tag/" + tag.normalized;
contentEl.style = "color: inherit !important";
contentEl.textContent = tag.tag;
}
_tags.reverse().forEach(tag => {
const a = document.createElement("a");
a.href = `/tag/${tag.normalized}`;
a.style = "color: inherit !important";
a.textContent = tag.tag;
const span = document.createElement("span");
span.classList.add("badge");
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
span.classList.add('new-tag-glow');
}
span.setAttribute('tooltip', tag.display_name || tag.user);
tag.badge.split(" ").forEach(b => span.classList.add(b));
if (isRating) {
span.classList.add('rating-tag', `is-${tag.normalized}`);
span.dataset.rating = tag.normalized;
if (activePostId) span.dataset.itemId = activePostId;
if (canManage) {
span.classList.add('can-cycle');
}
} else {
span.classList.add('tag-badge');
span.setAttribute('data-tag-id', tag.id || '');
span.setAttribute('data-tag', tag.tag);
span.setAttribute('data-tag-normalized', tag.normalized);
if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) {
span.setAttribute("tooltip", tag.display_name || tag.user);
}
const isExcl = !!tag.is_excluded;
if (isExcl) span.classList.add('tag-is-excluded');
}
const delbutton = document.createElement("a");
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
delbutton.href = "javascript:void(0)";
// Class for delegation
delbutton.classList.add("admin-deltag", "removetag");
span.appendChild(contentEl);
if (!isRating && tag.can_exclude) {
const excludeBtn = document.createElement("button");
excludeBtn.type = "button";
excludeBtn.className = "tag-exclude-btn";
excludeBtn.setAttribute("title", tag.is_excluded ? "Excluded (click to unexclude)" : "Exclude tag");
excludeBtn.setAttribute("aria-label", "Exclude tag");
excludeBtn.innerHTML = `<i class="fa-solid ${tag.is_excluded ? 'fa-circle-check' : 'fa-ban'}"></i>`;
span.appendChild(excludeBtn);
}
if (!isRating) {
const delbutton = document.createElement("a");
delbutton.href = "#";
delbutton.classList.add("admin-deltag", "removetag");
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
span.appendChild(document.createTextNode(" "));
span.appendChild(delbutton);
}
span.appendChild(a);
span.appendChild(document.createTextNode('\u00A0'));
span.appendChild(delbutton);
inner.insertAdjacentElement("afterbegin", span);
});
const hasRating = !!lastRatingTag;
if (!hasRating) {
const untaggedSpan = document.createElement("span");
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`;
untaggedSpan.dataset.rating = 'untagged';
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
const lbl = document.createElement("span");
lbl.className = "rating-label";
lbl.textContent = "unrated";
untaggedSpan.appendChild(lbl);
inner.insertAdjacentElement("afterbegin", untaggedSpan);
}
// Safeguard: remove any extra rating tags in DOM
const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]');
if (allRatingEls.length > 1) {
for (let i = 1; i < allRatingEls.length; i++) {
allRatingEls[i].remove();
}
}
// Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
if (activePostId) {
const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
th.setAttribute('data-mode', modeAttr);
});
if (document.body.classList.contains('onara-modal-open')) {
const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
}
}
// Handle show more/less toggle visibility and count
const allBadges = [...inner.querySelectorAll(".badge")];
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
@@ -210,7 +121,7 @@
e.stopImmediatePropagation();
const ctx = getContext();
if (!ctx) return;
const { postid, subf0ck_id } = ctx;
const { postid } = ctx;
let target = e.target;
if (target.nodeType === 3) target = target.parentElement;
@@ -224,12 +135,8 @@
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
ModAction.confirm((window.f0ckI18n && window.f0ckI18n.tag_delete_title) || 'Delete Tag', `${(window.f0ckI18n && window.f0ckI18n.tag_delete_confirm) || 'Are you sure you want to delete the tag'} <strong style="color:#d9534f">${tagname}</strong>?`, async (reason) => {
const qs = new URLSearchParams();
if (reason) qs.set('reason', reason);
if (subf0ck_id) qs.set('subf0ck_id', subf0ck_id);
const qsStr = qs.toString();
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (qsStr ? "?" + qsStr : ""), {
// Send reason via query param for DELETE request
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (reason ? "?reason=" + encodeURIComponent(reason) : ""), {
method: 'DELETE',
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
})).json();
@@ -237,10 +144,6 @@
if (!res.success) {
throw new Error(res.msg || "Error deleting tag");
}
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
const cur = window.albumGallery.getCurrentSubf0ck();
if (cur && res.tags) cur.tags = res.tags;
}
renderTags(res.tags);
if (window.flashMessage) window.flashMessage((window.f0ckI18n?.tag_deleted_success) || 'Tag deleted', 2500, 'success');
}, { allowEmpty: window.f0ckSession?.is_admin });
@@ -250,7 +153,7 @@
if (e) e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const { postid, subf0ck_id, tags } = ctx;
const { postid, tags } = ctx;
const anchor = document.querySelector("a#a_addtag");
if (!anchor) return;
@@ -259,363 +162,81 @@
existingTags: tags,
anchorEl: anchor,
onSubmit: async (tag) => {
const payload = { tagname: tag };
if (subf0ck_id) payload.subf0ck_id = subf0ck_id;
const res = await post("/api/v2/tags/" + postid, payload);
if (res.success) {
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
const cur = window.albumGallery.getCurrentSubf0ck();
if (cur && res.tags) cur.tags = res.tags;
}
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags: (newTags, hl) => {
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
const cur = window.albumGallery.getCurrentSubf0ck();
if (cur) cur.tags = newTags;
}
renderTags(newTags, hl);
}
renderTags
});
};
let favSeq = 0;
const toggleFavEvent = async (e) => {
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
if (typeof window.flashMessage === 'function') {
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
}
return;
}
const favoBtns = document.querySelectorAll("#a_favo");
if (!favoBtns.length) return;
const firstFavoBtn = favoBtns[0];
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
let localId = firstFavoBtn?.dataset?.localId;
const ctx = getContext();
const postid = localId ? Number(localId) : ctx?.postid;
if (!postid && !chanUrl) return;
if (!ctx) return;
const { postid } = ctx;
const wasAlreadyFav = favoBtns[0].classList.contains('fa-solid') && !favoBtns[0].classList.contains('fa-spinner');
const isNowFav = !wasAlreadyFav;
// Read state BEFORE the API call so we know which direction to toggle
const favoBtn = document.querySelector("#a_favo");
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
const setFavoUi = (isFav) => {
favoBtns.forEach(btn => {
btn.classList.remove('fa-spinner', 'fa-spin');
btn.classList.add('iconset', 'fa-heart');
btn.classList.toggle('fa-solid', isFav);
btn.classList.toggle('fa-regular', !isFav);
btn.title = isFav ? (window.f0ckI18n?.fav_remove || 'Remove from favorites') : (window.f0ckI18n?.fav_add || 'Favorite');
});
};
// 1. Instantly apply client UI
setFavoUi(isNowFav);
// Micro-animation for tactile pop
favoBtns.forEach(btn => {
btn.classList.remove('fav-pop');
void btn.offsetWidth;
btn.classList.add('fav-pop');
const res = await post('/api/v2/togglefav', {
postid: postid
});
// Instant flash message & vibration feedback
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
if (navigator.vibrate) navigator.vibrate(50);
if (postid && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
// Optimistically update #favs badge list
const favcontainer = document.querySelector('#favs');
const prevFavsHtml = favcontainer ? favcontainer.innerHTML : '';
const prevFavsHidden = favcontainer ? favcontainer.hidden : true;
if (favcontainer) {
const currentUser = (window.f0ckSession?.user || '').toLowerCase();
const isAnon = !!(window.f0ckSession?.is_anon || window.f0ckSession?.user === 'anonymous');
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
const findSelfBadge = () => {
const selfBadges = favcontainer.querySelectorAll('[data-self="true"]');
if (selfBadges.length) return selfBadges[0];
if (currentUser && currentUser !== 'anonymous') {
const links = favcontainer.querySelectorAll('a[href]');
for (const a of links) {
const path = a.getAttribute('href') || '';
if (path.toLowerCase().endsWith('/user/' + currentUser)) return a;
}
}
return null;
};
if (!isNowFav) {
const selfBadge = findSelfBadge();
if (selfBadge) selfBadge.remove();
if (favcontainer.children.length === 0) {
favcontainer.hidden = true;
}
} else {
let selfBadge = findSelfBadge();
if (!selfBadge) {
selfBadge = document.createElement('a');
selfBadge.dataset.self = 'true';
selfBadge.setAttribute('flow', 'up');
if (isAnonymized || isAnon) {
selfBadge.className = 'ghost-fav';
selfBadge.setAttribute('tooltip', 'anonymous');
selfBadge.style.cursor = 'default';
const img = document.createElement('img');
img.src = '/a/default.png';
img.style.height = "32px";
img.style.width = "32px";
selfBadge.appendChild(img);
} else {
selfBadge.href = `/user/${currentUser}`;
selfBadge.setAttribute('tooltip', window.f0ckSession?.display_name || window.f0ckSession?.user || 'anonymous');
const img = document.createElement('img');
const avatarFile = window.f0ckSession?.avatar_file;
const avatar = window.f0ckSession?.avatar;
img.src = avatarFile ? `/a/${avatarFile}` : (avatar ? `/t/${avatar}.webp` : '/a/default.png');
img.style.height = "32px";
img.style.width = "32px";
if (window.f0ckSession?.username_color) img.style.borderColor = window.f0ckSession.username_color;
selfBadge.appendChild(img);
}
favcontainer.appendChild(selfBadge);
}
favcontainer.hidden = false;
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;
const mySeq = ++favSeq;
if (favoBtn) {
favoBtn.classList.toggle('fa-solid', isNowFav);
favoBtn.classList.toggle('fa-regular', !isNowFav);
}
// 2. Run server operation in background — can take as long as it needs
(async () => {
try {
// If viewing un-rehosted 4chan post, auto-rehost first
if (isChan && !localId && chanUrl) {
if (mySeq !== favSeq) return;
const csrfToken = window.f0ckSession?.csrf_token || '';
const rehostResp = await fetch('/api/v2/scroller/rehost', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: new URLSearchParams({
url: chanUrl,
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
})
});
const rehostData = await rehostResp.json();
if (rehostData.success && rehostData.item_id) {
localId = rehostData.item_id;
favoBtns.forEach(btn => {
btn.dataset.itemId = localId;
btn.dataset.localId = localId;
});
const commentsEl = document.querySelector("#comments-container");
if (commentsEl) commentsEl.dataset.itemId = localId;
const infoEl = document.querySelector("#a_info");
if (infoEl) infoEl.dataset.itemId = localId;
const favcontainer = document.querySelector('#favs');
favcontainer.innerHTML = "";
if (res.favs.length > 0) {
res.favs.forEach(f => {
const a = document.createElement('a');
a.href = `/user/${f.user}`;
a.setAttribute('tooltip', f.display_name || f.user);
a.setAttribute('flow', 'up');
const timeEl = document.querySelector('time.timeago');
if (timeEl) {
const nowIso = new Date().toISOString();
timeEl.dataset.iso = nowIso;
const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
timeEl.setAttribute('tooltip', fullDate);
timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
}
const img = document.createElement('img');
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
img.style.height = "32px";
img.style.width = "32px";
if (f.username_color) img.style.borderColor = f.username_color;
if (chanRehostBtn) {
chanRehostBtn.classList.add('rehosted');
chanRehostBtn.outerHTML = `
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
</span>
`;
}
} else {
throw new Error(rehostData.msg || 'Rehost failed');
}
}
const effectivePostId = localId ? Number(localId) : (postid || getContext()?.postid);
if (!effectivePostId) {
throw new Error('Missing post ID');
}
if (mySeq !== favSeq) return;
const res = await post('/api/v2/togglefav', {
postid: effectivePostId,
chan_url: chanUrl,
action: isNowFav ? 'add' : 'delete',
favorited: isNowFav
a.appendChild(img);
favcontainer.appendChild(a);
});
if (mySeq !== favSeq) return;
if (res && res.success) {
if (window.invalidateItemCache) {
window.invalidateItemCache(effectivePostId);
}
const finalIsFav = (res.favorited !== undefined) ? !!res.favorited : isNowFav;
setFavoUi(finalIsFav);
const curFavContainer = document.querySelector('#favs');
if (curFavContainer && Array.isArray(res.favs)) {
curFavContainer.innerHTML = "";
if (res.favs.length > 0) {
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
const fragment = document.createDocumentFragment();
res.favs.forEach(f => {
const isSelf = window.f0ckSession && (
(window.f0ckSession.id && f.user_id && Number(window.f0ckSession.id) === Number(f.user_id)) ||
(window.f0ckSession.user && f.user && window.f0ckSession.user.toLowerCase() === f.user.toLowerCase()) ||
(window.f0ckSession.login && f.login && window.f0ckSession.login.toLowerCase() === f.login.toLowerCase())
);
const isFavAnon = f.is_anon || f.user === 'anonymous' || (typeof f.user === 'string' && f.user.startsWith('anon_'));
if (f.hide_fav_badge && !isSelf) {
const a = document.createElement('a');
a.className = 'ghost-fav';
a.setAttribute('tooltip', '?');
a.setAttribute('flow', 'up');
a.style.cursor = 'default';
const img = document.createElement('img');
img.src = '/s/img/ghost_fav.svg';
img.style.height = "32px";
img.style.width = "32px";
a.appendChild(img);
fragment.appendChild(a);
} else if (isAnonymized || isFavAnon) {
const a = document.createElement('a');
a.className = 'ghost-fav';
if (isSelf) a.dataset.self = 'true';
a.setAttribute('tooltip', 'anonymous');
a.setAttribute('flow', 'up');
a.style.cursor = 'default';
const img = document.createElement('img');
img.src = '/a/default.png';
img.style.height = "32px";
img.style.width = "32px";
a.appendChild(img);
fragment.appendChild(a);
} else {
const a = document.createElement('a');
if (isSelf) a.dataset.self = 'true';
a.href = `/user/${(f.user || '').toLowerCase()}`;
a.setAttribute('tooltip', f.display_name || f.user || 'anonymous');
a.setAttribute('flow', 'up');
const img = document.createElement('img');
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
img.style.height = "32px";
img.style.width = "32px";
if (f.username_color) img.style.borderColor = f.username_color;
a.appendChild(img);
fragment.appendChild(a);
}
});
curFavContainer.appendChild(fragment);
curFavContainer.hidden = false;
} else {
curFavContainer.hidden = true;
}
}
} else {
if (mySeq !== favSeq) return;
setFavoUi(wasAlreadyFav);
const curFavContainer = document.querySelector('#favs');
if (curFavContainer) {
curFavContainer.innerHTML = prevFavsHtml;
curFavContainer.hidden = prevFavsHidden;
}
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
}
}
} catch (err) {
console.error('[CHAN-FAV-ADMIN] Error during background favorite sync:', err);
if (mySeq !== favSeq) return;
setFavoUi(wasAlreadyFav);
const curFavContainer = document.querySelector('#favs');
if (curFavContainer) {
curFavContainer.innerHTML = prevFavsHtml;
curFavContainer.hidden = prevFavsHidden;
}
if (typeof window.flashMessage === 'function') {
window.flashMessage('Failed to update favorite.', 3000, 'error');
}
favcontainer.hidden = false;
} else {
favcontainer.hidden = true;
}
})();
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
if (navigator.vibrate) navigator.vibrate(50);
}
};
const deleteSubItemEvent = async (subf0ck, postid) => {
if (!subf0ck || !postid) return;
const deleteButtonEvent = async e => {
if (e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
const ctx = getContext();
if (!ctx) return;
const { postid, poster, authorId } = ctx;
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
const subDesc = subf0ck.display_index
? `Slide #${subf0ck.display_index}`
: `Slide (${subf0ck.slug || subf0ck.id})`;
ModAction.confirm(
'Delete Slide from Album',
`Are you sure you want to delete <strong style="color:#d9534f">${subDesc}</strong> from this album?<br><small style="opacity:0.8;">The rest of the album will remain intact.</small>`,
async (reason) => {
const res = await post("/api/v2/admin/delete-album-item", {
postid: postid,
sub_id: subf0ck.id,
sub_slug: subf0ck.slug,
order_index: subf0ck.order_index,
reason: reason
});
if (!res.success) {
throw new Error(res.msg || 'Failed to delete album item');
}
if (res.post_deleted) {
if (window.flashMessage) window.flashMessage('Album deleted (no remaining items)', 2500, 'info');
const mediaObj = document.querySelector('.media-object');
if (mediaObj) {
mediaObj.innerHTML = '<div style="padding: 100px; text-align: center; color: #d9534f;"><h1>Album Deleted</h1><p>The album has been removed.</p></div>';
}
} else {
if (window.albumGallery && typeof window.albumGallery.removeSubf0ck === 'function') {
window.albumGallery.removeSubf0ck(subf0ck.id || subf0ck.slug || subf0ck.order_index);
} else {
window.location.reload();
}
if (window.flashMessage) window.flashMessage('Slide deleted from album', 2500, 'success');
}
},
{ allowEmpty: window.f0ckSession?.is_admin, confirmText: 'Delete Slide' }
);
};
const deleteEntirePost = (postid, poster, authorId) => {
const i18n = window.f0ckI18n || {};
const confirmTitle = i18n.item_delete_title || 'Delete Item';
const posterStr = poster
@@ -646,68 +267,6 @@
}, { allowEmpty: window.f0ckSession?.is_admin });
};
const deleteButtonEvent = async e => {
if (e) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
const ctx = getContext();
if (!ctx) return;
const { postid, poster, authorId } = ctx;
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
const isAlbum = !!(window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function');
const curSub = isAlbum ? window.albumGallery.getCurrentSubf0ck() : null;
if (isAlbum && curSub) {
const subDesc = curSub.display_index ? `Slide ${curSub.display_index}` : 'Current Slide';
const choiceHtml = `
<div style="margin-bottom: 15px; font-size: 1.05rem;">
This post is an album containing multiple slides. What would you like to delete?
</div>
<div style="display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
<button type="button" id="btn-choice-delete-sub" class="btn btn-warning" style="padding: 8px 16px; font-weight: 600; cursor: pointer;">
<i class="fa-solid fa-trash-can"></i> Delete ${subDesc} Only
</button>
<button type="button" id="btn-choice-delete-album" class="btn btn-danger" style="padding: 8px 16px; font-weight: 600; cursor: pointer;">
<i class="fa-solid fa-layer-group"></i> Delete Entire Album
</button>
</div>
`;
ModAction.confirm('Delete Options', choiceHtml, () => {}, {
hideReason: true,
hideConfirm: true,
unsafeContent: true,
cancelText: 'Cancel'
});
setTimeout(() => {
const modal = document.getElementById('mod-action-modal');
if (!modal) return;
const subBtn = modal.querySelector('#btn-choice-delete-sub');
const albumBtn = modal.querySelector('#btn-choice-delete-album');
if (subBtn) {
subBtn.onclick = () => {
modal.style.display = 'none';
deleteSubItemEvent(curSub, postid);
};
}
if (albumBtn) {
albumBtn.onclick = () => {
modal.style.display = 'none';
deleteEntirePost(postid, poster, authorId);
};
}
}, 50);
return;
}
deleteEntirePost(postid, poster, authorId);
};
let tmptt = null;
const editTagEvent = async e => {
e.preventDefault();
@@ -774,87 +333,17 @@
addtagClick(e);
} else if (target.closest("#a_delete")) {
deleteButtonEvent(e);
} else if (target.closest(".album-sub-delete-btn, #a_delete_sub")) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
const ctx = getContext();
const curSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
? window.albumGallery.getCurrentSubf0ck()
: null;
if (ctx && curSub) {
deleteSubItemEvent(curSub, ctx.postid);
}
} else if (target.matches('#tags .badge > a[href*="/tag/"]')) {
editTagEvent(e);
} else if (target.closest('.admin-deltag') || target.closest('.removetag')) {
deleteEvent(e);
} else if (target.closest("#a_pin")) {
pinButtonEvent(e);
} else if (target.closest("#a_unavailable")) {
unavailableButtonEvent(e);
} else if (target.closest("#a_favo")) {
toggleFavEvent(e);
}
});
const unavailableButtonEvent = async e => {
if (e) e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const { postid } = ctx;
const unavBtn = document.querySelector('#a_unavailable');
if (!unavBtn) return;
const currentVis = parseInt(unavBtn.getAttribute('data-visibility') || '0', 10);
const willBeUnavailable = currentVis !== 3;
const targetVis = willBeUnavailable ? 3 : 0;
const actionText = willBeUnavailable ? 'Make Unavailable (serves HTTP 451 to non-logged in visitors)' : 'Make Available (Public)';
const proceed = async () => {
try {
const res = await (await fetch('/api/v2/item/visibility', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession?.csrf_token
},
body: new URLSearchParams({ postid, id: postid, visibility: targetVis })
})).json();
if (res.success) {
const isNowUnav = res.visibility === 3;
unavBtn.setAttribute('data-visibility', res.visibility);
unavBtn.classList.toggle('active', isNowUnav);
unavBtn.style.color = isNowUnav ? 'var(--danger, #ff4444)' : '';
unavBtn.setAttribute('title', isNowUnav ? 'Make Available (Public)' : 'Make Unavailable (451)');
const infoVisLabel = document.getElementById('info-visibility-label');
if (infoVisLabel) {
infoVisLabel.innerHTML = isNowUnav
? '<i class="fa-solid fa-ban" style="color: var(--color-danger, #ff4444);"></i> Unavailable (451)'
: '<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public';
}
const infoVisBtn = document.getElementById('info-visibility-edit-btn');
if (infoVisBtn) infoVisBtn.dataset.visibility = res.visibility;
window.flashMessage(isNowUnav ? 'ITEM MARKED UNAVAILABLE (451)' : 'ITEM RESTORED TO PUBLIC');
} else {
alert('Error: ' + (res.msg || 'Failed to update visibility'));
}
} catch (err) {
console.error('Unavailable error:', err);
}
};
if (typeof ModAction !== 'undefined' && ModAction.confirm) {
ModAction.confirm('Item Visibility', `${actionText} for post <strong>#${postid}</strong>?`, proceed);
} else if (confirm(`${actionText} for post #${postid}?`)) {
proceed();
}
};
const pinButtonEvent = async e => {
if (e) e.preventDefault();
const ctx = getContext();
-748
View File
@@ -1,748 +0,0 @@
/**
* f0ckm Anonymous Passkey Identity Manager
*
* Replaces the old OpenSSH Ed25519 approach.
* Private keys NEVER touch localStorage — they live in the OS / Bitwarden credential store.
*
* Flow:
* First visit: "Login as Anonymous" → register/begin → browser passkey prompt → register/finish → session
* Return visit: "Login as Anonymous" → auth/begin → browser passkey picker → auth/finish → session
*
* For registered users:
* Settings page calls window.f0ckPasskeyManager.addPasskey() to add a passkey.
*/
(function () {
'use strict';
// ─── base64url helpers (browser) ───────────────────────────────────────────
function b64urlToArr(b64) {
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
const arr = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
return arr;
}
function arrToB64url(buf) {
const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
let bin = '';
arr.forEach(b => bin += String.fromCharCode(b));
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// ─── Hardware fingerprint (kept for ban enforcement) ──────────────────────
async function getHardwareFingerprint() {
// Disabled by the operator (websrv.anon_hw_fingerprint: false): compute nothing, forget any cached value
if (window.f0ckHwFingerprint === false) {
try { localStorage.removeItem('f0ck_anon_hw_fp'); } catch (e) {}
return null;
}
try {
const cached = localStorage.getItem('f0ck_anon_hw_fp');
if (cached) return cached;
} catch (e) {}
try {
let glVendor = '', glRenderer = '', glLimits = '';
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (gl) {
const ext = gl.getExtension('WEBGL_debug_renderer_info');
if (ext) {
glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || '';
glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '';
}
glLimits = [
gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0,
gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0,
gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0,
gl.getParameter(gl.MAX_VARYING_VECTORS) || 0,
gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0
].join(',');
}
} catch (e) {}
let gpuArch = '';
try {
if (navigator.gpu) {
const adapter = await navigator.gpu.requestAdapter();
if (adapter && adapter.info) {
gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':');
}
}
} catch (e) {}
const concurrency = navigator.hardwareConcurrency || 0;
const memory = navigator.deviceMemory || 0;
const platform = navigator.platform || '';
const screenInfo = [
window.screen ? window.screen.width : 0,
window.screen ? window.screen.height : 0,
window.screen ? window.screen.colorDepth : 0,
window.devicePixelRatio || 1
].join('x');
const touchPoints = navigator.maxTouchPoints || 0;
let canvasFp = '';
try {
const c2d = document.createElement('canvas');
c2d.width = 240; c2d.height = 60;
const ctx = c2d.getContext('2d');
if (ctx) {
ctx.fillStyle = '#f60'; ctx.fillRect(10, 5, 60, 20);
ctx.fillStyle = '#069'; ctx.font = '14pt Arial, sans-serif';
ctx.fillText('f0ck.dev 😃', 4, 35);
const imgData = ctx.getImageData(0, 0, 240, 60).data;
let sum = 0;
for (let i = 0; i < imgData.length; i += 4) {
sum = (sum * 31 + imgData[i] + imgData[i+1] + imgData[i+2] + imgData[i+3]) >>> 0;
}
canvasFp = sum.toString(16);
}
} catch (e) {}
let audioFp = '';
try {
const AudioCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
if (AudioCtx) {
const actx = new AudioCtx(1, 44100, 44100);
const osc = actx.createOscillator();
osc.type = 'triangle';
osc.frequency.setValueAtTime(10000, actx.currentTime);
const comp = actx.createDynamicsCompressor();
comp.threshold.setValueAtTime(-50, actx.currentTime);
comp.knee.setValueAtTime(40, actx.currentTime);
comp.ratio.setValueAtTime(12, actx.currentTime);
comp.attack.setValueAtTime(0, actx.currentTime);
comp.release.setValueAtTime(0.25, actx.currentTime);
osc.connect(comp); comp.connect(actx.destination); osc.start(0);
const buf = await actx.startRendering();
const ch = buf.getChannelData(0);
let sum = 0;
for (let i = 4500; i < Math.min(ch.length, 5000); i++) sum += Math.abs(ch[i] || 0);
audioFp = sum.toFixed(7);
}
} catch (e) {}
const raw = [glVendor, glRenderer, glLimits, gpuArch, concurrency, memory, platform, screenInfo, touchPoints, canvasFp, audioFp].join('~~~');
const hashBuf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
const fp = `HW:${hashHex}`;
try { localStorage.setItem('f0ck_anon_hw_fp', fp); } catch (e) {}
return fp;
} catch (err) {
console.warn('[ANON_PASSKEY] Failed to compute hardware fingerprint:', err);
return null;
}
}
// ─── Tombstone (ban state persisted client-side) ───────────────────────────
function getTombstone() {
try { return JSON.parse(localStorage.getItem('f0ck_anon_tombstone') || 'null'); } catch (e) { return null; }
}
function setTombstone(t) {
try {
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(t));
if (t && t.banned) {
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(t))}; Path=/; Max-Age=31536000; SameSite=Lax`;
}
} catch (e) {}
}
// ─── AnonPasskey class ────────────────────────────────────────────────────
class AnonPasskey {
constructor() {
this.isSessionReady = false;
this._loginInProgress = false;
}
// ── Check WebAuthn support ──────────────────────────────────────────────
get supported() {
return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
}
// ── UI helpers ──────────────────────────────────────────────────────────
updateNavUI(isAnon) {
const icon = document.getElementById('nav-visitor-icon');
const label = document.getElementById('nav-anon-label');
if (icon) {
icon.classList.toggle('fa-user-secret', isAnon);
icon.classList.toggle('fa-user', !isAnon);
}
if (label) label.textContent = isAnon ? 'anonymous' : 'guest';
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
if (label) label.textContent = 'guest';
['nav-login-anon-btn', 'nav-anon-identity-btn', 'nav-anon-settings-btn', 'nav-anon-logout-btn', 'nav-anon-divider'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
if (modalAnonBtn) {
const w = modalAnonBtn.closest('div');
if (w) w.style.display = 'none'; else modalAnonBtn.style.display = 'none';
}
return;
}
const loginAnonBtn = document.getElementById('nav-login-anon-btn');
const anonIdentityBtn = document.getElementById('nav-anon-identity-btn');
const anonSettingsBtn = document.getElementById('nav-anon-settings-btn');
const anonLogoutBtn = document.getElementById('nav-anon-logout-btn');
const anonDivider = document.getElementById('nav-anon-divider');
const guestFavsNav = document.getElementById('nav-guest-favs');
const guestFavsLink = document.getElementById('nav-guest-favs-link');
if (loginAnonBtn) loginAnonBtn.style.display = isAnon ? 'none' : '';
if (anonIdentityBtn) anonIdentityBtn.style.display = isAnon ? '' : 'none';
if (anonSettingsBtn) anonSettingsBtn.style.display = isAnon ? '' : 'none';
if (anonLogoutBtn) anonLogoutBtn.style.display = isAnon ? '' : 'none';
if (anonDivider) anonDivider.style.display = isAnon ? '' : 'none';
if (guestFavsNav) guestFavsNav.style.display = isAnon ? '' : 'none';
if (guestFavsLink) guestFavsLink.style.display = isAnon ? '' : 'none';
if (isAnon && window.f0ckSession) {
window.f0ckSession.user = window.f0ckSession.user || 'anonymous';
window.f0ckSession.is_anon = true;
window.f0ckSession.logged_in = true;
}
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
}
_applySessionData(data, hwFingerprint) {
this.isSessionReady = true;
if (window.f0ckSession) {
window.f0ckSession.user = 'anonymous';
window.f0ckSession.is_anon = true;
window.f0ckSession.logged_in = true;
window.f0ckSession.id = data.user_id;
window.f0ckSession.user_id = data.user_id;
if (data.csrf_token) window.f0ckSession.csrf_token = data.csrf_token;
}
const metaCsrf = document.querySelector('meta[name="csrf-token"]');
if (metaCsrf && data.csrf_token) metaCsrf.content = data.csrf_token;
window.f0ckAnonIdentity = {
userId: data.user_id,
fingerprint: data.fingerprint,
shortFingerprint: data.short_fingerprint,
hwFingerprint: data.hw_fingerprint || hwFingerprint,
credentialId: data.credential_id
};
window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity }));
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
}
// ── Register a new passkey (anonymous user) ────────────────────────────
async register() {
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
// 1. Get registration options from server
const beginRes = await fetch('/api/v2/anon/passkey/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error during registration setup');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
// 2. Decode options for the WebAuthn API
const pkOpts = {
rp: opts.rp,
user: {
id: b64urlToArr(opts.user.id),
name: opts.user.name,
displayName: opts.user.displayName
},
challenge: b64urlToArr(rawChallenge),
pubKeyCredParams: opts.pubKeyCredParams,
timeout: opts.timeout || 60000,
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
authenticatorSelection: opts.authenticatorSelection,
attestation: opts.attestation || 'none'
};
// 3. Browser passkey creation prompt
const cred = await navigator.credentials.create({ publicKey: pkOpts });
// 4. Send attestation to server
const hwFp = await getHardwareFingerprint();
const tombstone = getTombstone();
const finishRes = await fetch('/api/v2/anon/passkey/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
attestationObject: arrToB64url(cred.response.attestationObject),
hw_fingerprint: hwFp,
tombstone: tombstone
})
});
const finishData = await finishRes.json();
if (finishData.banned) {
setTombstone({
banned: true,
fingerprint: finishData.fingerprint,
hw_fingerprint: finishData.hw_fingerprint || hwFp,
reason: finishData.reason,
expires: finishData.expires
});
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
throw new Error('Banned: ' + (finishData.reason || ''));
}
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
// Mark this browser as having a registered passkey for this site
this._markLocalPasskey();
return finishData;
}
// ── Authenticate with an existing passkey (anonymous user) ────────────
async authenticate() {
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
// 1. Get auth challenge from server
const beginRes = await fetch('/api/v2/anon/passkey/auth/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error during authentication setup');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
// 2. Invoke browser passkey picker
const pkOpts = {
challenge: b64urlToArr(rawChallenge),
rpId: opts.rpId,
userVerification: opts.userVerification || 'preferred',
timeout: opts.timeout || 60000,
allowCredentials: (opts.allowCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) }))
};
const assertion = await navigator.credentials.get({ publicKey: pkOpts });
// 3. Send assertion to server
const hwFp = await getHardwareFingerprint();
const tombstone = getTombstone();
const finishRes = await fetch('/api/v2/anon/passkey/auth/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(assertion.rawId),
clientDataJSON: arrToB64url(assertion.response.clientDataJSON),
authenticatorData: arrToB64url(assertion.response.authenticatorData),
signature: arrToB64url(assertion.response.signature),
hw_fingerprint: hwFp,
tombstone: tombstone
})
});
const finishData = await finishRes.json();
if (finishData.banned) {
setTombstone({
banned: true,
fingerprint: finishData.fingerprint,
hw_fingerprint: finishData.hw_fingerprint || hwFp,
reason: finishData.reason,
expires: finishData.expires
});
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
throw new Error('Banned: ' + (finishData.reason || ''));
}
if (!finishData.success) throw new Error(finishData.msg || 'Authentication failed');
return finishData;
}
// ── Add a passkey to the current anonymous identity ───────────────────
async addPasskey() {
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token)
|| document.querySelector('meta[name="csrf-token"]')?.content || '';
const beginRes = await fetch('/api/v2/anon/passkey/add/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({})
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
const cred = await navigator.credentials.create({ publicKey: {
rp: opts.rp,
user: {
id: b64urlToArr(opts.user.id),
name: opts.user.name,
displayName: opts.user.displayName
},
challenge: b64urlToArr(rawChallenge),
pubKeyCredParams: opts.pubKeyCredParams,
timeout: opts.timeout || 60000,
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
authenticatorSelection: opts.authenticatorSelection,
attestation: opts.attestation || 'none'
}});
const finishRes = await fetch('/api/v2/anon/passkey/add/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
attestationObject: arrToB64url(cred.response.attestationObject),
hw_fingerprint: await getHardwareFingerprint()
})
});
const finishData = await finishRes.json();
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
return finishData;
}
// ── Helpers ───────────────────────────────────────────────────────────────
_hasLocalPasskey() {
try { return !!localStorage.getItem('f0ck_anon_has_passkey'); } catch (e) { return false; }
}
_markLocalPasskey() {
try { localStorage.setItem('f0ck_anon_has_passkey', '1'); } catch (e) {}
}
async _finishLogin(data) {
const hwFp = await getHardwareFingerprint();
this._applySessionData(data, hwFp);
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
await window.f0ckGuestFavs.importToAccount();
}
this.updateNavUI(true);
// Close both modals
const sm = document.getElementById('anon-setup-modal'); if (sm) sm.style.display = 'none';
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
if (typeof window.showToastNotification === 'function') {
window.showToastNotification('Logged in as anonymous');
}
window.location.reload();
}
// ── Public: called by the setup modal's "Create my passkey" button ────────
async doRegister() {
if (this._loginInProgress) return;
this._loginInProgress = true;
try {
const data = await this.register(); // throws on error/cancel
this._markLocalPasskey();
await this._finishLogin(data);
} catch (err) {
this._loginInProgress = false;
throw err; // modal handles the error display
}
}
// ── Public: called by the setup modal's "Use my passkey" button ───────────
async doAuthenticate() {
if (this._loginInProgress) return;
this._loginInProgress = true;
try {
const data = await this.authenticate();
await this._finishLogin(data);
} catch (err) {
this._loginInProgress = false;
throw err;
}
}
// ── Public: opens the setup modal (new vs returning view) ─────────────────
openSetupModal() {
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) return;
const tombstone = getTombstone();
if (tombstone && tombstone.banned) {
if (window.location.pathname !== '/banned') window.location.href = '/banned';
return;
}
if (!this.supported) {
alert('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.');
return;
}
// Close the login modal first
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
const hasPasskey = this._hasLocalPasskey();
const newView = document.getElementById('anon-setup-new');
const retView = document.getElementById('anon-setup-returning');
if (newView) newView.style.display = hasPasskey ? 'none' : '';
if (retView) retView.style.display = hasPasskey ? '' : 'none';
const modal = document.getElementById('anon-setup-modal');
if (modal) modal.style.display = 'flex';
}
// ── Legacy: clicking "Login as anonymous" anywhere just opens the modal ───
loginAsAnonymous() {
this.openSetupModal();
}
// ── Logout ────────────────────────────────────────────────────────────────
async logoutAnonymous() {
try {
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
} finally {
this.updateNavUI(false);
if (typeof window.showToastNotification === 'function') {
window.showToastNotification('Logged out from anonymous session');
}
window.location.reload();
}
}
// ── Identity modal (shown when already logged in as anon) ─────────────────
openModal() {
const modal = document.getElementById('anon-passkey-modal');
if (!modal) return;
this._refreshModalContent();
modal.style.display = 'flex';
}
closeModal() {
const modal = document.getElementById('anon-passkey-modal');
if (modal) modal.style.display = 'none';
}
async _refreshModalContent() {
try {
const res = await fetch('/api/v2/anon/identity');
const data = await res.json();
const fpEl = document.getElementById('anon-pk-fp-display');
if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none';
this._renderPasskeyCount(data.passkey_count, data.passkey_max);
} catch (e) {}
}
_renderPasskeyCount(count, max) {
const countEl = document.getElementById('anon-pk-count');
const maxEl = document.getElementById('anon-pk-max');
const addBtn = document.getElementById('anon-pk-add-btn');
if (countEl && typeof count === 'number') countEl.textContent = count;
if (maxEl && typeof max === 'number') maxEl.textContent = max;
if (addBtn && typeof count === 'number' && typeof max === 'number') {
const full = count >= max;
addBtn.disabled = full;
addBtn.innerHTML = full
? '<i class="fa-solid fa-lock"></i> Limit reached'
: '<i class="fa-solid fa-plus"></i> Add passkey';
}
}
// ── Init ──────────────────────────────────────────────────────────────────
async init() {
if (window.location.pathname === '/banned') return;
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
if (window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
window.location.reload();
return;
}
this.updateNavUI(false);
return;
}
// Registered user — nothing to do
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) return;
// Check tombstone — if banned, don't auto-login
const tombstone = getTombstone();
if (tombstone && tombstone.banned) {
this.updateNavUI(false);
this.attachUIListeners();
return;
}
// If backend session already shows is_anon, mark ready
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
this.isSessionReady = true;
this.updateNavUI(true);
} else {
// Stale backend session without a local key no longer applies — just show guest
if (window.f0ckSession && window.f0ckSession.is_anon && !window.f0ckSession.logged_in) {
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
window.location.reload();
return;
}
this.updateNavUI(false);
}
this.attachUIListeners();
}
attachUIListeners() {
// Modal login-as-anonymous button
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
if (modalAnonBtn) {
modalAnonBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); this.loginAsAnonymous(); });
}
document.addEventListener('click', e => {
if (e.target.closest('#nav-anon-identity-btn')) {
e.preventDefault(); this.openModal(); return;
}
if (e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon')) {
e.preventDefault(); this.loginAsAnonymous(); return;
}
const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]');
if (logoutTarget && window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
e.preventDefault(); this.logoutAnonymous(); return;
}
});
const anonBtn = document.getElementById('nav-anon-identity-btn');
if (anonBtn) anonBtn.addEventListener('click', e => { e.preventDefault(); this.openModal(); });
const modalClose = document.getElementById('anon-passkey-modal-close');
if (modalClose) modalClose.addEventListener('click', () => this.closeModal());
const modalOverlay = document.getElementById('anon-passkey-modal');
if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); });
// Add-passkey button inside modal — attaches a new passkey to this identity (max enforced server-side)
const addBtn = document.getElementById('anon-pk-add-btn');
if (addBtn) {
addBtn.addEventListener('click', async () => {
const errEl = document.getElementById('anon-pk-error');
if (errEl) errEl.style.display = 'none';
addBtn.disabled = true;
try {
const data = await this.addPasskey();
if (typeof window.showToastNotification === 'function') window.showToastNotification('Passkey added');
this._renderPasskeyCount(data.passkey_count, data.passkey_max);
} catch (err) {
console.warn('[ANON_PASSKEY] Modal add passkey error:', err);
if (errEl) {
errEl.style.display = '';
errEl.textContent = (err && err.name === 'NotAllowedError')
? 'Cancelled or blocked. Unlock your password manager and try again.'
: ((err && err.message) || 'Failed to add passkey.');
}
addBtn.disabled = false;
this._refreshModalContent();
}
});
}
}
}
// ─── Passkey manager for registered users (used by settings page) ──────────
class PasskeyManager {
async listPasskeys() {
const res = await fetch('/api/v2/settings/passkeys');
return (await res.json()).passkeys || [];
}
async addPasskey(name) {
// 1. Begin
const beginRes = await fetch('/api/v2/settings/passkeys/register/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name || 'Passkey' })
});
const beginData = await beginRes.json();
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
const rawChallenge = beginData.options.challenge;
const opts = beginData.options;
const pkOpts = {
rp: opts.rp,
user: {
id: b64urlToArr(opts.user.id),
name: opts.user.name,
displayName: opts.user.displayName
},
challenge: b64urlToArr(rawChallenge),
pubKeyCredParams: opts.pubKeyCredParams,
timeout: opts.timeout || 60000,
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
authenticatorSelection: opts.authenticatorSelection,
attestation: opts.attestation || 'none'
};
const cred = await navigator.credentials.create({ publicKey: pkOpts });
// 2. Finish
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const finishRes = await fetch('/api/v2/settings/passkeys/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({
challenge: rawChallenge,
credentialId: arrToB64url(cred.rawId),
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
attestationObject: arrToB64url(cred.response.attestationObject),
name: name || 'Passkey'
})
});
const finishData = await finishRes.json();
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
return finishData;
}
async deletePasskey(credentialId) {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const res = await fetch('/api/v2/settings/passkeys/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
body: JSON.stringify({ credential_id: credentialId })
});
const data = await res.json();
if (!data.success) throw new Error(data.msg || 'Delete failed');
return data;
}
}
// ─── Bootstrap ────────────────────────────────────────────────────────────
window.f0ckAnonPasskey = new AnonPasskey();
window.f0ckPasskeyManager = new PasskeyManager();
// Backwards compat alias (so any code that checks window.f0ckAnonSSH still works for guards)
window.f0ckAnonSSH = window.f0ckAnonPasskey;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => window.f0ckAnonPasskey.init());
} else {
window.f0ckAnonPasskey.init();
}
})();
+44 -327
View File
@@ -27,8 +27,6 @@ class CommentSystem {
if (this.displayMode === 1) this.sort = 'old';
this.customEmojis = CommentSystem.emojiCache || {};
this._selfPostedCommentIds = new Set();
this._pendingSelfCommentTexts = new Set();
this.icons = {
reply: `<i class="fa-solid fa-reply"></i>`,
@@ -47,36 +45,21 @@ class CommentSystem {
// inline script in header.html (prevents flash). Here we sync the
// container-level class and display state to match.
if (this.container) {
// A slide cut short by navigating away must not leave its border behind
this._legacySlide = null;
document.body.classList.remove('comments-sliding');
const isHidden = localStorage.getItem('comments_hidden') === 'true';
// Force show if hash is present
if (window.location.hash && window.location.hash.startsWith('#c')) {
this.container.classList.remove('faded-out', 'is-hidden');
this.container.style.removeProperty('display');
this.container.style.display = '';
this.container.classList.remove('faded-out');
this.container.style.display = 'block';
localStorage.setItem('comments_hidden', 'false');
document.body.classList.remove('sidebar-left-hidden', 'comments-hidden');
document.body.classList.remove('sidebar-left-hidden');
const layout = this.container.closest('.item-layout-container');
if (layout) layout.classList.remove('sidebar-hidden');
const sidebar = this.container.closest('.item-sidebar-left');
if (sidebar) {
sidebar.classList.remove('is-hidden');
sidebar.style.removeProperty('display');
sidebar.style.display = '';
}
} else if (isHidden) {
this.container.classList.add('faded-out', 'is-hidden');
this.container.style.setProperty('display', 'none', 'important');
document.body.classList.add('sidebar-left-hidden', 'comments-hidden');
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
this.container.classList.add('faded-out');
this.container.style.display = 'none';
document.body.classList.add('sidebar-left-hidden');
const layout = this.container.closest('.item-layout-container');
if (layout) layout.classList.add('sidebar-hidden');
const sidebar = this.container.closest('.item-sidebar-left');
if (sidebar) {
sidebar.classList.add('is-hidden');
sidebar.style.setProperty('display', 'none', 'important');
}
}
}
@@ -147,7 +130,7 @@ class CommentSystem {
let isAtBottom = currentlyAtBottom;
// Base the bottom state primarily on whether the input box / end-of-thread marker is visible
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice');
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder');
if (bottomElement) {
const bottomRect = bottomElement.getBoundingClientRect();
@@ -477,34 +460,6 @@ class CommentSystem {
}
}
_isSelfComment(data) {
if (!data) return false;
if (data.id && this._selfPostedCommentIds?.has(data.id)) return true;
if (data.body && this._pendingSelfCommentTexts?.has(data.body)) return true;
const session = window.f0ckSession || {};
const currentUserId = session.id || session.user_id;
if (currentUserId && data.user_id && parseInt(data.user_id, 10) === parseInt(currentUserId, 10)) {
return true;
}
const currentUsername = session.user || this.user;
if (currentUsername && data.username && currentUsername.toLowerCase() === data.username.toLowerCase()) {
return true;
}
if (session.is_anon && data.is_anon) {
if (data.anon_fingerprint && session.fingerprint && data.anon_fingerprint === session.fingerprint) {
return true;
}
if (data.anon_short_fingerprint && session.fingerprint && session.fingerprint.includes(data.anon_short_fingerprint)) {
return true;
}
}
return false;
}
handleLiveComment(data) {
if (!this.container || !this.itemId) return;
// 1. Check if comment belongs to this item
@@ -570,20 +525,14 @@ class CommentSystem {
// Danmaku: fire one-shot for other users' comments.
// Own comment is handled by the optimistic submit path (fire + addItem).
const isSelf = this._isSelfComment(data);
if (window.danmakuInstance && !isSelf) {
// The _loadDanmaku re-render will add this comment to the items rotation.
const currentUser = window.f0ckSession?.user;
if (window.danmakuInstance && data.username !== currentUser) {
window.danmakuInstance.fire(
data.body,
data.display_name || data.username || '?',
data.username_color || null
);
window.danmakuInstance.addItem({
id: data.id,
content: data.body,
video_time: data.video_time ?? null,
display_name: data.display_name || data.username || '?',
username_color: data.username_color || null
});
}
// Update backlinks for live comment
@@ -793,25 +742,16 @@ class CommentSystem {
}
}
// Render skeleton only if the fetch is slow. Rendering it immediately made every item switch flash an
// empty list that the real comments then replaced a moment later.
// Render skeleton (Result: Layout visible immediately)
// Skip when preserveScroll=true (tab re-focus refresh): the user already sees comments,
// so wiping the DOM causes the browser to lose the #c anchor element and auto-scroll to top.
let skeletonTimer = null;
if (!scrollToId && !preserveScroll) {
if (this.user || !this.container.querySelector('.comment-input, .lock-notice')) {
skeletonTimer = setTimeout(() => {
skeletonTimer = null;
this.render([], this.user, initialIsSubscribed);
this.restoreState(state);
}, 250);
}
this.render([], this.user, initialIsSubscribed);
this.restoreState(state);
}
const cancelSkeleton = () => { if (skeletonTimer) { clearTimeout(skeletonTimer); skeletonTimer = null; } };
try {
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
cancelSkeleton();
// If server is restarting (502/503), res.ok will be false.
if (!res.ok) throw new Error(`Server returned ${res.status}`);
@@ -924,7 +864,6 @@ class CommentSystem {
}
}
} catch (e) {
cancelSkeleton();
console.error('[CommentSystem] Error loading comments:', e);
// Catch-all for network errors, 502s, JSON parse errors (e.g. server restart)
// If initial load already finished (SSR or first fetch), just keep existing comments on screen.
@@ -1400,8 +1339,7 @@ class CommentSystem {
} else if (currentUserId) {
inputSection = this.renderInput();
} else {
// Guests can read but not comment: an inert look-alike keeps the layout identical
inputSection = this.renderGuestInput();
inputSection = '<div class="login-placeholder"><a href="/login" class="login-trigger-btn">Login</a> to comment</div>';
}
const isLegacy = document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view');
@@ -1488,7 +1426,7 @@ class CommentSystem {
});
}
const mainInput = this.container.querySelector('.main-input:not(.is-guest)');
const mainInput = this.container.querySelector('.main-input');
if (mainInput) this.setupEmojiPicker(mainInput);
// Lazy emoji load: scan the just-rendered comment text for :emoji: patterns.
@@ -2271,30 +2209,14 @@ class CommentSystem {
}
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
let bannerStyle = (bannerEnabled && comment.banner_file && comment.banner_file !== 'null')
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);
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
if (isAnonGuest) bannerStyle = '';
const avatarHtml = isAnonGuest
? `<div class="comment-avatar"><img src="/a/default.png"></div>`
: `<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>`;
const isAnon = isAnonGuest || comment.is_anon || comment.anon_fingerprint || comment.username === 'anonymous' || (comment.username && comment.username.startsWith('anon_'));
const authorHtml = isAnon
? `<span class="comment-author">anonymous</span>`
: (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>');
const anonDataAttrs = isAnon ? '' : `data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}"`;
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}>${avatarHtml}<div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" ${anonDataAttrs}>${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, isAnon ? null : comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted ? `${(currentUserId || window.f0ckAnonSSH?.pubkey) ? `<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}" ${anonDataAttrs}>#${comment.id}</a></div>${repliesHtml}`;
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
}
timeAgo(date) {
@@ -2395,24 +2317,6 @@ class CommentSystem {
</div>`;
}
// Same markup as renderInput() so every theme styles it identically, but inert: readonly, not focusable,
// no pointer events on the controls, no submit. Clicking it opens the login modal.
renderGuestInput() {
const i18n = window.f0ckI18n || {};
const placeholder = i18n.login_to_comment || 'Log in to comment';
const postLabel = i18n.post || 'Post';
return `
<div class="comment-input main-input is-guest" aria-disabled="true" title="${placeholder}"
onclick="const b=document.getElementById('nav-login-btn'); if (b) b.click();">
<textarea placeholder="${placeholder}" readonly tabindex="-1" aria-disabled="true"></textarea>
<div class="comment-file-preview"></div>
<div class="input-actions">
<button class="submit-comment" type="button" tabindex="-1" aria-disabled="true"><span class="submit-label">${postLabel}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
</div>
</div>
`;
}
renderInput(parentId = null) {
const i18n = window.f0ckI18n || {};
const session = window.f0ckSession || {};
@@ -2426,8 +2330,7 @@ class CommentSystem {
const counter = (maxLen !== null && maxLen !== undefined)
? `<span class="char-counter" data-max="${maxLen}">0 / ${maxLen}</span>`
: '';
const anonAttachmentsDisabled = session.is_anon && session.anon_permissions?.comment_attachments === false;
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments && !anonAttachmentsDisabled;
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments;
const multiFile = session.fileupload_comments_multifile;
const attachBtn = fileUploadEnabled
? `<button class="comment-attach-btn" title="${attachLabel}" type="button"><i class="fa-solid fa-paperclip"></i></button><input type="file" class="comment-file-input" accept="image/*,video/*,audio/*" ${multiFile ? 'multiple' : ''} style="display:none;">`
@@ -2772,7 +2675,7 @@ class CommentSystem {
});
} else {
// Scroll to Bottom of comments
const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice');
const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice') || this.container.querySelector('.login-placeholder');
if (bottomElement) {
bottomElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
} else {
@@ -3411,11 +3314,6 @@ class CommentSystem {
return;
}
if (submitBtn.classList.contains('loading') || submitBtn.disabled) return;
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions?.comment === false) {
if (window.flashMessage) window.flashMessage('Anonymous commenting is disabled.', 3000, 'error');
else alert('Anonymous commenting is disabled.');
return;
}
// ── Upload all staged files now (at submit time) ───────────────────────
const fileIds = [];
@@ -3508,7 +3406,6 @@ class CommentSystem {
} else {
this.isMainSubmitting = true;
}
this._pendingSelfCommentTexts.add(text);
let retryCount = 0;
const maxRetries = 20; // Allow several minutes of retrying during restart
@@ -3535,59 +3432,18 @@ class CommentSystem {
params.append('has_poll', '1');
}
let csrfToken = window.f0ckSession?.csrf_token || document.querySelector('meta[name="csrf-token"]')?.content || '';
const csrfToken = window.f0ckSession?.csrf_token || '';
if (csrfToken) params.append('csrf_token', csrfToken);
const fetchHeaders = {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
};
if ((!window.f0ckSession?.user || window.f0ckSession?.is_anon) && window.f0ckAnonSSH) {
try {
const ident = window.f0ckAnonSSH.getIdentity();
if (ident && ident.pubkey) {
const ts = Date.now();
const msg = `anon-auth:${ts}:${ident.pubkey}`;
const sig = await window.f0ckAnonSSH.sign(msg);
fetchHeaders['X-SSH-Pubkey'] = ident.pubkey;
fetchHeaders['X-SSH-Timestamp'] = String(ts);
fetchHeaders['X-SSH-Signature'] = sig;
}
} catch (e) {
console.warn('[ANON_COMMENTS] Failed to sign comment:', e);
}
}
let res = await fetch('/api/comments', {
const res = await fetch('/api/comments', {
method: 'POST',
headers: fetchHeaders,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: params
});
// Auto-recovery if CSRF token drifted
if (res.status === 403) {
const errJson = await res.clone().json().catch(() => ({}));
if (errJson.msg === 'Invalid CSRF token' || errJson.message === 'Invalid CSRF token') {
try {
const idRes = await fetch('/api/v2/anon/identity', { credentials: 'same-origin' });
const idData = await idRes.json();
if (idData && idData.csrf_token) {
if (window.f0ckSession) window.f0ckSession.csrf_token = idData.csrf_token;
const mCsrf = document.querySelector('meta[name="csrf-token"]');
if (mCsrf) mCsrf.content = idData.csrf_token;
params.set('csrf_token', idData.csrf_token);
fetchHeaders['X-CSRF-Token'] = idData.csrf_token;
res = await fetch('/api/comments', {
method: 'POST',
headers: fetchHeaders,
body: params
});
}
} catch (e) {}
}
}
if (!res.ok) {
if (res.status >= 500) {
throw new Error(`Server returned ${res.status}`);
@@ -3595,7 +3451,7 @@ class CommentSystem {
// For 4xx errors, we stop and show the error to user (likely validation or auth)
const json = await res.json().catch(() => ({}));
alert('Error: ' + (json.message || `Status ${res.status}`));
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
return;
}
@@ -3753,11 +3609,6 @@ class CommentSystem {
poll: null
};
if (json.comment?.id) {
this._selfPostedCommentIds.add(json.comment.id);
}
this._pendingSelfCommentTexts.delete(text);
// Danmaku: fire immediately (one-shot) + add to future rotation
if (window.danmakuInstance) {
window.danmakuInstance.fire(
@@ -3766,7 +3617,6 @@ class CommentSystem {
session.username_color || null
);
window.danmakuInstance.addItem({
id: newComment.id,
content: text,
video_time: newComment.video_time ?? null,
display_name: session.display_name || currentUsername || '?',
@@ -3877,10 +3727,10 @@ class CommentSystem {
}
this._silentSync();
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
} else {
alert('Error: ' + json.message);
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
}
} catch (err) {
console.warn(`[CommentSystem] Submit attempt ${retryCount + 1} failed:`, err);
@@ -3892,7 +3742,7 @@ class CommentSystem {
setTimeout(attemptSubmit, delay);
} else {
alert('Failed to send comment after multiple attempts. Please check your connection.');
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
}
}
};
@@ -3924,10 +3774,7 @@ class CommentSystem {
console.log('[ensureBtn] done, button in DOM:', !!contentEl.querySelector('.load-full-comment-btn'));
}
_finishSubmit(btn, originalHtml, parentId, submittedText = null) {
if (submittedText) {
this._pendingSelfCommentTexts?.delete(submittedText);
}
_finishSubmit(btn, originalHtml, parentId) {
if (parentId) {
this.pendingSubmissions.delete(parentId);
} else {
@@ -4030,12 +3877,7 @@ class CommentSystem {
e.preventDefault();
// If comments are hidden, show them first
const isHidden = cs.container.classList.contains('faded-out') ||
cs.container.classList.contains('is-hidden') ||
cs.container.style.display === 'none' ||
document.body.classList.contains('sidebar-left-hidden') ||
document.body.classList.contains('comments-hidden') ||
localStorage.getItem('comments_hidden') === 'true';
const isHidden = cs.container.classList.contains('faded-out') || cs.container.style.display === 'none';
if (isHidden) cs.toggleComments();
// Legacy layout: comments are in the normal page flow under .item-main-content
@@ -4116,7 +3958,7 @@ class CommentSystem {
toggleComments() {
if (!this.container) return;
if (document.body.classList.contains('layout-modern') && !document.body.classList.contains('onara-modal-open')) {
if (document.body.classList.contains('layout-modern')) {
if (window.innerWidth < 1000) return;
if (typeof window.toggleSidebarLeft === 'function') {
window.toggleSidebarLeft();
@@ -4131,138 +3973,31 @@ class CommentSystem {
] : [];
// Check if currently hidden (or slid out)
const isHidden = this.container.classList.contains('faded-out') ||
this.container.classList.contains('is-hidden') ||
this.container.style.display === 'none' ||
document.body.classList.contains('sidebar-left-hidden') ||
document.body.classList.contains('comments-hidden') ||
localStorage.getItem('comments_hidden') === 'true';
// Legacy layout: comments slide up behind the info box (and back down out of it)
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (document.body.classList.contains('layout-legacy') && typeof this.container.animate === 'function' && !reduceMotion) {
// A slide still running decides the direction, since the classes only flip when it ends
const running = this._legacySlide && this._legacySlide.el === this.container ? this._legacySlide : null;
const hiddenNow = running ? running.dir === 'hide' : isHidden;
this.slideLegacyComments(!hiddenNow, layout, sidebar, siblings);
return;
}
const isHidden = this.container.classList.contains('faded-out') || this.container.style.display === 'none';
if (isHidden) {
// SHOW: expand grid first, then slide content in
if (layout) layout.classList.remove('sidebar-hidden');
document.body.classList.remove('sidebar-left-hidden', 'comments-hidden');
this.container.classList.remove('is-hidden');
this.container.style.removeProperty('display');
document.body.classList.remove('sidebar-left-hidden');
this.container.style.display = '';
if (sidebar) {
sidebar.classList.remove('is-hidden');
sidebar.style.removeProperty('display');
sidebar.style.display = '';
}
localStorage.setItem('comments_hidden', 'false');
void this.container.offsetWidth; // force reflow so transition fires
this.container.classList.remove('faded-out');
siblings.forEach(el => el.classList.remove('faded-out'));
} else {
// HIDE: mark state immediately so quick navigations stay hidden, fade out content, then collapse
// HIDE: slide content out first, then collapse grid
localStorage.setItem('comments_hidden', 'true');
this.container.classList.add('faded-out');
siblings.forEach(el => el.classList.add('faded-out'));
setTimeout(() => {
if (!this.container.classList.contains('faded-out')) return;
this.container.classList.add('is-hidden');
this.container.style.setProperty('display', 'none', 'important');
if (sidebar) {
sidebar.classList.add('is-hidden');
sidebar.style.setProperty('display', 'none', 'important');
}
this.container.style.display = 'none';
if (layout) layout.classList.add('sidebar-hidden');
document.body.classList.add('sidebar-left-hidden', 'comments-hidden');
document.body.classList.add('sidebar-left-hidden');
}, 300);
}
}
// Legacy layout slide: the container shrinks from the bottom up while its content moves up by the same
// amount, so the comments look like they retract behind the box above them (reverse on show).
// Pressing again mid-slide turns it around from where it is.
slideLegacyComments(hide, layout, sidebar, siblings) {
const el = this.container;
const kids = Array.from(el.children);
// Curtain feel: slow, soft start, gentle settle at the end
const DURATION = 900;
const EASING = 'cubic-bezier(0.45, 0, 0.2, 1)';
let startH = null;
if (this._legacySlide) {
// Only turn around from mid-slide on the same item; a slide left over from a previous item is dropped
if (this._legacySlide.el === el) startH = el.getBoundingClientRect().height;
this._legacySlide.anims.forEach(a => a.cancel());
this._legacySlide = null;
}
if (!hide) {
// Put it back in the flow first (collapsed), so its full height can be measured
if (layout) layout.classList.remove('sidebar-hidden');
document.body.classList.remove('sidebar-left-hidden', 'comments-hidden');
// Border stays until the comments are fully down again
document.body.classList.add('comments-sliding');
el.classList.remove('is-hidden', 'faded-out');
el.style.removeProperty('display');
if (sidebar) {
sidebar.classList.remove('is-hidden');
sidebar.style.removeProperty('display');
}
siblings.forEach(s => s.classList.remove('faded-out'));
localStorage.setItem('comments_hidden', 'false');
if (startH === null) startH = 0;
} else {
// Mark state immediately so quick navigations stay hidden
localStorage.setItem('comments_hidden', 'true');
document.body.classList.add('comments-sliding');
if (startH === null) startH = el.getBoundingClientRect().height;
}
const fullH = el.scrollHeight;
const endH = hide ? 0 : fullH;
// Height + clipping go through a class and a registered custom property (see f0ckm.css): layouts
// like the Onara modal pin the container with `height: auto !important; overflow: visible
// !important`, which beats both an animated height and an inline overflow. The class rule is
// more specific and !important, and its height reads the variable the animation drives.
el.style.setProperty('--comments-slide-h', endH + 'px');
el.classList.add('is-comments-sliding');
const frames = (h0, h1) => [{ transform: 'translateY(' + (h0 - fullH) + 'px)' }, { transform: 'translateY(' + (h1 - fullH) + 'px)' }];
// A turnaround covers only part of the distance, so it takes proportionally less time (same speed)
const share = fullH > 0 ? Math.abs(endH - startH) / fullH : 1;
const opts = { duration: Math.round(DURATION * Math.max(0.3, Math.min(1, share))), easing: EASING };
const anims = [el.animate([{ '--comments-slide-h': startH + 'px' }, { '--comments-slide-h': endH + 'px' }], opts)];
kids.forEach(k => anims.push(k.animate(frames(startH, endH), opts)));
const slide = { dir: hide ? 'hide' : 'show', anims, el };
this._legacySlide = slide;
anims[0].onfinish = () => {
if (this._legacySlide !== slide) return;
this._legacySlide = null;
el.classList.remove('is-comments-sliding');
el.style.removeProperty('--comments-slide-h');
if (!hide) {
document.body.classList.remove('comments-sliding');
return;
}
el.classList.add('faded-out', 'is-hidden');
el.style.setProperty('display', 'none', 'important');
siblings.forEach(s => s.classList.add('faded-out'));
if (sidebar) {
sidebar.classList.add('is-hidden');
sidebar.style.setProperty('display', 'none', 'important');
}
if (layout) layout.classList.add('sidebar-hidden');
document.body.classList.add('sidebar-left-hidden', 'comments-hidden');
document.body.classList.remove('comments-sliding');
};
}
escapeHtml(unsafe) {
@@ -5008,28 +4743,6 @@ class CommentSystem {
}, { once: true });
};
// Bring the whole picker into view with some room below it. Measured once the enter animation has
// finished (mid-animation it is shifted up and slightly squashed, so it would look smaller than it
// is) and scrolled on whatever actually scrolls here (Onara modal, index container or the window).
const revealPicker = () => {
if (!picker || picker.style.display === 'none') return;
let done = false;
const run = () => {
if (done || !picker || picker.style.display === 'none') return;
done = true;
const MARGIN = 24;
const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(picker) : null;
const view = sc ? sc.getBoundingClientRect() : { top: 0, bottom: window.innerHeight };
const r = picker.getBoundingClientRect();
let delta = r.bottom + MARGIN - view.bottom;
// Never push the picker's own top out of view (very short viewports)
delta = Math.min(delta, r.top - view.top - 8);
if (delta > 0) (sc || window).scrollBy({ top: delta, behavior: 'smooth' });
};
picker.addEventListener('animationend', run, { once: true });
setTimeout(run, 300);
};
trigger.addEventListener('click', (e) => {
e.preventDefault();
@@ -5053,7 +4766,9 @@ class CommentSystem {
picker.offsetHeight; // reflow
picker.style.animation = '';
trigger.classList.add('is-active');
revealPicker();
requestAnimationFrame(() => requestAnimationFrame(() =>
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
));
}
return;
}
@@ -5116,7 +4831,9 @@ class CommentSystem {
}
container.appendChild(picker);
revealPicker();
requestAnimationFrame(() => requestAnimationFrame(() =>
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
));
});
}
+94 -528
View File
@@ -12,42 +12,11 @@
const PILL_MIN_MS = 6000; // Fastest a pill can cross the screen
const PILL_MAX_MS = 12000; // Slowest a pill can cross the screen
const LANE_COUNT = 10; // Vertical lane slots
const LOOKAHEAD_SEC = 0.25; // How far ahead of currentTime we look when scanning
const MIN_RANDOM_SECS = 2; // Random timecode lower bound (avoid very start)
const RANDOM_SPREAD = 0.85; // Use 85% of duration for random spread
const DEFAULT_DANMAKU_TUNING = {
fontSize: 35,
opacity: 1.0,
speedMultiplier: 1.0,
laneCount: 10,
laneCoverage: 100,
fontWeight: 700,
outlineStyle: 2, // 0=None, 1=Subtle, 2=Default Outline, 3=Neon Glow
useCustomColor: 0,
customColor: '#ffffff',
pillBackground: 0, // 0=None, 1=Glass Card, 2=Solid Capsule
allowMediaEmbeds: 1,
mediaMaxHeight: 80,
showGreentext: 1,
densityLimit: 35,
flashInterval: 2.5,
showLaneGuides: 0,
showDebugHUD: 0
};
const loadDanmakuTuning = () => {
try {
const raw = localStorage.getItem('f0ck_danmaku_tuning');
return raw ? Object.assign({}, DEFAULT_DANMAKU_TUNING, JSON.parse(raw)) : Object.assign({}, DEFAULT_DANMAKU_TUNING);
} catch (e) {
return Object.assign({}, DEFAULT_DANMAKU_TUNING);
}
};
window.DEFAULT_DANMAKU_TUNING = DEFAULT_DANMAKU_TUNING;
window.danmakuTuning = window.danmakuTuning || loadDanmakuTuning();
/**
* SyntheticClock — emulates a <video> element's time API for non-video items
* (Flash/Ruffle). Ticks at 4 Hz so Danmaku's timeupdate handler fires normally.
@@ -105,10 +74,7 @@ class Danmaku {
this.items = [];
this._lastTime = -1;
this._paused = false;
const initialLanes = Math.max(2, Math.min(30, Number(window.danmakuTuning?.laneCount) || 10));
this._laneUntil = new Array(initialLanes).fill(0);
this._laneUntil = new Array(LANE_COUNT).fill(0);
// Site-wide config default
const configDefault = (window.f0ckSession && window.f0ckSession.enable_danmaku !== undefined)
? !!window.f0ckSession.enable_danmaku
@@ -134,21 +100,12 @@ class Danmaku {
this._initEmojiCache();
this._createOverlay();
this._applyTuning();
this._bound_onTuningChange = () => this._applyTuning();
window.addEventListener('f0ck:danmaku_tuning_changed', this._bound_onTuningChange);
this.media.addEventListener('timeupdate', this._bound_onTime, { passive: true });
this.media.addEventListener('seeked', this._bound_onSeek, { passive: true });
this.media.addEventListener('pause', this._bound_onPause, { passive: true });
this.media.addEventListener('play', this._bound_onPlay, { passive: true });
// Comment markers on the progress bar need the duration
this._bound_onDuration = () => this._renderMarkers();
this._bound_onMarker = this._onMarkerClick.bind(this);
this.media.addEventListener('loadedmetadata', this._bound_onDuration, { passive: true });
this.media.addEventListener('durationchange', this._bound_onDuration, { passive: true });
// For Ruffle/SyntheticClock: no poller needed — clock runs freely
}
/**
@@ -178,12 +135,14 @@ class Danmaku {
window.addEventListener('f0ck:emojis_ready', this._bound_onEmojis);
// Aggressive retry: try every 500 ms for up to 30 attempts.
// Each attempt checks CommentSystem first (free), then falls back to a fetch.
let attempts = 0;
let fetched = false;
const retry = () => {
if (this._emojiCache && Object.keys(this._emojiCache).length > 0) return;
if (this._emojiCache && Object.keys(this._emojiCache).length > 0) return; // already got them
if (++attempts > 30) return;
// 1. CommentSystem populated by now?
const cs = tryCs();
if (cs) {
this._emojiCache = cs;
@@ -191,6 +150,7 @@ class Danmaku {
return;
}
// 2. Kick off the HTTP fetch once; then just wait for it / the event
if (!fetched) {
fetched = true;
fetch('/api/v2/emojis')
@@ -209,13 +169,14 @@ class Danmaku {
})
.catch(err => {
console.warn('[Danmaku] emoji fetch failed:', err.message);
fetched = false;
fetched = false; // allow retry
});
}
// Schedule next check
if (!this._destroyed) setTimeout(retry, 500);
};
setTimeout(retry, 200);
setTimeout(retry, 200); // first attempt after a short grace window
}
// ── Public API ────────────────────────────────────────────────────────────
@@ -234,9 +195,7 @@ class Danmaku {
const mapped = comments
.filter(c => !c.is_deleted && c.content)
.map(c => ({
id: c.id || null,
text: this._prepareText(c.content),
raw: c.content,
username: c.display_name || c.username || '?',
color: c.username_color || null,
raw_time: (c.video_time != null) ? parseFloat(c.video_time) : null,
@@ -245,11 +204,14 @@ class Danmaku {
}));
if (this._synthClock) {
// Flash/Ruffle mode: bypass the timeline entirely.
// Store pool and start the random continuous loop.
this._flashPool = mapped;
this._startFlashLoop();
return;
return; // don't touch this.items / _lastTime
}
// Normal video mode: use video_time, random spread for nulls
this.items = mapped.map(item => {
if (item.raw_time !== null) {
item.video_time = item.raw_time;
@@ -264,18 +226,20 @@ class Danmaku {
this._resetFiredState(this.media.currentTime);
this._lastTime = this.media.currentTime;
this._renderMarkers();
}
/**
* Random continuous loop for Flash/Ruffle.
* Fires one comment every 2-5 s (random), reshuffles pool on exhaustion.
*/
_startFlashLoop() {
// Cancel any previous loop
if (this._flashTimer) clearTimeout(this._flashTimer);
this._flashTimer = null;
if (!this._flashPool || this._flashPool.length === 0) return;
// Shuffle helper
const shuffle = arr => {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -284,12 +248,14 @@ class Danmaku {
return arr;
};
// Working queue — randomised copy of pool
let queue = shuffle([...this._flashPool]);
let idx = 0;
const tick = () => {
if (this._destroyed || !this._enabled) return;
// Refill and reshuffle when queue exhausted
if (idx >= queue.length) {
queue = shuffle([...this._flashPool]);
idx = 0;
@@ -298,12 +264,12 @@ class Danmaku {
const item = queue[idx++];
this._spawnPill(item.text, item.username, item.color);
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
const baseInterval = Math.max(0.4, Number(cfg.flashInterval) || 2.5);
const delay = (baseInterval * 1000) + Math.random() * (baseInterval * 1200);
// Random delay 2 – 5 seconds between pills
const delay = 2000 + Math.random() * 3000;
this._flashTimer = setTimeout(tick, delay);
};
// Small initial delay so page finishes loading before first pill
this._flashTimer = setTimeout(tick, 800);
}
@@ -317,10 +283,11 @@ class Danmaku {
/**
* Add a new comment to the timeline so it loops back in future playback.
* Also fires it immediately as a one-shot pill.
* @param {Object} comment — raw comment object from API
*/
addItem(comment) {
if (!comment || !comment.content) return;
if (comment.id && this.items && this.items.some(i => i.id === comment.id)) return;
const duration = this.media.duration;
const hasDuration = isFinite(duration) && duration > 0;
@@ -340,38 +307,26 @@ class Danmaku {
}
const item = {
id: comment.id || null,
video_time: t,
text: this._prepareText(comment.content),
raw: comment.content,
raw_time: (comment.video_time != null) ? parseFloat(comment.video_time) : null,
username: comment.display_name || comment.username || '?',
color: comment.username_color || null,
fired: true
fired: true // mark as already fired — caller handles any immediate one-shot
};
if (this._synthClock && this._flashPool) {
if (!this._flashPool.some(i => (comment.id && i.id === comment.id) || (i.text === item.text && i.username === item.username))) {
this._flashPool.push(item);
}
return;
}
// Insert in sorted order
const idx = this.items.findIndex(i => i.video_time > t);
if (idx === -1) this.items.push(item);
else this.items.splice(idx, 0, item);
this._renderMarkers();
}
/** Toggle danmaku on/off. */
toggle() {
this._enabled = !this._enabled;
localStorage.setItem('danmaku', this._enabled ? 'true' : 'false');
if (this.overlay) this.overlay.style.display = this._enabled ? '' : 'none';
// Comment markers on the progress bar belong to danmaku: hidden while it is off
if (this._markerLayer) this._markerLayer.style.display = this._enabled ? '' : 'none';
if (!this._enabled) this._hideMarkerPreview();
this.overlay.style.display = this._enabled ? '' : 'none';
// Update the switch if it exists in the player
const sw = this.player.querySelector('#toggledanmaku');
if (sw) sw.classList.toggle('active', this._enabled);
}
@@ -383,129 +338,13 @@ class Danmaku {
isEnabled() { return this._enabled; }
toggleLoop(forcedVal, options = {}) {
this._loopMode = (forcedVal !== undefined) ? !!forcedVal : !this._loopMode;
if (this._loopMode) {
this.items.forEach(i => { i.fired = false; });
this._startDebugContinuousLoop(options.interval || 220, options.customText, options.username, options.color);
} else {
this._stopDebugContinuousLoop();
}
return this._loopMode;
}
isLooping() {
return !!this._loopMode;
}
_getSampleComments() {
return [
{ text: 'Danmaku burst test! :kreygasm:', username: 'BurstUser', color: '#ffcc00' },
{ text: '>be me\n>browsing f0ck\n>feels good man', username: 'Anon', color: '#78b87a' },
{ text: '[spoiler]Secret Classified Spoiler[/spoiler]', username: 'Agent007', color: '#ff5577' },
{ text: '[blur]Sensitive content blur reveal[/blur]', username: 'ModUser', color: '#bb77ff' },
{ text: 'Testing flight speed & collision avoidance 🚀', username: 'DevTester', color: '#00e5ff' },
{ text: '弾幕 Nico Nico style flying comment', username: 'Otaku', color: '#ff88aa' },
{ text: 'Super high density bullet stream! ⚡⚡', username: 'HyperUser', color: '#99ff00' },
{ text: 'FeelsGoodMan :feelsgood:', username: 'Pepe', color: '#55cc55' },
{ text: 'Nice visualizer and danmaku sync! 🔥', username: 'Vibes', color: '#ff6600' },
{ text: '>mfw danmaku is running infinitely :dance_fart:', username: 'F0cker', color: '#78b87a' }
];
}
_startDebugContinuousLoop(interval = 220, customText = null, username = null, color = null) {
if (this._debugLoopTimer) clearInterval(this._debugLoopTimer);
let idx = 0;
const tick = () => {
if (this._destroyed || !this._enabled || !this._loopMode) {
this._stopDebugContinuousLoop();
return;
}
if (customText) {
this._spawnPill(customText, username || 'LoopTester', color || '#00ffcc');
} else {
let pool = (this.items && this.items.length > 0) ? this.items : (this._flashPool || []);
if (!pool || pool.length === 0) {
pool = this._getSampleComments();
}
if (idx >= pool.length) {
idx = 0;
pool.forEach(i => { i.fired = false; });
}
const item = pool[idx++];
if (item) {
this._spawnPill(item.text, item.username || 'Tester', item.color || null);
}
}
};
tick();
this._debugLoopTimer = setInterval(tick, Math.max(40, interval));
}
_stopDebugContinuousLoop() {
if (this._debugLoopTimer) {
clearInterval(this._debugLoopTimer);
this._debugLoopTimer = null;
}
}
clearActivePills() {
if (!this.overlay) return;
this.overlay.querySelectorAll('.danmaku-pill').forEach(p => p.remove());
}
resetTimeline() {
if (this._synthClock) {
this._loopSynth();
} else {
this._resetFiredState(this.media ? this.media.currentTime : 0);
this._lastTime = this.media ? this.media.currentTime : 0;
}
}
fireBurst(count = 10, options = {}) {
const isFlood = count > 15;
const interval = isFlood ? 100 : 160;
if (this._loopMode) {
this._startDebugContinuousLoop(interval, options.text, options.username, options.color);
return;
}
const samples = this._getSampleComments();
for (let i = 0; i < count; i++) {
setTimeout(() => {
if (this._destroyed || !this._enabled) return;
const sample = samples[i % samples.length];
const text = options.text ? `${options.text} #${i + 1}` : `${sample.text} #${i + 1}`;
const user = options.username || sample.username;
const col = options.color || sample.color;
this.fire(text, user, col);
}, i * interval);
}
}
destroy() {
this._destroyed = true;
this._stopDebugContinuousLoop();
this.media.removeEventListener('timeupdate', this._bound_onTime);
this.media.removeEventListener('seeked', this._bound_onSeek);
this.media.removeEventListener('pause', this._bound_onPause);
this.media.removeEventListener('play', this._bound_onPlay);
this.media.removeEventListener('loadedmetadata', this._bound_onDuration);
this.media.removeEventListener('durationchange', this._bound_onDuration);
if (this._markerLayer && this._markerLayer.parentNode) this._markerLayer.parentNode.removeChild(this._markerLayer);
this._markerLayer = null;
if (this._markerTip && this._markerTip.parentNode) this._markerTip.parentNode.removeChild(this._markerTip);
this._markerTip = null;
if (this._bound_onEmojis) window.removeEventListener('f0ck:emojis_ready', this._bound_onEmojis);
if (this._bound_onTuningChange) window.removeEventListener('f0ck:danmaku_tuning_changed', this._bound_onTuningChange);
if (this._laneGuidesTimer) clearInterval(this._laneGuidesTimer);
if (this._hudTimer) clearInterval(this._hudTimer);
if (this._rufflePoller) clearInterval(this._rufflePoller);
if (this._flashTimer) clearTimeout(this._flashTimer);
if (this._synthClock) this._synthClock.destroy();
@@ -522,273 +361,22 @@ class Danmaku {
this.player.appendChild(this.overlay);
}
_applyTuning() {
if (!this.overlay) return;
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
this.overlay.style.setProperty('--danmaku-font-size', (cfg.fontSize || 35) + 'px');
this.overlay.style.setProperty('--danmaku-opacity', cfg.opacity !== undefined ? cfg.opacity : 1);
this.overlay.style.setProperty('--danmaku-font-weight', cfg.fontWeight || 700);
this.overlay.style.setProperty('--danmaku-color', Number(cfg.useCustomColor) === 1 ? (cfg.customColor || '#ffffff') : '#ffffff');
this.overlay.style.setProperty('--danmaku-media-max-h', (cfg.mediaMaxHeight || 80) + 'px');
this.overlay.classList.toggle('danmaku-bg-glass', Number(cfg.pillBackground) === 1);
this.overlay.classList.toggle('danmaku-bg-capsule', Number(cfg.pillBackground) === 2);
this.overlay.classList.toggle('danmaku-glow-none', Number(cfg.outlineStyle) === 0);
this.overlay.classList.toggle('danmaku-glow-subtle', Number(cfg.outlineStyle) === 1);
this.overlay.classList.toggle('danmaku-glow-neon', Number(cfg.outlineStyle) === 3);
this.overlay.classList.toggle('danmaku-no-greentext', Number(cfg.showGreentext) === 0);
const targetLanes = Math.max(2, Math.min(30, Number(cfg.laneCount) || 10));
if (this._laneUntil.length !== targetLanes) {
const old = this._laneUntil;
this._laneUntil = new Array(targetLanes).fill(0);
for (let i = 0; i < Math.min(old.length, targetLanes); i++) {
this._laneUntil[i] = old[i];
}
}
this._updateLaneGuides();
this._updateDebugHUD();
}
_updateLaneGuides() {
if (!this.overlay) return;
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
let guides = this.overlay.querySelector('.danmaku-lane-guides-container');
if (!cfg.showLaneGuides) {
if (guides) guides.remove();
if (this._laneGuidesTimer) {
clearInterval(this._laneGuidesTimer);
this._laneGuidesTimer = null;
}
return;
}
if (!guides) {
guides = document.createElement('div');
guides.className = 'danmaku-lane-guides-container';
this.overlay.appendChild(guides);
}
const laneCount = this._laneUntil.length || 10;
const coverage = Math.min(100, Math.max(10, Number(cfg.laneCoverage) || 100)) / 100;
const laneH = (100 * coverage) / laneCount;
let html = '';
const now = Date.now();
for (let i = 0; i < laneCount; i++) {
const topPct = i * laneH;
const isOccupied = (this._laneUntil[i] || 0) > now;
const remainingSec = isOccupied ? (((this._laneUntil[i] - now) / 1000).toFixed(1) + 's') : 'FREE';
html += `
<div class="danmaku-lane-guide ${isOccupied ? 'occupied' : ''}" style="top: ${topPct}%; height: ${laneH}%;">
<span class="danmaku-lane-badge">L${i} (${topPct.toFixed(0)}%)</span>
<span class="danmaku-lane-status">${remainingSec}</span>
</div>
`;
}
guides.innerHTML = html;
if (!this._laneGuidesTimer) {
this._laneGuidesTimer = setInterval(() => {
if (this._destroyed || !this.overlay || !window.danmakuTuning?.showLaneGuides) {
if (this._laneGuidesTimer) clearInterval(this._laneGuidesTimer);
this._laneGuidesTimer = null;
return;
}
this._updateLaneGuides();
}, 300);
}
}
_updateDebugHUD() {
if (!this.overlay) return;
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
let hud = this.overlay.querySelector('.danmaku-debug-hud');
if (!cfg.showDebugHUD) {
if (hud) hud.remove();
if (this._hudTimer) {
clearInterval(this._hudTimer);
this._hudTimer = null;
}
return;
}
if (!hud) {
hud = document.createElement('div');
hud.className = 'danmaku-debug-hud';
this.overlay.appendChild(hud);
}
const activePills = this.overlay.querySelectorAll('.danmaku-pill').length;
const totalItems = this.items.length || (this._flashPool ? this._flashPool.length : 0);
const firedItems = this.items.filter(i => i.fired).length;
const curTime = (this.media ? this.media.currentTime : 0).toFixed(1);
const emojiCount = Object.keys(this._emojiCache || {}).length;
hud.innerHTML = `
<div class="hud-title"><i class="fa-solid fa-bolt"></i> Danmaku Debug HUD</div>
<div class="hud-row"><span>Active Pills:</span><span class="hud-val accent">${activePills} / ${cfg.densityLimit || 35}</span></div>
<div class="hud-row"><span>Loaded Comments:</span><span class="hud-val">${totalItems} (fired: ${firedItems})</span></div>
<div class="hud-row"><span>Clock Time:</span><span class="hud-val">${curTime}s ${this._synthClock ? '(Synth)' : ''}</span></div>
<div class="hud-row"><span>Lanes:</span><span class="hud-val">${this._laneUntil.length} (${cfg.laneCoverage || 100}%)</span></div>
<div class="hud-row"><span>Speed Multiplier:</span><span class="hud-val">${cfg.speedMultiplier || 1.0}x</span></div>
<div class="hud-row"><span>Emoji Cache:</span><span class="hud-val">${emojiCount} emojis</span></div>
`;
if (!this._hudTimer) {
this._hudTimer = setInterval(() => {
if (this._destroyed || !this.overlay || !window.danmakuTuning?.showDebugHUD) {
if (this._hudTimer) clearInterval(this._hudTimer);
this._hudTimer = null;
return;
}
this._updateDebugHUD();
}, 250);
}
}
// ── Progress-bar comment markers ─────────────────────────────────────────
// One marker per timeline comment on .v0ck_progress. Comments with their own timestamp are solid;
// the others got a random time on load and are drawn fainter. Clicking jumps there and shows the pill.
_renderMarkers() {
if (this._destroyed || this._synthClock) return;
const track = this.player.querySelector('.v0ck_progress');
if (!track) return;
const dur = this.media.duration;
const ok = isFinite(dur) && dur > 0 && this.items.length > 0;
let layer = this._markerLayer;
if (!ok) { if (layer) layer.textContent = ''; return; }
if (!layer || !layer.isConnected) {
layer = document.createElement('div');
layer.className = 'danmaku-markers';
if (!this._enabled) layer.style.display = 'none';
// Keep the bar's own scrub handlers (mousedown/pointerdown/click on the track) out of marker clicks
const stop = (e) => e.stopPropagation();
layer.addEventListener('pointerdown', stop);
layer.addEventListener('mousedown', stop);
layer.addEventListener('touchstart', stop, { passive: true });
layer.addEventListener('click', this._bound_onMarker);
layer.addEventListener('mouseover', (e) => {
const b = e.target.closest && e.target.closest('.danmaku-marker');
if (b) this._showMarkerPreview(b);
});
layer.addEventListener('mouseout', (e) => {
const to = e.relatedTarget;
if (!to || !to.closest || !to.closest('.danmaku-marker')) this._hideMarkerPreview();
});
track.appendChild(layer);
this._markerLayer = layer;
}
const fmt = (t) => {
const m = Math.floor(t / 60), sec = Math.floor(t % 60);
return m + ':' + String(sec).padStart(2, '0');
};
const frag = document.createDocumentFragment();
this.items.forEach((item, i) => {
if (!(item.video_time >= 0) || item.video_time > dur) return;
const b = document.createElement('button');
b.type = 'button';
b.tabIndex = -1;
b.className = 'danmaku-marker' + (item.raw_time == null ? ' is-random' : '');
b.style.left = (item.video_time / dur * 100) + '%';
b.dataset.idx = String(i);
const txt = String(item.raw || '').replace(/\s+/g, ' ').trim();
b.setAttribute('aria-label', fmt(item.video_time) + ' · ' + item.username + ': ' + (txt.length > 80 ? txt.slice(0, 80) + '…' : txt));
frag.appendChild(b);
});
this._hideMarkerPreview();
layer.textContent = '';
layer.appendChild(frag);
}
// Plain-text preview of a comment above its marker. Built with textContent only (no HTML from comments).
_showMarkerPreview(b) {
const item = this.items[+b.dataset.idx];
if (!item) return;
let tip = this._markerTip;
if (!tip || !tip.isConnected) {
tip = document.createElement('div');
tip.className = 'danmaku-marker-preview';
tip.innerHTML = '<div class="dmp-head"><span class="dmp-time"></span><span class="dmp-user"></span></div><div class="dmp-text"></div>';
this.player.appendChild(tip);
this._markerTip = tip;
}
const t = item.video_time;
tip.querySelector('.dmp-time').textContent = Math.floor(t / 60) + ':' + String(Math.floor(t % 60)).padStart(2, '0');
const user = tip.querySelector('.dmp-user');
user.textContent = item.username;
user.style.color = item.color || '';
// Same renderer as the flying pills: emojis, images/media embeds, greentext; spoiler and blur
// stay hidden. Builds DOM nodes (no HTML from the comment); size is capped by CSS.
const body = tip.querySelector('.dmp-text');
body.textContent = '';
const liveCache = (this._emojiCache && Object.keys(this._emojiCache).length > 0)
? this._emojiCache
: ((typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null);
if (liveCache && liveCache !== this._emojiCache) this._emojiCache = liveCache;
body.appendChild(this._renderContent(item.raw || ''));
if (!body.firstChild) body.textContent = '…';
// Reposition once images have their size
body.querySelectorAll('img').forEach(img => {
if (!img.complete) img.addEventListener('load', () => { if (this._markerTip === tip && tip.classList.contains('is-visible')) this._showMarkerPreview(b); }, { once: true });
});
// Position: centred over the marker, just above the progress bar, kept inside the player
const pr = this.player.getBoundingClientRect();
const mr = b.getBoundingClientRect();
tip.style.visibility = 'hidden';
tip.classList.add('is-visible');
const w = tip.offsetWidth;
let left = mr.left + mr.width / 2 - pr.left - w / 2;
left = Math.max(6, Math.min(left, pr.width - w - 6));
tip.style.left = left + 'px';
tip.style.bottom = (pr.bottom - mr.top + 8) + 'px';
tip.style.visibility = '';
}
_hideMarkerPreview() {
if (this._markerTip) this._markerTip.classList.remove('is-visible');
}
_onMarkerClick(e) {
const b = e.target.closest && e.target.closest('.danmaku-marker');
if (!b) return;
e.preventDefault();
e.stopPropagation();
const item = this.items[+b.dataset.idx];
if (!item) return;
// Land just past the comment: the seek reset then counts it as fired, so the timeline won't
// spawn it a second time; it is shown right here instead (also while paused)
const dur = this.media.duration;
const t = Math.min(isFinite(dur) ? dur : Infinity, item.video_time + LOOKAHEAD_SEC + 0.05);
try { this.media.currentTime = t; } catch (_) {}
item.fired = true;
this._hideMarkerPreview();
this._spawnPill(item.text, item.username, item.color, true);
}
_onPause() {
this._paused = true;
if (this._synthClock) this._synthClock.pause();
// Do NOT pause pill animations — pills already in flight always complete.
}
_onPlay() {
this._paused = false;
if (this._synthClock) this._synthClock.resume();
// Nothing to do for in-flight pills — they were never paused.
}
_checkRuffleState() {
const rp = document.querySelector('ruffle-player, ruffle-object');
if (!rp) return;
// Ruffle exposes is_playing on the element
const isPlaying = rp.is_playing !== undefined ? !!rp.is_playing : true;
if (isPlaying && this._paused) this._onPlay();
if (!isPlaying && !this._paused) this._onPause();
@@ -802,39 +390,33 @@ class Danmaku {
const prev = this._lastTime;
this._lastTime = now;
// Detect video loop (time jumped backwards) — reset so comments fire again
if (now < prev - 0.5) {
this._resetFiredState(now);
return;
}
// Forward jump (seek, marker click, scrubbing): the timeupdate at the new position arrives before
// 'seeked', and treating the gap as played would fire every comment in between. Resync instead,
// so only comments from here on appear. Normal playback advances ~0.25s per event (x playbackRate).
const maxStep = Math.max(1.5, 1.5 * (this.media.playbackRate || 1));
if (this.media.seeking || now > prev + maxStep) {
this._resetFiredState(now);
return;
}
const from = prev;
const to = now + LOOKAHEAD_SEC;
for (const item of this.items) {
if (item.fired) continue;
if (item.video_time < from) { item.fired = true; continue; }
if (item.video_time > to) break;
if (item.video_time < from) { item.fired = true; continue; } // already passed
if (item.video_time > to) break; // sorted, nothing further in range
item.fired = true;
this._spawnPill(item.text, item.username, item.color);
}
// Loop for SyntheticClock (Flash/Ruffle): once all items have fired, restart
if (this._synthClock && this.items.length > 0 && this.items.every(i => i.fired)) {
this._loopSynth();
}
}
/** Reset all fired flags and the synthetic clock for looping. */
_loopSynth() {
this.items.forEach(i => { i.fired = false; });
this._synthClock.reset();
this._synthClock.reset(); // back to t=0
this._lastTime = 0;
}
@@ -851,51 +433,38 @@ class Danmaku {
_pickLane() {
const now = Date.now();
const laneCount = this._laneUntil.length || 10;
// Find the lane that will be free soonest
let best = 0;
let bestFree = this._laneUntil[0] || 0;
for (let i = 1; i < laneCount; i++) {
if ((this._laneUntil[i] || 0) < bestFree) {
let bestFree = this._laneUntil[0];
for (let i = 1; i < LANE_COUNT; i++) {
if (this._laneUntil[i] < bestFree) {
bestFree = this._laneUntil[i];
best = i;
}
}
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
const speedMult = Math.max(0.2, Number(cfg.speedMultiplier) || 1.0);
const maxMs = PILL_MAX_MS / speedMult;
this._laneUntil[best] = now + maxMs;
// Occupy the lane — use the max duration so slower pills don't get overwritten
this._laneUntil[best] = now + PILL_MAX_MS;
return best;
}
/** @param {boolean} [force] show even while paused (marker click on the progress bar) */
_spawnPill(text, username, color, force = false) {
if (!this.overlay || !this._enabled || (this._paused && !force)) return;
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
const limit = Number(cfg.densityLimit) || 35;
const currentPills = this.overlay.querySelectorAll('.danmaku-pill');
if (currentPills.length >= limit) {
if (currentPills[0]) currentPills[0].remove();
}
_spawnPill(text, username, color) {
if (!this.overlay || !this._enabled || this._paused) return;
const pill = document.createElement('div');
pill.className = 'danmaku-pill';
// Lane assignment — distribute vertically to avoid full overlap
const lane = this._pickLane();
const laneCount = this._laneUntil.length || 10;
const coverage = Math.min(100, Math.max(10, Number(cfg.laneCoverage) || 100)) / 100;
const laneH = (100 * coverage) / laneCount;
const topPct = lane * laneH + (laneH * 0.1);
const laneH = 100 / LANE_COUNT;
const topPct = lane * laneH + (laneH * 0.1); // slight inset
pill.style.top = topPct + '%';
if (Number(cfg.useCustomColor) === 1 && cfg.customColor) {
pill.style.color = cfg.customColor;
}
// Message content — store raw text for deferred emoji re-render
const msg = document.createElement('span');
msg.className = 'dpill-text';
msg.dataset.rawText = text;
// Read the freshest emoji source available at spawn time
const liveCache = (this._emojiCache && Object.keys(this._emojiCache).length > 0)
? this._emojiCache
: ((typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null);
@@ -903,23 +472,29 @@ class Danmaku {
msg.appendChild(this._renderContent(text));
// Track pill for deferred re-render if emojis weren't ready yet
if (!this._emojiCache || Object.keys(this._emojiCache).length === 0) {
if (!this._pendingPills) this._pendingPills = new Set();
this._pendingPills.add(msg);
}
pill.appendChild(msg);
// Insert paused so we can measure before animation fires
this.overlay.appendChild(pill);
// Duration scales with text length so long comments get enough time to cross.
// Formula: 5s base + 25ms per character, clamped to [6s, 45s].
const charCount = text.length;
const speedMult = Math.max(0.2, Number(cfg.speedMultiplier) || 1.0);
const baseDuration = Math.min(Math.max(5000 + charCount * 25, PILL_MIN_MS), 45_000);
const duration = baseDuration / speedMult;
const duration = Math.min(Math.max(5000 + charCount * 25, PILL_MIN_MS), 45_000);
// Use actual scroll (content) width — wider than offsetWidth for very long lines.
// This ensures the animation pixel travel is enough for ALL content to exit left,
// not just the max-width-capped pill box.
const overlayW = this.overlay.offsetWidth || window.innerWidth || 1920;
const contentW = pill.scrollWidth || pill.offsetWidth || 200;
const startX = overlayW + contentW;
const endX = -(contentW + 200);
const startX = overlayW + contentW; // off-screen right
const endX = -(contentW + 200); // fully off-screen left, overflow included
const anim = pill.animate(
[
@@ -929,20 +504,23 @@ class Danmaku {
{ duration, easing: 'linear', fill: 'none' }
);
// Remove pill once animation completes
anim.addEventListener('finish', () => {
if (pill.parentNode) pill.parentNode.removeChild(pill);
}, { once: true });
// Failsafe in case the Animations API finish event doesn't fire
pill._timeoutId = setTimeout(() => { if (pill.parentNode) pill.parentNode.removeChild(pill); }, duration + 1000);
}
/** Re-render pending pills once emojis are available. */
_reRenderEmojis() {
if (!this.overlay) return;
// Prefer direct element tracking (fast, no DOM query needed)
const pending = this._pendingPills;
if (pending && pending.size > 0) {
pending.forEach(msg => {
if (!msg.parentNode) { pending.delete(msg); return; }
if (!msg.parentNode) { pending.delete(msg); return; } // already removed
const raw = msg.dataset.rawText;
if (!raw) return;
msg.textContent = '';
@@ -950,9 +528,11 @@ class Danmaku {
});
pending.clear();
}
// Also sweep any pills that slipped through (belt-and-suspenders)
this.overlay.querySelectorAll('.dpill-text[data-raw-text]').forEach(msg => {
const raw = msg.dataset.rawText;
if (!raw) return;
// Only re-render if the content is still plain text (no img children)
if (!msg.querySelector('.dpill-emoji')) {
msg.textContent = '';
msg.appendChild(this._renderContent(raw));
@@ -969,8 +549,10 @@ class Danmaku {
const prepared = this._prepareText(rawText);
const frag = document.createDocumentFragment();
const cache = this._emojiCache;
const cache = this._emojiCache; // always use full cache in danmaku
// Process line by line — leading > lines become greentext
// Filter empty lines to avoid ghost rows from trailing newlines
const lines = prepared.split('\n').filter(l => l.trim() !== '');
lines.forEach((line) => {
const isQuote = /^>\s?/.test(line);
@@ -987,8 +569,10 @@ class Danmaku {
/**
* Renders inline content (spoiler/blur/emoji) into a parent node.
* Prepends a space before the first text chunk for visual separation.
*/
_renderInline(text, parent, emojiCache) {
// match[1]=spoiler, match[2]=blur, match[3]=emoji, match[4]=inline-img-url
const combined = /\[spoiler\]([\s\S]*?)\[\/spoiler\]|\[blur\]([\s\S]*?)\[\/blur\]|:([a-z0-9_+\-]+):|\x04([^\x05]+)\x05/gi;
let lastIndex = 0;
let match;
@@ -1003,14 +587,14 @@ class Danmaku {
span.className = 'dpill-spoiler';
span.title = 'Click to reveal spoiler';
span.addEventListener('click', (e) => { e.stopPropagation(); span.classList.toggle('revealed'); });
this._renderInline(match[1], span, emojiCache);
this._renderInline(match[1], span, emojiCache); // recursive so emojis inside spoilers work
parent.appendChild(span);
} else if (match[2] !== undefined) {
const span = document.createElement('span');
span.className = 'dpill-blur';
span.title = 'Click to reveal';
span.addEventListener('click', (e) => { e.stopPropagation(); span.classList.toggle('revealed'); });
this._renderInline(match[2], span, emojiCache);
this._renderInline(match[2], span, emojiCache); // recursive so emojis inside blur work
parent.appendChild(span);
} else if (match[3]) {
const code = match[3];
@@ -1040,17 +624,12 @@ class Danmaku {
}
} else if (match[4]) {
const mediaUrl = match[4];
const isConvertedGif = mediaUrl.endsWith('#gif');
const cleanUrl = mediaUrl.replace(/#gif$/, '');
const videoExts = /\.(?:mp4|webm|ogv|mov)$/i;
const audioExts = /\.(?:mp3|ogg|wav|flac|aac|opus|m4a)$/i;
const cfg = window.danmakuTuning || DEFAULT_DANMAKU_TUNING;
if (Number(cfg.allowMediaEmbeds) === 0) {
const span = document.createElement('span');
span.className = 'dpill-media-placeholder';
span.textContent = ' [attachment] ';
parent.appendChild(span);
} else if (videoExts.test(cleanUrl)) {
if (videoExts.test(cleanUrl)) {
const vid = document.createElement('video');
vid.src = cleanUrl;
vid.className = 'dpill-video';
@@ -1085,21 +664,17 @@ class Danmaku {
_prepareText(text) {
if (!text) return '';
// Idempotent: pills prepare the text on load and _renderContent prepares it again. Media already
// wrapped as \x04url\x05 is kept as one token; re-matching the URL inside it used to double-wrap
// it and leave a stray \x05 that browsers draw as a little control-character box behind the image.
const imgTokenUrls = [];
// Protect emoji codes from the bold/italic underscore regex.
// e.g. `:dance_fart: :dance_fart:` would have its underscores eaten
// when the regex pairs the _ from the first code with the _ in the second.
const emojiTokens = [];
let protected_ = text
.replace(/\x04([^\x04\x05]+)\x05/g, (_, url) => {
imgTokenUrls.push(url);
return `\x04${imgTokenUrls.length - 1}\x05`;
})
.replace(/:([a-z0-9_+\-]+):/gi, (match) => {
emojiTokens.push(match);
return `\x02${emojiTokens.length - 1}\x03`;
});
let protected_ = text.replace(/:([a-z0-9_+\-]+):/gi, (match) => {
emojiTokens.push(match);
return `\x02${emojiTokens.length - 1}\x03`; // private-use delimiters
});
// Tokenize image URLs with \x04URL\x05 so they survive all regexes and reach _renderInline
// Only embed images from the allowed-images allowlist (window.f0ckAllowedImages) or same site
const allowedHosts = Array.isArray(window.f0ckAllowedImages) ? window.f0ckAllowedImages : [];
const siteHost = window.location.hostname;
const isAllowedImg = (url) => {
@@ -1108,15 +683,18 @@ class Danmaku {
return h === siteHost || allowedHosts.some(a => h === a || h.endsWith('.' + a));
} catch { return false; }
};
const imgTokenUrls = [];
protected_ = protected_
// Tokenize relative /c/ media URLs (comment attachments)
.replace(/\/c\/[a-f0-9]+\.(?:png|jpg|jpeg|gif|webp|svg|avif|mp4|webm|ogv|mov|mp3|ogg|wav|flac|aac|opus|m4a)(?:#gif)?/gi, (url) => {
imgTokenUrls.push(url);
return `\x04${imgTokenUrls.length - 1}\x05`;
})
// Stop at protocol boundaries so concatenated URLs aren't merged into one broken src.
.replace(/https?:\/\/(?:(?!https?:\/\/)\S)+\.(?:png|jpg|jpeg|gif|webp|svg|avif)(\?(?:(?!https?:\/\/)\S)*)?/gi, (url) => {
if (!isAllowedImg(url)) return url;
if (!isAllowedImg(url)) return url; // disallowed image URLs stay as plain text
imgTokenUrls.push(url);
return `\x04${imgTokenUrls.length - 1}\x05`;
return `\x04${imgTokenUrls.length - 1}\x05`; // numeric index placeholder
})
.replace(/```[\s\S]*?```/g, '[code]')
.replace(/`[^`]+`/g, match => match.slice(1, -1))
@@ -1124,31 +702,19 @@ class Danmaku {
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^#{1,6}\s+/gm, '')
.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1')
// Normalize \r\n → \n but keep line breaks for greentext
.replace(/\r\n?/g, '\n')
// Collapse 3+ blank lines to 2
.replace(/\n{3,}/g, '\n\n')
.trim();
// Any other control characters (from the comment itself) would render as visible boxes; strays
// of the token markers too. Valid media tokens are parked on \x0E/\x0F (already stripped) meanwhile.
// Restore emoji codes, then image URL tokens
return protected_
.replace(/[\x00-\x01\x06-\x08\x0B-\x1F\x7F]/g, '')
.replace(/\x02(\d+)\x03/g, (_, i) => emojiTokens[+i] || '')
.replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? '\x0E' + i + '\x0F' : '')
.replace(/[\x02-\x05]/g, '')
.replace(/\x0E(\d+)\x0F/g, (_, i) => '\x04' + imgTokenUrls[+i] + '\x05');
.replace(/\x04(\d+)\x05/g, (_, i) => imgTokenUrls[+i] ? `\x04${imgTokenUrls[+i]}\x05` : '');
}
}
Danmaku.applyGlobalTuning = function(newCfg) {
if (newCfg) {
Object.assign(window.danmakuTuning, newCfg);
try {
localStorage.setItem('f0ck_danmaku_tuning', JSON.stringify(window.danmakuTuning));
} catch (e) {}
}
window.dispatchEvent(new CustomEvent('f0ck:danmaku_tuning_changed', { detail: window.danmakuTuning }));
};
window.Danmaku = Danmaku;
window.SyntheticClock = SyntheticClock;
+4 -49
View File
@@ -7,47 +7,10 @@
const dragModalClose = document.getElementById('drag-modal-close');
const dragForm = dragModal ? dragModal.querySelector('.upload-form') : null;
const navUploadLink = document.getElementById('nav-upload-link');
// Dropdown placement: right under the navbar "Upload" link, left-aligned with it and kept inside the
// window. Without a visible link (phone: it sits in the collapsed menu) it drops from the navbar.
const positionModal = () => {
if (!dragModal || !dragModal.classList.contains('show')) return;
const content = dragModal.querySelector('.modal-content');
if (!content) return;
const vw = document.documentElement.clientWidth;
const linkRect = navUploadLink ? navUploadLink.getBoundingClientRect() : null;
const useLink = !!(linkRect && linkRect.width > 0 && linkRect.height > 0);
const nav = document.querySelector('body > nav.navbar, nav.navbar');
const top = Math.round((useLink ? linkRect.bottom : (nav ? nav.getBoundingClientRect().bottom : 0)) + 6);
content.style.top = top + 'px';
content.style.maxHeight = Math.max(200, window.innerHeight - top - 12) + 'px';
const w = content.offsetWidth;
let left = useLink ? linkRect.left : (vw - w) / 2;
left = Math.max(8, Math.min(left, vw - w - 8));
content.style.left = Math.round(left) + 'px';
};
window.addEventListener('resize', positionModal, { passive: true });
// The navbar link shows when the dropdown is open. Watched on the modal itself because it gets closed
// from several places (close button, Escape, toggle, finished upload, hideAllModals).
if (dragModal && navUploadLink) {
const syncLink = () => {
const open = dragModal.classList.contains('show');
navUploadLink.classList.toggle('is-active', open);
navUploadLink.setAttribute('aria-expanded', open ? 'true' : 'false');
};
new MutationObserver(syncLink).observe(dragModal, { attributes: true, attributeFilter: ['class'] });
syncLink();
}
const navEl = document.querySelector('body > nav.navbar, nav.navbar');
if (navEl && window.ResizeObserver) new ResizeObserver(positionModal).observe(navEl);
const showModal = () => {
if (!dragModal) return;
dragModal.classList.add('show', 'is-dropdown');
dragModal.classList.add('show');
document.body.classList.add('modal-open');
positionModal();
// Reset scroll position so it always starts at the top
dragModal.scrollTop = 0;
const modalContent = dragModal.querySelector('.modal-content');
@@ -56,22 +19,14 @@
if (modalBody) modalBody.scrollTop = 0;
};
// Hide without resetting: a picked file / typed tags survive, reopening continues there.
// (The close button and Escape still close + reset, see below.)
const hideModal = () => {
if (!dragModal) return;
dragModal.classList.remove('show');
document.body.classList.remove('modal-open');
};
// Navbar upload link — always attached so it works even if drag-drop can't init.
// Toggles the dropdown when available; falls back to /upload navigation otherwise.
// Opens the modal when available; falls back to /upload navigation otherwise.
const navUploadLink = document.getElementById('nav-upload-link');
if (navUploadLink) {
navUploadLink.addEventListener('click', (e) => {
if (!dragModal) return; // no modal → fall back to href navigation
e.preventDefault();
if (dragModal.classList.contains('show')) hideModal();
else showModal();
showModal();
});
}
+1359 -15170
View File
File diff suppressed because it is too large Load Diff
+45 -160
View File
@@ -25,35 +25,11 @@
const isMobile = /Mobi/i.test(navigator.userAgent);
function isItemPage() {
if (document.body?.classList.contains('scroller-active') ||
document.getElementById('scroller-feed') ||
/^\/scroller(\/|$)/.test(window.location.pathname)) {
return false;
}
// Fast DOM check: item view or primary media element present
if (document.getElementById('my-video') || document.querySelector('#main.item-view, .item-view, .item-layout-container, .item-main-content')) {
return true;
}
const path = window.location.pathname;
const isForbidden = /^\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|mod|comments|notifications|feed|upload|tags|halls|ranking|abyss|random|scroller)(\/|$)/.test(path);
if (isForbidden) return false;
const segments = path.split('/').filter(Boolean);
if (segments.length === 0) return false;
// Path ends in /p/123 -> pagination grid, not single item
const last = segments[segments.length - 1];
if (/^\d+$/.test(last) && segments.length >= 2 && segments[segments.length - 2] === 'p') {
return false;
}
if (['p', 'tags', 'halls', 'ranking', 'abyss', 'uploads', 'favs', 'f0cks'].includes(last)) {
return false;
}
// Single item path: e.g. /123, /ZLHmYNnj-kK, /tag/foo/ZLHmYNnj-kK, /h/bar/123, etc.
return segments.some(s => /^[a-zA-Z0-9_-]{11}$/.test(s) || /^\d+$/.test(s));
// Strictly match item pages (e.g., /123, /user/name/123) and exclude grids/specials
const isItem = (path.match(/^\/\d+/) || path.split('/').some(s => /^\d+$/.test(s))) && !path.match(/\/p\//);
const isForbidden = path === '/upload' || path.startsWith('/admin') || path.startsWith('/mod');
return isItem && !isForbidden;
}
// ---------- Settings / Config ----------
@@ -259,12 +235,11 @@
applyConfigToCanvas();
function drawFrame(force = false) {
if (!enabled || (!force && (video.paused || video.ended))) return;
function drawFrame() {
if (!enabled || video.paused || video.ended) return;
try {
const w = canvas.width;
const h = canvas.height;
if (!w || !h) return;
ctx.drawImage(video, 0, 0, w, h);
// If advanced palette reduction is disabled, skip quantization
@@ -274,7 +249,7 @@
ctx.putImageData(frame, 0, 0);
}
} catch (e) {
if (window.f0ckDebug) window.f0ckDebug("[flash_yank] drawFrame error:", e);
// ignore
}
}
@@ -296,8 +271,6 @@
enabled = true;
canvas.style.display = 'block';
video.style.visibility = 'hidden'; // Keep layout space!
applyConfigToCanvas();
drawFrame(true);
if (!video.paused && !video.ended) startLoop();
}
@@ -323,38 +296,22 @@
stopLoop();
applyConfigToCanvas();
if (wasEnabled) {
drawFrame(true);
if (!video.paused && !video.ended) {
startLoop();
}
startLoop();
}
}
const handleFrameUpdate = () => {
if (enabled) drawFrame(true);
};
const handleMetaLoaded = () => {
applyConfigToCanvas();
if (enabled) drawFrame(true);
};
function destroy() {
disable();
canvas.remove();
video.removeEventListener('play', startLoop);
video.removeEventListener('pause', stopLoop);
video.removeEventListener('ended', stopLoop);
video.removeEventListener('seeked', handleFrameUpdate);
video.removeEventListener('loadeddata', handleMetaLoaded);
video.removeEventListener('loadedmetadata', handleMetaLoaded);
}
video.addEventListener('play', startLoop);
video.addEventListener('pause', stopLoop);
video.addEventListener('ended', stopLoop);
video.addEventListener('seeked', handleFrameUpdate);
video.addEventListener('loadeddata', handleMetaLoaded);
video.addEventListener('loadedmetadata', handleMetaLoaded);
video.addEventListener('loadedmetadata', applyConfigToCanvas); // Recalculate when metadata allows
return { enable, disable, toggle, isEnabled, destroy, onConfigChanged };
}
@@ -362,18 +319,13 @@
function setupVideo(video) {
if (!video) return;
// Ignore sidebar sticker / emoji preview / modal videos or scroller feed
if (video.closest && video.closest('.sidebar-activity, .global-sidebar-right, .emoji-preview, .modal, #scroller-feed, .scroll-slide')) {
return;
}
if (!isItemPage()) {
if (ui) ui.wrapper.style.display = 'none';
return;
}
// Prioritize the main item player (id="my-video" or class "viewer")
const isPrimary = video.id === 'my-video' || video.classList.contains('viewer') || video.classList.contains('v0ck_video') || (video.closest && !!video.closest('.media-object, .v0ck'));
const isPrimary = video.id === 'my-video' || video.classList.contains('viewer') || video.classList.contains('v0ck_video');
if (video.dataset.flashFilterAttached === '1') {
// If already attached, ensure its currentController is restored if it's the primary one
@@ -572,10 +524,7 @@
const bottom = rect.bottom + HOVER_MARGIN;
if (e.clientX < left || e.clientX > right || e.clientY < top || e.clientY > bottom) {
const overTrigger = e.target.closest && (e.target.closest('#toggleswf') || e.target === floatingBadge);
if (!overTrigger) {
hidePanel();
}
hidePanel();
}
});
@@ -594,30 +543,33 @@
info
};
slider.addEventListener('input', (e) => setYank(e.target.value));
slider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10);
settings.yank = isNaN(val) ? 0 : Math.min(100, Math.max(0, val));
// Yank drives the underlying advanced parameters
updateAdvancedFromYank();
saveSettings();
applySettingsToRuntime();
});
function toggleEnabledFromUI(targetController) {
settings.enabled = !settings.enabled;
saveSettings();
let controller = targetController || currentController;
if (!controller) {
const mainVid = document.getElementById('my-video') || document.querySelector('video.v0ck_video, video.viewer, video');
if (mainVid) {
if (!mainVid.__flashFilterController) setupVideo(mainVid);
controller = mainVid.__flashFilterController;
}
}
// If a specific controller was the target (e.g. clicked inside a player), use it.
// Otherwise use the global currentController (main player).
const controller = targetController || currentController;
if (controller) {
if (settings.enabled) controller.enable();
else controller.disable();
}
// For global consistency, if settings.enabled changed, we might want to toggle ALL?
// But per user request, we focus on the item player.
// If there's another video that isn't the currentController, it won't toggle here,
// but the hotkey and UI rely on currentController.
updateUIFromSettings();
if (typeof window.flashMessage === 'function') {
window.flashMessage(`Flash Yank ${settings.enabled ? 'enabled' : 'disabled'}`, 2000, settings.enabled ? 'success' : 'info');
}
}
// Click SWF badge or title to toggle filter enabled/disabled
@@ -629,18 +581,15 @@
// If clicked a button inside a player, try to get THAT player's controller
let targetCtrl = null;
if (swfBtn) {
const player = swfBtn.closest('.v0ck') || swfBtn.closest('.media-object') || document.querySelector('.v0ck');
const vid = player ? player.querySelector('video') : (document.getElementById('my-video') || document.querySelector('video'));
if (vid) {
if (!vid.__flashFilterController) {
setupVideo(vid);
}
const player = swfBtn.closest('.v0ck');
const vid = player ? player.querySelector('video') : null;
if (vid && vid.__flashFilterController) {
targetCtrl = vid.__flashFilterController;
}
}
toggleEnabledFromUI(targetCtrl);
// Explicitly show options panel on click for both mobile and desktop
if (swfBtn || floatingBadge) {
// On mobile, explicitly show panel on click/tap
if (isMobile) {
handleBadgeHover(swfBtn || floatingBadge);
}
}
@@ -683,23 +632,21 @@
ui.slider.disabled = !isEnabled;
// Visual state: strike-through when disabled
const swfButtons = Array.from(document.querySelectorAll('#toggleswf, .v0ck_menu_item')).filter(b => b.id === 'toggleswf' || b.textContent.trim() === 'SWF');
const swfButtons = Array.from(document.querySelectorAll('.v0ck_menu_item')).filter(b => b.textContent.trim() === 'SWF');
// Handle floating badge visibility — only show as fallback when on an item page
// with the primary player present but no in-player SWF button (e.g. v0ck not loaded).
// Never show it just because sidebar .webm stickers exist.
const primaryVideo = document.getElementById('my-video') ||
document.querySelector('video.viewer, video.v0ck_video');
// Retired: the settings live in the sidebar Tuner (Flash Yank tab) now, so no floating fallback badge
void primaryVideo;
ui.floatingBadge.style.display = 'none';
ui.floatingBadge.style.display = (swfButtons.length === 0 && !!primaryVideo) ? 'block' : 'none';
// Style both (if they exist)
[ui.floatingBadge, ...swfButtons].forEach(b => {
if (!b) return;
b.style.textDecoration = isEnabled ? 'none' : 'line-through';
b.style.opacity = isEnabled ? '1' : '0.6';
if (b.classList.contains('v0ck_menu_item') || b.id === 'toggleswf') {
if (b.classList.contains('v0ck_menu_item')) {
b.style.color = isEnabled ? 'var(--accent, #9f0)' : '#fff';
b.style.fontWeight = isEnabled ? 'bold' : 'normal';
}
@@ -713,11 +660,6 @@
hotkeyAttached = true;
document.addEventListener('keydown', (e) => {
if (document.body?.classList.contains('scroller-active') ||
document.getElementById('scroller-feed') ||
/^\/scroller(\/|$)/.test(window.location.pathname)) {
return;
}
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || !isItemPage()) return;
if (e.key.toLowerCase() !== 's') return;
@@ -725,78 +667,21 @@
const tag = document.activeElement?.tagName?.toLowerCase();
if (tag === 'input' || tag === 'textarea' || document.activeElement?.isContentEditable) return;
if (!currentController) {
const mainVid = document.getElementById('my-video') || document.querySelector('video.v0ck_video, video.viewer, video');
if (mainVid) {
if (!mainVid.__flashFilterController) setupVideo(mainVid);
currentController = mainVid.__flashFilterController;
}
}
if (!currentController) return;
setEnabled(!settings.enabled);
settings.enabled = !settings.enabled;
if (settings.enabled) currentController.enable();
else currentController.disable();
saveSettings();
updateUIFromSettings();
if (typeof window.flashMessage === 'function') {
window.flashMessage(`Flash Yank ${settings.enabled ? 'enabled' : 'disabled'}`, 2000, settings.enabled ? 'success' : 'success');
}
});
}
// ---------- Public API (sidebar Tuner → Flash Yank) ----------
// The in-player "SWF" button is gone; the Tuner pane drives the filter through this API and
// listens to 'f0ck:flashyank_changed' (also fired by the 's' hotkey) to stay in sync.
function findController() {
if (currentController) return currentController;
const mainVid = document.getElementById('my-video') || document.querySelector('video.v0ck_video, video.viewer, video');
if (!mainVid) return null;
if (!mainVid.__flashFilterController) setupVideo(mainVid);
return mainVid.__flashFilterController || null;
}
function getState() {
return {
enabled: !!settings.enabled,
yank: settings.yank,
width: currentConfig.internalWidth,
fps: currentConfig.fps,
colors: Math.pow(currentConfig.paletteLevels, 3),
available: isItemPage() && document.querySelectorAll('video').length > 0
};
}
function emitChange() {
window.dispatchEvent(new CustomEvent('f0ck:flashyank_changed', { detail: getState() }));
}
function setEnabled(on, { silent = false } = {}) {
settings.enabled = !!on;
saveSettings();
const controller = findController();
if (controller) {
if (settings.enabled) controller.enable();
else controller.disable();
}
updateUIFromSettings();
emitChange();
if (!silent && typeof window.flashMessage === 'function') {
window.flashMessage('Flash Yank ' + (settings.enabled ? 'enabled' : 'disabled'), 2000, settings.enabled ? 'success' : 'info');
}
}
function setYank(value) {
const v = parseInt(value, 10);
settings.yank = isNaN(v) ? 0 : Math.min(100, Math.max(0, v));
updateAdvancedFromYank();
saveSettings();
applySettingsToRuntime();
emitChange();
}
window.f0ckFlashYank = {
getState,
setEnabled: (on) => setEnabled(on),
toggle: () => setEnabled(!settings.enabled),
setYank,
defaults: { yank: DEFAULT_SETTINGS.yank }
};
// ---------- Bootstrapping ----------
function onReady(fn) {
-552
View File
@@ -1,552 +0,0 @@
/**
* Laser cursor: the mouse pointer becomes a glowing laser dot with a smooth, tapering, fading trail.
* - hover over something clickable: a ring opens around the dot
* - press: the dot squeezes; release: a shockwave ring
* - drag (button held while moving): the beam gets thicker and hotter
* - text fields keep the native text cursor; leaving the window or an idle video player fades it out
*
* One transparent, pointer-events:none canvas over the page, drawn only while something moves
* (the rAF loop stops when the trail is gone and all animations have settled).
* Mouse/trackpad only; off for touch devices and prefers-reduced-motion.
*
* Everything is configurable (tuner "Laser" tab): window.f0ckLaser = { getState, set, setEnabled,
* reset, defaults, schema }, settings in localStorage 'f0ck_laser_settings', 'f0ck:laser_changed' event.
*/
(() => {
const STORE_KEY = 'f0ck_laser_settings';
const LEGACY_KEY = 'f0ck_laser_cursor'; // old on/off switch
const DEFAULTS = {
enabled: 1,
useAccent: 1, // 1 = theme accent colour, 0 = custom colour below
color: '#99ff00',
hotCore: 1, // white-hot centre on the beam and dot
rainbow: 0, // colour cycles along the trail
rainbowSpeed: 90, // degrees per second
trailMs: 260, // how long a trail point lives
trailPoints: 64, // max points kept (smoothness of long trails)
beamWidth: 5,
glowWidth: 16,
glowStrength: 1,
headSize: 3.2,
headGlow: 22,
hoverRing: 15,
rippleSize: 36,
rippleMs: 480,
dragBoost: 0.7,
};
// Tuner rows (the tuner builds its UI from this)
const SCHEMA = [
{ section: 'Colour' },
{ key: 'useAccent', label: 'Use theme accent colour', type: 'toggle' },
{ key: 'color', label: 'Custom colour', type: 'color' },
{ key: 'hotCore', label: 'White-hot core', type: 'toggle' },
{ key: 'rainbow', label: 'Rainbow beam', type: 'toggle' },
{ key: 'rainbowSpeed', label: 'Rainbow speed', type: 'range', min: 0, max: 720, step: 10, unit: '°/s' },
{ section: 'Trail' },
{ key: 'trailMs', label: 'Trail length', type: 'range', min: 0, max: 1500, step: 10, unit: 'ms' },
{ key: 'trailPoints', label: 'Trail smoothness', type: 'range', min: 8, max: 240, step: 1, unit: ' pts' },
{ key: 'beamWidth', label: 'Beam width', type: 'range', min: 0.5, max: 16, step: 0.5, unit: 'px' },
{ key: 'glowWidth', label: 'Glow width', type: 'range', min: 0, max: 60, step: 1, unit: 'px' },
{ key: 'glowStrength', label: 'Glow strength', type: 'range', min: 0, max: 3, step: 0.05, unit: 'x' },
{ section: 'Dot' },
{ key: 'headSize', label: 'Dot size', type: 'range', min: 0, max: 12, step: 0.1, unit: 'px' },
{ key: 'headGlow', label: 'Dot glow radius', type: 'range', min: 0, max: 80, step: 1, unit: 'px' },
{ key: 'hoverRing', label: 'Hover ring size', type: 'range', min: 0, max: 50, step: 1, unit: 'px' },
{ section: 'Click & drag' },
{ key: 'rippleSize', label: 'Shockwave size', type: 'range', min: 0, max: 160, step: 1, unit: 'px' },
{ key: 'rippleMs', label: 'Shockwave duration', type: 'range', min: 100, max: 2000, step: 10, unit: 'ms' },
{ key: 'dragBoost', label: 'Drag boost', type: 'range', min: 0, max: 3, step: 0.05, unit: 'x' },
];
const CLICKABLE = 'a[href], button, [role="button"], label, select, summary, [onclick], .iconset, .thumb, ' +
'.album-thumb-item, .rating-tag.can-cycle, .tag-btn, input[type="range"], input[type="checkbox"], ' +
'input[type="radio"], input[type="button"], input[type="submit"], .v0ck_player_button';
const TEXT_FIELD = 'textarea, [contenteditable=""], [contenteditable="true"], ' +
'input:not([type="range"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="color"]):not([type="file"])';
const mq = (q) => !!(window.matchMedia && window.matchMedia(q).matches);
const supported = () => mq('(hover: hover) and (pointer: fine)') && !mq('(prefers-reduced-motion: reduce)');
// ── Settings ────────────────────────────────────────────────────────────────
const S = Object.assign({}, DEFAULTS);
try {
const saved = JSON.parse(localStorage.getItem(STORE_KEY) || 'null');
if (saved && typeof saved === 'object') Object.keys(DEFAULTS).forEach(k => { if (saved[k] !== undefined) S[k] = saved[k]; });
else if (localStorage.getItem(LEGACY_KEY) === 'false') S.enabled = 0;
} catch (_) {}
const save = () => { try { localStorage.setItem(STORE_KEY, JSON.stringify(S)); } catch (_) {} };
const coerce = (key, val) => {
if (typeof DEFAULTS[key] === 'string') return String(val);
const n = Number(val);
return isNaN(n) ? DEFAULTS[key] : n;
};
// ── State ───────────────────────────────────────────────────────────────────
let canvas = null, ctx = null, W = 0, H = 0;
let active = false, rafId = 0;
const pts = []; // trail points {x, y, t}
const ripples = []; // {x, y, t}
let headX = -100, headY = -100, hasPos = false;
let visible = 0, visibleTarget = 0; // fade (window leave, text fields, idle player)
let ring = 0, ringTarget = 0; // hover ring radius
let squeeze = 1, squeezeTarget = 1; // press squeeze
let heat = 0, heatTarget = 0; // drag intensity (0..1)
let down = false, downX = 0, downY = 0;
let lastTarget = null;
let hovering = false; // last hover classification said "clickable"
let accentRgb = [153, 255, 0];
let customRgb = [153, 255, 0];
let lastColorCheck = 0;
const cssColorToRgb = (raw) => {
if (!document.body) return null;
const probe = document.createElement('span');
probe.style.color = raw;
probe.style.display = 'none';
document.body.appendChild(probe);
const m = getComputedStyle(probe).color.match(/\d+(\.\d+)?/g);
probe.remove();
return (m && m.length >= 3) ? [+m[0], +m[1], +m[2]] : null;
};
const readColors = () => {
const raw = getComputedStyle(document.body || document.documentElement).getPropertyValue('--accent').trim() || '#9f0';
accentRgb = cssColorToRgb(raw) || accentRgb;
customRgb = cssColorToRgb(S.color) || customRgb;
};
// Colour at a position along the trail (0 = tail .. 1 = head)
const colAt = (a, pos, now) => {
if (S.rainbow) {
const hue = ((now / 1000) * S.rainbowSpeed + (1 - pos) * 140) % 360;
return 'hsla(' + hue.toFixed(1) + ',100%,60%,' + a.toFixed(3) + ')';
}
const c = S.useAccent ? accentRgb : customRgb;
return 'rgba(' + c[0] + ',' + c[1] + ',' + c[2] + ',' + a.toFixed(3) + ')';
};
const core = (a, pos, now) => S.hotCore ? 'rgba(255,255,255,' + a.toFixed(3) + ')' : colAt(a, pos, now);
const resize = () => {
if (!canvas) return;
// 1.5x is plenty for a soft glow and keeps the backing store small (Firefox composites it every frame)
const dpr = Math.min(1.5, window.devicePixelRatio || 1);
W = window.innerWidth; H = window.innerHeight;
prevBox = null;
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
kick();
};
// Native cursor: hidden everywhere by CSS (html.laser-cursor-on, see f0ckm.css), so it can never
// show, not even for a frame. To still know which cursor an element WOULD show, the element and its
// ancestors are briefly marked [data-laser-probe] (the hiding rule skips marked elements), the
// computed cursor is read and the marks are removed, all synchronously, so nothing is ever painted.
// The laser then draws its own version of that cursor (cursorKind).
let nativeOnly = false; // fullscreen on a bare <video>/<iframe>: the laser can't be drawn there
let cursorKind = 'default', kindSince = 0;
const KIND = {
// 'none' counts as default: an idle player hides its cursor, but our pointermove runs before the
// player's mousemove marks it hovered; hiding over the player is decided per frame in classify()
auto: 'default', default: 'default', none: 'default', pointer: 'pointer',
text: 'text', 'vertical-text': 'text',
grab: 'grab', grabbing: 'grabbing', move: 'move', 'all-scroll': 'move',
'not-allowed': 'no', 'no-drop': 'no',
wait: 'wait', progress: 'wait',
'zoom-in': 'zoomin', 'zoom-out': 'zoomout', help: 'help', crosshair: 'cross', cell: 'cross',
'ew-resize': 'rh', 'e-resize': 'rh', 'w-resize': 'rh', 'col-resize': 'rh',
'ns-resize': 'rv', 'n-resize': 'rv', 's-resize': 'rv', 'row-resize': 'rv',
'nwse-resize': 'rd1', 'nw-resize': 'rd1', 'se-resize': 'rd1',
'nesw-resize': 'rd2', 'ne-resize': 'rd2', 'sw-resize': 'rd2',
};
const PROBE = 'data-laser-probe';
const readCursor = (el) => {
const chain = [];
for (let e = el; e && e.nodeType === 1; e = e.parentElement) { e.setAttribute(PROBE, ''); chain.push(e); }
let c = 'auto';
try { c = getComputedStyle(el).cursor; } catch (_) {}
for (const e of chain) e.removeAttribute(PROBE);
return c;
};
// Probing forces a style recalc (costly in Firefox), so each element's kind is cached for a second;
// clicks and content loads clear the cache (classes that change the cursor usually come with those)
let kindCache = new WeakMap();
const KIND_TTL = 1000;
const takeOverCursor = (el, fresh = false) => {
if (!el || el.nodeType !== 1) return;
const now = performance.now();
const hit = !fresh && kindCache.get(el);
let kind;
if (hit && now - hit.t < KIND_TTL) {
kind = hit.kind;
} else {
// Computed value may be "url(...), pointer": the keyword fallback is the last entry
const raw = readCursor(el).split(',').pop().trim();
kind = KIND[raw] || 'default';
if (kind === 'default' && el.closest(TEXT_FIELD)) kind = 'text';
kindCache.set(el, { kind, t: now });
}
if (kind !== cursorKind) { cursorKind = kind; kindSince = now; }
};
// Hover state from the element under the pointer: clickable -> ring, text field / idle player -> hide
// The player goes idle (hides its controls and cursor) on a timer while the mouse rests, when no laser
// frames run: re-check every 400ms, only while the pointer is over a player
let playerWatch = 0;
const watchPlayer = (on) => {
if (on && !playerWatch) {
playerWatch = setInterval(() => { if (lastTarget && lastTarget.isConnected) { classify(lastTarget); kick(); } }, 400);
} else if (!on && playerWatch) {
clearInterval(playerWatch);
playerWatch = 0;
}
};
const classify = (el) => {
if (!el || !el.closest) { hovering = false; ringTarget = 0; watchPlayer(false); return; }
watchPlayer(active && !!el.closest('.v0ck'));
const idlePlayer = !!el.closest('.v0ck:not(.v0ck_hover)');
visibleTarget = idlePlayer ? 0 : 1;
// Ring for clickables; not when the laser shows a different cursor shape there
const shaped = cursorKind !== 'default' && cursorKind !== 'pointer';
hovering = !shaped && (cursorKind === 'pointer' || !!el.closest(CLICKABLE));
ringTarget = hovering ? S.hoverRing : 0;
};
const onMove = (e) => {
if (e.pointerType && e.pointerType !== 'mouse') return;
const now = performance.now();
const evs = (typeof e.getCoalescedEvents === 'function' && e.getCoalescedEvents().length) ? e.getCoalescedEvents() : [e];
// Firefox delivers many coalesced points: keep one per ~2px, more gives no visible smoothness
if (S.trailMs > 0) {
for (const c of evs) {
const last = pts[pts.length - 1];
if (last && Math.abs(last.x - c.clientX) < 2 && Math.abs(last.y - c.clientY) < 2) { last.t = now; continue; }
pts.push({ x: c.clientX, y: c.clientY, t: now });
}
}
if (pts.length > S.trailPoints) pts.splice(0, pts.length - S.trailPoints);
headX = e.clientX; headY = e.clientY;
if (!hasPos) { hasPos = true; visible = 0; }
if (e.target !== lastTarget) { lastTarget = e.target; takeOverCursor(e.target); classify(e.target); }
if (down && Math.hypot(headX - downX, headY - downY) > 4) heatTarget = 1;
kick();
};
const onDown = (e) => {
if (e.pointerType && e.pointerType !== 'mouse') return;
down = true; downX = e.clientX; downY = e.clientY;
squeezeTarget = 0.55;
kick();
};
const onUp = (e) => {
if (!down) return;
down = false;
squeezeTarget = 1;
heatTarget = 0;
if (visibleTarget > 0 && S.rippleSize > 0 && e && e.clientX !== undefined) {
ripples.push({ x: e.clientX, y: e.clientY, t: performance.now() });
}
// A click often swaps the content under a mouse that doesn't move (AJAX navigation, modals):
// re-read what is under the laser a moment later
setTimeout(reprobe, 120);
kick();
};
// Re-read the element under the laser without a mouse move (content changed underneath it)
const reprobe = () => {
if (!active || !hasPos) return;
const el = document.elementFromPoint(headX, headY);
if (!el) return;
kindCache = new WeakMap();
lastTarget = el;
takeOverCursor(el, true);
classify(el);
kick();
};
const onLeave = (e) => { if (!e.relatedTarget) { visibleTarget = 0; kick(); } };
const onEnter = () => { visibleTarget = 1; if (lastTarget) classify(lastTarget); kick(); };
// Tapered, smoothed trail: segments between midpoints (quadratic through each point), each a bit
// thinner and more transparent toward the tail end. Wide soft pass first, core on top.
const drawTrail = (now) => {
while (pts.length && now - pts[0].t > S.trailMs) pts.shift();
const n = pts.length;
if (n < 2 || S.trailMs <= 0) return;
const widthMul = 1 + heat * S.dragBoost;
const glowA = 0.22 * S.glowStrength * (0.8 + heat * 0.4);
for (let pass = 0; pass < 2; pass++) {
if (pass === 0 && (S.glowWidth <= 0 || S.glowStrength <= 0)) continue;
for (let i = 1; i < n; i++) {
const p0 = pts[i - 1], p1 = pts[i];
const age = (now - p1.t) / S.trailMs; // 0 = fresh .. 1 = gone
const pos = i / (n - 1); // 0 = tail .. 1 = head
const k = Math.max(0, Math.min(pos, 1 - age));
if (k <= 0.01) continue;
const prev = i > 1 ? pts[i - 2] : p0;
ctx.beginPath();
ctx.moveTo((prev.x + p0.x) / 2, (prev.y + p0.y) / 2);
ctx.quadraticCurveTo(p0.x, p0.y, (p0.x + p1.x) / 2, (p0.y + p1.y) / 2);
if (pass === 0) {
ctx.strokeStyle = colAt(Math.min(1, glowA * k * visible), pos, now);
ctx.lineWidth = S.glowWidth * widthMul * (0.25 + 0.75 * k);
} else {
ctx.strokeStyle = (k > 0.6) ? core(0.9 * k * visible, pos, now) : colAt(0.95 * k * visible, pos, now);
ctx.lineWidth = S.beamWidth * widthMul * (0.15 + 0.85 * k);
}
ctx.stroke();
}
// Close the last gap up to the actual pointer position
const last = pts[n - 1];
ctx.beginPath();
ctx.moveTo((pts[n - 2].x + last.x) / 2, (pts[n - 2].y + last.y) / 2);
ctx.lineTo(last.x, last.y);
ctx.strokeStyle = pass === 0 ? colAt(Math.min(1, glowA * visible), 1, now) : core(0.9 * visible, 1, now);
ctx.lineWidth = pass === 0 ? S.glowWidth * widthMul : S.beamWidth * widthMul;
ctx.stroke();
}
};
const drawHead = (now) => {
if (!hasPos || visible < 0.01) return;
const s = squeeze * (1 + heat * 0.25 * S.dragBoost);
if (S.headGlow > 0 && S.glowStrength > 0) {
const gr = S.headGlow * s;
const g = ctx.createRadialGradient(headX, headY, 0, headX, headY, gr);
g.addColorStop(0, colAt(Math.min(1, 0.55 * S.glowStrength * visible), 1, now));
g.addColorStop(0.35, colAt(Math.min(1, 0.22 * S.glowStrength * visible), 1, now));
g.addColorStop(1, colAt(0, 1, now));
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(headX, headY, gr, 0, Math.PI * 2); ctx.fill();
}
drawGlyph(now);
// The dot only stays with shapes that have room around it; the others carry detail in the centre
const dotKinds = { default: 1, pointer: 1, grab: 1, grabbing: 1, wait: 1, none: 1 };
if (S.headSize > 0 && dotKinds[cursorKind]) {
ctx.fillStyle = core(visible, 1, now);
ctx.beginPath(); ctx.arc(headX, headY, S.headSize * s, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.arc(headX, headY, S.headSize * 1.45 * s, 0, Math.PI * 2);
ctx.lineWidth = 1.5; ctx.strokeStyle = colAt(0.9 * visible, 1, now); ctx.stroke();
}
if (ring > 0.5 && S.hoverRing > 0) {
const ra = visible * Math.min(1, ring / S.hoverRing);
ctx.beginPath(); ctx.arc(headX, headY, ring * (0.8 + 0.2 * squeeze), 0, Math.PI * 2);
ctx.lineWidth = 1.5; ctx.strokeStyle = colAt(0.85 * ra, 1, now); ctx.stroke();
ctx.lineWidth = 6; ctx.strokeStyle = colAt(0.12 * ra * S.glowStrength, 1, now); ctx.stroke();
}
};
// Laser versions of the native cursors. Each shape is stroked twice: a wide faint glow and a thin core.
const drawGlyph = (now) => {
const kind = (cursorKind === 'grab' && down) ? 'grabbing' : cursorKind;
if (kind === 'default' || kind === 'pointer' || kind === 'none') return;
const a = visible * Math.min(1, (now - kindSince) / 140);
if (a < 0.01) return;
const x = headX, y = headY;
const L = (x1, y1, x2, y2) => { ctx.moveTo(x + x1, y + y1); ctx.lineTo(x + x2, y + y2); };
const C = (r, a0 = 0, a1 = Math.PI * 2) => { ctx.moveTo(x + r * Math.cos(a0), y + r * Math.sin(a0)); ctx.arc(x, y, r, a0, a1); };
const arrow = (dx, dy, len) => { // double-headed arrow along (dx, dy)
const hx = dx * len, hy = dy * len, px = -dy * 4, py = dx * 4, bx = dx * 4, by = dy * 4;
L(-hx, -hy, hx, hy);
L(hx, hy, hx - bx + px, hy - by + py); L(hx, hy, hx - bx - px, hy - by - py);
L(-hx, -hy, -hx + bx + px, -hy + by + py); L(-hx, -hy, -hx + bx - px, -hy + by - py);
};
const shape = () => {
ctx.beginPath();
switch (kind) {
case 'text': L(0, -10, 0, 10); L(-4, -10, 4, -10); L(-4, 10, 4, 10); break;
case 'grab': C(10); break;
case 'grabbing': C(6); break;
case 'move': arrow(1, 0, 10); arrow(0, 1, 10); break;
case 'rh': arrow(1, 0, 11); break;
case 'rv': arrow(0, 1, 11); break;
case 'rd1': arrow(0.7071, 0.7071, 11); break;
case 'rd2': arrow(0.7071, -0.7071, 11); break;
case 'no': C(9); L(-6.4, 6.4, 6.4, -6.4); break;
case 'wait': { const t = now / 160; C(9, t, t + Math.PI * 1.4); break; }
case 'zoomin': C(9); L(-4, 0, 4, 0); L(0, -4, 0, 4); break;
case 'zoomout': C(9); L(-4, 0, 4, 0); break;
case 'help': C(9); break;
case 'cross': L(-11, 0, -3, 0); L(3, 0, 11, 0); L(0, -11, 0, -3); L(0, 3, 0, 11); break;
}
};
ctx.setLineDash(kind === 'grab' ? [3, 3] : []);
shape();
ctx.lineWidth = 6; ctx.strokeStyle = colAt(Math.min(1, 0.15 * a * S.glowStrength), 1, now); ctx.stroke();
ctx.lineWidth = 1.8; ctx.strokeStyle = core(0.95 * a, 1, now); ctx.stroke();
ctx.setLineDash([]);
if (kind === 'help') {
ctx.font = 'bold 11px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillStyle = core(0.95 * a, 1, now); ctx.fillText('?', x, y + 0.5);
}
};
const drawRipples = (now) => {
for (let i = ripples.length - 1; i >= 0; i--) {
const r = ripples[i];
const p = (now - r.t) / S.rippleMs;
if (p >= 1) { ripples.splice(i, 1); continue; }
const e = 1 - Math.pow(1 - p, 3); // ease-out
const a = (1 - p) * (1 - p);
ctx.beginPath(); ctx.arc(r.x, r.y, 4 + e * S.rippleSize, 0, Math.PI * 2);
ctx.lineWidth = 2 * (1 - p) + 0.5; ctx.strokeStyle = colAt(0.9 * a, 1, now); ctx.stroke();
ctx.lineWidth = 10 * (1 - p); ctx.strokeStyle = colAt(Math.min(1, 0.15 * a * S.glowStrength), 1, now); ctx.stroke();
}
};
const ease = (cur, target, k) => (Math.abs(target - cur) < 0.001 ? target : cur + (target - cur) * k);
// Bounding box of everything this frame will draw (trail points, head + glow + ring + glyph, ripples),
// padded by the widest stroke/glow. null when nothing is drawn.
let prevBox = null;
const drawBox = (now) => {
while (pts.length && now - pts[0].t > S.trailMs) pts.shift();
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
const add = (x, y, r) => {
if (x - r < x0) x0 = x - r; if (y - r < y0) y0 = y - r;
if (x + r > x1) x1 = x + r; if (y + r > y1) y1 = y + r;
};
const boost = 1 + S.dragBoost;
const trailPad = Math.max(S.glowWidth, S.beamWidth) * boost / 2 + 4;
for (const p of pts) add(p.x, p.y, trailPad);
if (hasPos && visible > 0.005) {
add(headX, headY, Math.max(S.headGlow * boost * 1.1, S.hoverRing + 10, S.headSize * 2 * boost + 4, 24));
}
for (const r of ripples) add(r.x, r.y, S.rippleSize + 16);
if (x0 === Infinity) return null;
return { x0: Math.max(0, Math.floor(x0)), y0: Math.max(0, Math.floor(y0)), x1: Math.min(W, Math.ceil(x1)), y1: Math.min(H, Math.ceil(y1)) };
};
const frame = () => {
rafId = 0;
if (!active) return;
const now = performance.now();
if (now - lastColorCheck > 1500) { lastColorCheck = now; readColors(); }
// Hover state can change without the pointer changing element (the video player adds/removes its
// hover class after our pointermove): re-read it every frame (a few closest() calls)
if (lastTarget && lastTarget.isConnected) classify(lastTarget);
visible = ease(visible, visibleTarget, 0.18);
ring = ease(ring, ringTarget, 0.22);
squeeze = ease(squeeze, squeezeTarget, down ? 0.35 : 0.2);
heat = ease(heat, heatTarget, 0.15);
// Dirty rectangle: clear only what was drawn last frame plus what will be drawn now, instead of the
// whole screen (the full-screen clear + composite is what made Firefox lag)
const box = drawBox(now);
const clr = prevBox && box ? {
x0: Math.min(prevBox.x0, box.x0), y0: Math.min(prevBox.y0, box.y0),
x1: Math.max(prevBox.x1, box.x1), y1: Math.max(prevBox.y1, box.y1),
} : (prevBox || box);
if (clr) ctx.clearRect(clr.x0, clr.y0, clr.x1 - clr.x0, clr.y1 - clr.y0);
prevBox = box;
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
drawTrail(now);
drawRipples(now);
drawHead(now);
ctx.globalCompositeOperation = 'source-over';
// Keep going while anything is still moving (a rainbow dot keeps cycling); otherwise stop until
// the next input
const settled = pts.length === 0 && ripples.length === 0 && !(S.rainbow && S.rainbowSpeed > 0 && visible > 0.01) &&
!(cursorKind === 'wait' && visible > 0.01) && (now - kindSince > 160) &&
visible === visibleTarget && ring === ringTarget && squeeze === squeezeTarget && heat === heatTarget;
if (!settled) kick();
};
function kick() { if (active && !rafId) rafId = requestAnimationFrame(frame); }
// Fullscreen: only the fullscreen element is painted, so the canvas moves into it. A bare
// <video>/<iframe> can't hold it: then the native cursor comes back until fullscreen ends.
const onFullscreen = () => {
if (!canvas) return;
const fs = document.fullscreenElement;
const canHost = fs && !/^(VIDEO|IFRAME|IMG|CANVAS)$/.test(fs.tagName);
(canHost ? fs : document.body).appendChild(canvas);
nativeOnly = !!fs && !canHost;
document.documentElement.classList.toggle('laser-cursor-on', !nativeOnly);
resize();
};
const start = () => {
if (active || !document.body) return;
active = true;
canvas = document.createElement('canvas');
canvas.id = 'laser-cursor-canvas';
canvas.setAttribute('aria-hidden', 'true');
ctx = canvas.getContext('2d');
document.body.appendChild(canvas);
document.documentElement.classList.add('laser-cursor-on');
readColors();
resize();
window.addEventListener('pointermove', onMove, { passive: true });
window.addEventListener('pointerdown', onDown, { passive: true });
window.addEventListener('pointerup', onUp, { passive: true });
window.addEventListener('blur', onUp);
document.addEventListener('mouseout', onLeave, { passive: true });
document.addEventListener('mouseover', onEnter, { passive: true });
window.addEventListener('resize', resize, { passive: true });
document.addEventListener('fullscreenchange', onFullscreen);
document.addEventListener('f0ck:contentLoaded', reprobe);
};
const stop = () => {
if (!active) return;
active = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = 0;
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerdown', onDown);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('blur', onUp);
document.removeEventListener('mouseout', onLeave);
document.removeEventListener('mouseover', onEnter);
window.removeEventListener('resize', resize);
document.removeEventListener('fullscreenchange', onFullscreen);
document.removeEventListener('f0ck:contentLoaded', reprobe);
document.documentElement.classList.remove('laser-cursor-on');
lastTarget = null;
watchPlayer(false);
if (canvas) canvas.remove();
canvas = null; ctx = null;
pts.length = 0; ripples.length = 0; hasPos = false;
};
const apply = () => { if (S.enabled && supported()) start(); else stop(); };
const changed = () => {
save();
readColors();
if (hovering) ringTarget = S.hoverRing;
apply();
kick();
window.dispatchEvent(new CustomEvent('f0ck:laser_changed', { detail: api.getState() }));
};
const api = {
defaults: Object.assign({}, DEFAULTS),
schema: SCHEMA,
getState: () => Object.assign({}, S, { supported: supported(), active }),
set: (key, val) => {
if (!(key in DEFAULTS)) return;
S[key] = coerce(key, val);
changed();
},
setEnabled: (on) => { S.enabled = on ? 1 : 0; changed(); },
reset: () => { Object.assign(S, DEFAULTS); changed(); },
};
window.f0ckLaser = api;
window.toggleLaserCursor = (force) => {
api.setEnabled(typeof force === 'boolean' ? force : !S.enabled);
return active;
};
// Announce once ready, so a tuner built before this script loaded fills its Laser tab
const init = () => { apply(); window.dispatchEvent(new CustomEvent('f0ck:laser_changed', { detail: api.getState() })); };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
-294
View File
@@ -1,294 +0,0 @@
var CATEGORY_META = {
wrong_rating: { label: 'Wrong Rating', bg: '#ffc107', color: '#000' },
spam: { label: 'Spam', bg: '#6c757d', color: '#fff' },
duplicate: { label: 'Duplicate', bg: '#17a2b8', color: '#fff' },
copyright: { label: 'Copyright', bg: '#fd7e14', color: '#fff' },
illegal: { label: 'Illegal', bg: '#dc3545', color: '#fff' },
other: { label: 'Other', bg: '#495057', color: '#fff' }
};
function catBadge(c) {
var m = CATEGORY_META[c] || { label: c, bg: '#444', color: '#fff' };
return '<span class="rp-cat" style="background:' + m.bg + ';color:' + m.color + ';">' + m.label + '</span>';
}
function rpEsc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function relTime(d) {
var diff = (Date.now() - new Date(d)) / 1000;
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff/60) + 'm ago';
if (diff < 86400) return Math.floor(diff/3600) + 'h ago';
return new Date(d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
window.currentPage = window.currentPage || 1;
window.loadReports = async function(page) {
page = page || 1;
window.currentPage = page;
var status = document.getElementById('report-status-filter').value;
var feed = document.getElementById('reports-feed');
var pag = document.getElementById('reports-pagination');
feed.innerHTML = '<div class="rp-state-msg"><i class="fa-solid fa-spinner fa-spin"></i> Loading...</div>';
pag.innerHTML = '';
try {
var res = await fetch('/api/v2/mod/reports?status=' + status + '&page=' + page);
var data = await res.json();
if (!data.success) {
feed.innerHTML = '<div class="rp-state-msg" style="color:#dc3545;">Error: ' + rpEsc(data.msg) + '</div>';
return;
}
window.currentReports = data.reports;
window.emojiMap = new Map();
if (data.emojis) data.emojis.forEach(function(e) { window.emojiMap.set(e.name.toLowerCase(), e.url); });
if (!data.reports.length) {
feed.innerHTML = '<div class="rp-state-msg">No reports found.</div>';
return;
}
feed.innerHTML = '';
var isAdmin = window.f0ckSession && window.f0ckSession.admin;
data.reports.forEach(function(r) {
var card = document.createElement('div');
var cats = Array.isArray(r.categories) ? r.categories : [];
card.className = 'rp-card' + (cats.indexOf('illegal') !== -1 ? ' illegal-flag' : '');
var statusBadge = '<span class="rp-status-badge rp-status-' + status + '">' + status + '</span>';
var reporter = r.reporter_name
? '<a href="/user/' + rpEsc(r.reporter_name) + '" class="rp-reporter-link">' + rpEsc(r.reporter_name) + '</a>' + (r.reporter_ip ? ' <span class="rp-reporter-ip">(' + rpEsc(r.reporter_ip) + ')</span>' : '')
: '<span style="color:#666;font-style:italic;">Guest' + (r.reporter_ip ? ' (' + rpEsc(r.reporter_ip) + ')' : '') + '</span>';
var targetHtml = '';
var itemLink = '';
if (r.comment_id) {
targetHtml = '<span style="color:#888;font-size:0.85em;">comment #' + r.comment_id + '</span>';
if (r.resolved_item_id) itemLink = '<a href="/' + r.resolved_item_id + '" target="_blank" class="rp-open-link"><i class="fa-solid fa-arrow-up-right-from-square"></i> item #' + r.resolved_item_id + '</a>';
} else if (r.resolved_item_id) {
targetHtml = '<span style="color:#888;font-size:0.85em;">item</span>';
itemLink = '<a href="/' + r.resolved_item_id + '" target="_blank" class="rp-open-link"><i class="fa-solid fa-arrow-up-right-from-square"></i> #' + r.resolved_item_id + '</a>';
} else if (r.reported_user_name) {
targetHtml = 'user <a href="/user/' + rpEsc(r.reported_user_name) + '" class="rp-target-link">' + rpEsc(r.reported_user_name) + '</a>';
}
var previewHtml = '';
var isItem = !!r.resolved_item_id && r.resolved_item_dest;
var isComment = !!r.comment_id;
if (isItem && !isComment) {
var mime = r.resolved_item_mime || '';
var src = '/b/' + r.resolved_item_dest;
var href = '/' + r.resolved_item_id;
if (mime === 'video/youtube') {
var ytId = r.resolved_item_dest.replace('yt:', '');
previewHtml = '<div class="rp-preview"><img src="https://img.youtube.com/vi/' + ytId + '/mqdefault.jpg" loading="lazy"><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-brands fa-youtube"></i></a></div>';
} else if (mime.indexOf('image/') === 0) {
previewHtml = '<div class="rp-preview"><img src="' + src + '" loading="lazy"><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-expand"></i></a></div>';
} else if (mime.indexOf('video/') === 0) {
previewHtml = '<div class="rp-preview"><video src="' + src + '" muted playsinline preload="metadata"></video><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-play"></i></a></div>';
} else if (mime.indexOf('audio/') === 0) {
previewHtml = '<div class="rp-preview"><div class="rp-no-preview"><i class="fa-solid fa-music"></i></div><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-expand"></i></a></div>';
} else {
previewHtml = '<div class="rp-preview"><div class="rp-no-preview"><i class="fa-solid fa-file"></i></div><a href="' + href + '" target="_blank" class="rp-preview-link"><i class="fa-solid fa-expand"></i></a></div>';
}
} else if (isComment) {
var body = (r.comment_body || '[deleted]').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
if (window.emojiMap) {
body = body.replace(/:([a-z0-9_]+):/g, function(match, code) {
var url = window.emojiMap.get(code.toLowerCase());
return url ? '<img src="' + url + '" style="height:18px;vertical-align:middle;" title=":' + code + ':">' : match;
});
}
previewHtml = '<div class="rp-preview" style="background:rgba(0,0,0,0.4);width:180px;min-width:180px;align-items:flex-start;padding:12px;overflow-y:auto;font-size:0.78em;color:#ccc;font-family:monospace;line-height:1.5;white-space:pre-wrap;height:140px;">' + body + '</div>';
} else {
previewHtml = '<div class="rp-preview"><div class="rp-no-preview"><i class="fa-solid fa-user"></i></div></div>';
}
var catsHtml = cats.length ? '<div class="rp-cats">' + cats.map(catBadge).join('') + '</div>' : '';
var reasonHtml = r.reason ? '<div class="rp-reason">' + rpEsc(r.reason) + '</div>' : '';
var actions = '';
if (status === 'pending') {
actions += '<button class="rp-btn rp-btn-resolve" onclick="window.resolveReport(' + r.id + ',\'resolved\')"><i class="fa-solid fa-check"></i> Resolve</button>';
actions += '<button class="rp-btn rp-btn-reject" onclick="window.resolveReport(' + r.id + ',\'rejected\')"><i class="fa-solid fa-xmark"></i> Reject</button>';
actions += '<span class="rp-sep"></span>';
}
if (isItem && !isComment) {
var isUnav = r.resolved_item_visibility === 3;
actions += '<button class="rp-btn rp-btn-delete" onclick="window.adminDeleteItem(' + r.resolved_item_id + ')"><i class="fa-solid fa-trash"></i> Delete</button>';
actions += '<button class="rp-btn ' + (isUnav ? 'rp-btn-avail' : 'rp-btn-unavail') + '" onclick="window.modToggleUnavailable(' + r.resolved_item_id + ',' + (r.resolved_item_visibility||0) + ')">' + (isUnav ? '<i class="fa-solid fa-eye"></i> Restore' : '<i class="fa-solid fa-ban"></i> 451') + '</button>';
}
if (isComment) {
actions += '<button class="rp-btn rp-btn-delete" onclick="window.adminDeleteComment(' + r.comment_id + ')"><i class="fa-solid fa-trash"></i> Del Comment</button>';
}
if (r.reported_user_id && (isAdmin || !r.reported_user_is_admin)) {
var who = r.reported_user_name ? rpEsc(r.reported_user_name) : 'user';
actions += '<span class="rp-sep"></span>';
actions += '<button class="rp-btn rp-btn-warn" onclick="window.modWarnUser(' + r.reported_user_id + ')"><i class="fa-solid fa-triangle-exclamation"></i> Warn ' + who + '</button>';
actions += '<button class="rp-btn rp-btn-ban" onclick="window.adminBanUser(' + r.reported_user_id + ')"><i class="fa-solid fa-gavel"></i> Ban ' + who + '</button>';
} else if (!r.reported_user_id) {
actions += '<span class="rp-anon-note">Anonymous reporter</span>';
}
if (r.reporter_id && r.reporter_name) {
actions += '<button class="rp-btn rp-btn-secondary" onclick="window.modWarnUser(' + r.reporter_id + ')">Warn Reporter</button>';
}
var ts = new Date(r.created_at).toLocaleString();
var rt = relTime(r.created_at);
var resolverHtml = (status !== 'pending' && r.resolver_name)
? '<span style="font-size:0.78em;color:#888;margin-left:6px;"><i class="fa-solid fa-' + (status === 'resolved' ? 'check' : 'xmark') + '" style="margin-right:3px;color:' + (status === 'resolved' ? '#28a745' : '#6c757d') + ';"></i>by <a href="/user/' + rpEsc(r.resolver_name) + '" style="color:#888;font-weight:600;text-decoration:none;">' + rpEsc(r.resolver_name) + '</a></span>'
: '';
card.innerHTML =
'<div class="rp-card-bar">' +
'<div class="rp-card-bar-left">' +
'<span class="rp-card-id">#' + r.id + '</span>' +
statusBadge +
resolverHtml +
'<span style="font-size:0.83em;color:#aaa;margin-left:6px;">from ' + reporter + '</span>' +
(targetHtml ? '<span style="font-size:0.83em;color:#777;">&rarr; ' + targetHtml + '</span>' : '') +
itemLink +
'</div>' +
'<span class="rp-card-time" title="' + rpEsc(ts) + '">' + rt + '</span>' +
'</div>' +
'<div class="rp-card-body">' +
previewHtml +
'<div class="rp-info">' + catsHtml + reasonHtml + '</div>' +
'</div>' +
'<div class="rp-card-actions">' + actions + '</div>';
feed.appendChild(card);
});
pag.innerHTML = '';
if (data.pages > 1) {
if (data.page > 1)
pag.innerHTML += '<button onclick="window.loadReports(' + (data.page-1) + ')"><i class="fa-solid fa-chevron-left"></i> Prev</button>';
pag.innerHTML += '<span class="rp-page-info">Page ' + data.page + ' of ' + data.pages + '</span>';
if (data.page < data.pages)
pag.innerHTML += '<button onclick="window.loadReports(' + (data.page+1) + ')">Next <i class="fa-solid fa-chevron-right"></i></button>';
}
} catch(e) {
feed.innerHTML = '<div class="rp-state-msg" style="color:#dc3545;"><i class="fa-solid fa-circle-exclamation"></i> Network error</div>';
}
};
window.resolveReport = function(id, action) {
var label = action === 'resolved' ? 'Resolve' : 'Reject';
var desc = action === 'resolved'
? 'Mark report #' + id + ' as resolved.'
: 'Reject report #' + id + '. The content will remain as-is.';
window.ModAction.confirm(label + ' Report #' + id, desc, async function() {
var params = new URLSearchParams();
params.append('action', action);
var csrfToken = window.f0ckSession && window.f0ckSession.csrf_token;
var res = await fetch('/api/v2/mod/reports/' + id + '/resolve', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': csrfToken
},
body: params
});
var data = await res.json();
if (data.success) {
window.loadReports(window.currentPage);
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.pollDebounced === 'function')
window.NotificationSystemInstance.pollDebounced();
} else {
throw new Error(data.msg || 'Failed to update report');
}
}, { hideReason: true });
};
window.adminDeleteComment = function(id) {
window.ModAction.confirm('Delete Comment #' + id, 'Are you sure you want to delete this comment? This action is permanent.', async (reason) => {
var params = new URLSearchParams();
params.append('reason', reason);
var res = await fetch('/api/comments/' + id + '/delete', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('comment deleted', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
window.adminDeleteItem = function(id) {
window.ModAction.confirm('Delete Item #' + id, 'Are you sure you want to delete this item? This action is permanent.', async (reason) => {
var params = new URLSearchParams();
params.append('postid', id); params.append('reason', reason);
var res = await fetch('/api/v2/admin/deletepost', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('item deleted', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
window.modToggleUnavailable = function(id, currentVis) {
var willBeUnavailable = currentVis !== 3;
var targetVis = willBeUnavailable ? 3 : 0;
var actionText = willBeUnavailable ? 'Make Unavailable (HTTP 451)' : 'Make Available (Public)';
window.ModAction.confirm('Item Visibility', actionText + ' for item #' + id + '?', async () => {
var params = new URLSearchParams();
params.append('postid', id); params.append('id', id); params.append('visibility', targetVis);
var csrfToken = window.f0ckSession && window.f0ckSession.csrf_token;
var res = await fetch('/api/v2/item/visibility', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken }, body: params });
var data = await res.json();
if (data.success) {
if (window.showFlash) window.showFlash(willBeUnavailable ? 'Item marked unavailable (451)' : 'Item restored to public', 'success');
var item = window.currentReports.find(function(x) { return x.resolved_item_id === id; });
if (item) item.resolved_item_visibility = targetVis;
window.loadReports(window.currentPage);
} else throw new Error(data.msg || 'Failed to update visibility');
});
};
window.modWarnUser = function(userId) {
window.ModAction.confirm('Warn User ID ' + userId, '', async (reason) => {
var params = new URLSearchParams();
params.append('user_id', userId); params.append('reason', reason);
var res = await fetch('/api/v2/mod/warnings/issue', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('user has been warned', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
window.adminBanUser = function(userId) {
var isAdmin = window.f0ckSession && window.f0ckSession.admin;
var promptHtml =
'<p>This will restrict the user from accessing their account and performing most actions.</p>' +
'<div style="margin-top:10px;"><label>Ban Duration:</label>' +
'<select id="ban-duration-select" class="form-control" style="margin-top:5px;">' +
(isAdmin ? '<option value="permanent">Permanent</option>' : '') +
'<option value="1">1 Hour</option><option value="6">6 Hours</option><option value="24">24 Hours (1 Day)</option>' +
(!isAdmin ? '<option value="48">48 Hours (2 Days)</option>' : '') +
(isAdmin ? '<option value="168">168 Hours (1 Week)</option>' : '') +
(isAdmin ? '<option value="720">720 Hours (1 Month)</option>' : '') +
'</select></div>';
window.ModAction.confirm('Ban User ID ' + userId, promptHtml, async (reason) => {
var duration = document.getElementById('ban-duration-select').value;
var params = new URLSearchParams();
params.append('user_id', userId); params.append('reason', reason); params.append('duration', duration);
var res = await fetch('/api/v2/admin/ban', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: params });
var data = await res.json();
if (data.success) { if (window.showFlash) window.showFlash('User banned cleanly.', 'success'); }
else throw new Error(data.msg || 'Unknown error');
});
};
(function() {
var filter = document.getElementById('report-status-filter');
if (filter) filter.onchange = function() { window.loadReports(1); };
window.loadReports(1);
})();
-326
View File
@@ -1,326 +0,0 @@
/**
* page-modal.js — open info pages (ranking, rules, about, privacy, terms) in a modal over the content area
* (below the navbar, between the sidebars).
*
* Normal navigation swaps #main, which kills a playing video/audio. Links marked [data-page-modal]
* instead fetch the page, lift its #main content (with the page's <style>s and the #main class, which
* some page CSS hangs on) into the modal and re-run its inline scripts. The page underneath is untouched.
*
* Inside the modal, links to other modal pages load in place; any other link closes the modal and
* navigates as usual. Direct visits to these URLs are unaffected.
*/
(() => {
if (window.f0ckPageModal) return;
const MODAL_PAGES = /^\/(ranking|rules|about|privacy|terms|settings)\/?$/;
const modal = document.getElementById('page-modal');
if (!modal) return;
const body = modal.querySelector('.pgm-body');
const titleEl = modal.querySelector('.pgm-title');
let currentUrl = null;
let loadSeq = 0;
// Open state is tracked separately from display: while the close animation runs the modal is still shown
let isOpen = false;
let fx = null; // running open/close animation
const reduceMotion = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : { matches: false };
const animate = (el, frames, opts) => (typeof el.animate === 'function' && !reduceMotion.matches) ? el.animate(frames, opts) : null;
const pathOf = (href) => { try { return new URL(href, location.origin).pathname.replace(/\/+$/, '') || '/'; } catch { return null; } };
// Sidebar/footer entries of the page that is open get .is-active (a second click on it closes the page)
const markActive = () => {
const cur = isOpen && currentUrl ? pathOf(currentUrl) : null;
document.querySelectorAll('a[data-page-modal]').forEach((a) => {
a.classList.toggle('is-active', !!cur && pathOf(a.getAttribute('href')) === cur);
});
};
// URL bar + title of the page underneath, restored on close. replaceState (never push/back): a popstate
// would make the site's AJAX router reload the page underneath, which this modal exists to avoid.
let baseUrl = null;
let baseTitle = null;
const showUrl = (url) => {
try { history.replaceState(history.state, '', url); } catch {}
};
const isModalPath = (href) => {
try {
const u = new URL(href, location.origin);
return u.origin === location.origin && MODAL_PAGES.test(u.pathname);
} catch { return false; }
};
const runScripts = (root) => {
root.querySelectorAll('script').forEach((old) => {
// JSON data blocks are read by the page scripts, not executed
if (old.type && old.type !== 'text/javascript' && old.type !== 'module') return;
const s = document.createElement('script');
for (const a of old.attributes) s.setAttribute(a.name, a.value);
s.textContent = old.textContent;
old.replaceWith(s);
});
};
const load = async (url, title) => {
const seq = ++loadSeq;
currentUrl = url;
showUrl(url);
markActive();
titleEl.textContent = title || '';
body.innerHTML = '<div class="pgm-loading"><i class="fa-solid fa-spinner fa-spin"></i></div>';
try {
const res = await fetch(url, { credentials: 'same-origin' });
const doc = new DOMParser().parseFromString(await res.text(), 'text/html');
if (seq !== loadSeq) return;
const main = doc.getElementById('main');
if (!res.ok || !main) throw new Error(`HTTP ${res.status}`);
const wrap = document.createElement('div');
wrap.className = `${main.className} pgm-main`.trim();
wrap.innerHTML = main.innerHTML;
body.innerHTML = '';
body.appendChild(wrap);
body.scrollTop = 0;
animate(wrap, [{ opacity: 0, transform: 'translateY(6px)' }, { opacity: 1, transform: 'none' }], { duration: 200, easing: 'cubic-bezier(0.2, 0, 0, 1)' });
if (!title) {
const h = wrap.querySelector('h1, h2, h3');
titleEl.textContent = h ? h.textContent.trim() : '';
}
if (doc.title) document.title = doc.title;
runScripts(wrap);
} catch (e) {
if (seq !== loadSeq) return;
body.innerHTML = `<div class="pgm-loading">Could not load this page. <a href="${url}" class="pgm-fallback">Open it directly</a>.</div>`;
}
};
// ── Placement: the modal fills the content area only, so the navbar and sidebars stay usable ──
const visibleRect = (sel) => {
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
if (r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== 'hidden') return r;
}
return null;
};
// Top follows the navbar live (e.g. mobile burger menu collapsing). The sides follow the sidebars' *state*,
// not their animated position, so the content never re-lays out while a sidebar slides: it changes width
// once, when the toggle has finished, masked by a short fade (same as the main content).
let lastSides = null;
// Set by toggleSidebarRight at the moment the main content changes width (before the slide when
// opening, after it when closing); wins over the body class, which only flips once the slide ends.
let rightOverride = null;
const place = () => {
const vw = window.innerWidth;
const nav = visibleRect('body > nav.navbar, nav.navbar');
const top = nav ? Math.max(0, Math.round(nav.bottom)) : 0;
// Right sidebar: open unless body.sidebar-right-hidden; its layout width ignores the slide transform
const rsEl = document.querySelector('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
const rsOpen = !!rsEl && !document.body.classList.contains('sidebar-right-hidden')
&& getComputedStyle(rsEl).display !== 'none' && rsEl.offsetWidth > 0;
let right = rsOpen ? Math.min(Math.round(rsEl.offsetWidth), Math.round(vw / 2)) : 0;
if (rightOverride && performance.now() < rightOverride.until) right = rightOverride.right;
// Small phones: the sidebar is a pure overlay there, the content never makes room for it
if (vw <= 599) right = 0;
// Left (comments) sidebar on item pages
const ls = visibleRect('.item-sidebar-left');
const left = ls && ls.right > 1 && ls.right < vw / 2 ? Math.round(ls.right) : 0;
modal.style.setProperty('--pgm-top', `${top}px`);
// Desktop, sidebar beside the content: the header takes the exact height of the sidebar's tab row,
// so the two form one continuous bar under the navbar
const tabs = right > 0 && rsEl ? rsEl.querySelector('.sidebar-tabs') : null;
const tabsH = tabs ? tabs.getBoundingClientRect().height : 0;
if (tabsH > 0) {
modal.style.setProperty('--pgm-header-h', `${tabsH}px`);
modal.classList.add('pgm-header-synced');
} else {
modal.style.removeProperty('--pgm-header-h');
modal.classList.remove('pgm-header-synced');
}
const sides = `${left}|${right}`;
if (sides !== lastSides) {
const changed = lastSides !== null && modal.style.display !== 'none';
lastSides = sides;
modal.style.setProperty('--pgm-right', `${right}px`);
modal.style.setProperty('--pgm-left', `${left}px`);
if (changed && typeof body.animate === 'function') {
body.animate([{ opacity: 0.55 }, { opacity: 1 }], { duration: 220, easing: 'ease-out' });
}
}
};
// Follow sidebar slide animations for a moment after a toggle
let followUntil = 0;
const follow = () => {
if (modal.style.display === 'none') return;
place();
if (performance.now() < followUntil) requestAnimationFrame(follow);
};
const followFor = (ms) => {
const running = performance.now() < followUntil;
followUntil = performance.now() + ms;
if (!running) requestAnimationFrame(follow);
};
window.addEventListener('resize', () => { if (modal.style.display !== 'none') place(); }, { passive: true });
window.addEventListener('f0ck:sidebar-right-layout', (e) => {
const d = e.detail || {};
rightOverride = { right: d.open && !d.overlay ? Math.min(Math.round(d.width || 0), Math.round(window.innerWidth / 2)) : 0, until: performance.now() + 800 };
if (modal.style.display !== 'none') place();
});
// Sidebar toggles commit their body class when the slide has finished: update once then. The left
// (comments) sidebar may still be transitioning, so check again after it settles.
new MutationObserver(() => {
if (modal.style.display === 'none') return;
place();
setTimeout(place, 400);
}).observe(document.body, { attributes: true, attributeFilter: ['class'] });
// The navbar and sidebars change size on their own (mobile burger menu expanding/collapsing, sidebar resize):
// follow every size change so the modal's edges stay glued to them
const LAYOUT_SEL = 'nav.navbar, .global-sidebar-right, .item-sidebar-right, .index-sidebar-right, .item-sidebar-left';
const watched = new WeakSet();
const ro = typeof ResizeObserver === 'function'
? new ResizeObserver(() => { if (modal.style.display !== 'none') place(); })
: null;
const watchLayout = () => {
document.querySelectorAll(LAYOUT_SEL).forEach((el) => {
if (watched.has(el)) return;
watched.add(el);
if (ro) ro.observe(el);
// Class/style toggles inside the navbar (collapse "show", inline heights) may animate: follow for a bit
if (el.matches('nav.navbar')) {
new MutationObserver(() => { if (modal.style.display !== 'none') followFor(600); })
.observe(el, { attributes: true, attributeFilter: ['class', 'style'], subtree: true });
}
});
};
const open = (url, title) => {
if (!isOpen || baseUrl === null) {
baseUrl = location.pathname + location.search + location.hash;
baseTitle = document.title;
}
// Fade in on a fresh open, and when reopened while the close animation is still running
const fadeIn = !isOpen;
isOpen = true;
// Item pages bring their own sidebars after AJAX navigation, so (re)attach observers on each open
watchLayout();
place();
modal.style.display = 'flex';
// No visible scrollbar: focus the body so arrow keys / PageUp / PageDown / Space scroll it right away
try { body.focus({ preventScroll: true }); } catch {}
followFor(300);
if (fadeIn) {
// Mid-close: continue from where the fade-out is instead of flashing back to full opacity
const from = fx ? parseFloat(getComputedStyle(modal).opacity) || 0 : 0;
if (fx) { fx.cancel(); fx = null; }
fx = animate(modal, [{ opacity: from, transform: `translateY(${10 * (1 - from)}px)` }, { opacity: 1, transform: 'none' }], { duration: 220, easing: 'cubic-bezier(0.2, 0, 0, 1)' });
if (fx) fx.onfinish = () => { fx = null; };
}
load(url, title);
};
// restoreUrl = false when history already moved (back/forward): the URL bar is correct then
const close = (restoreUrl = true) => {
if (!isOpen) return;
isOpen = false;
currentUrl = null;
++loadSeq; // drop a load still in flight so it can't rewrite the URL after closing
if (restoreUrl && baseUrl !== null) {
showUrl(baseUrl);
if (baseTitle !== null) document.title = baseTitle;
}
baseUrl = baseTitle = null;
markActive();
const hide = () => {
fx = null;
if (isOpen) return; // reopened meanwhile
modal.style.display = 'none';
lastSides = null;
body.innerHTML = '';
};
if (fx) { fx.cancel(); fx = null; }
const out = animate(modal, [{ opacity: 1, transform: 'none' }, { opacity: 0, transform: 'translateY(10px)' }], { duration: 160, easing: 'cubic-bezier(0.4, 0, 1, 1)', fill: 'forwards' });
if (!out) { hide(); return; }
fx = out;
out.onfinish = () => { if (fx === out) { out.cancel(); hide(); } };
};
// Capture phase: runs before the global AJAX link handler
document.addEventListener('click', (e) => {
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const a = e.target.closest?.('a[href]');
if (!a) return;
if (a.hasAttribute('data-page-modal')) {
// Already on that page (e.g. the gear on /settings): plain link, never the same page twice
if (!isOpen && pathOf(a.getAttribute('href')) === pathOf(location.pathname)) return;
e.preventDefault();
e.stopPropagation();
// Same page already open: the entry works as a toggle
if (isOpen && currentUrl && pathOf(currentUrl) === pathOf(a.getAttribute('href'))) {
close();
return;
}
// Mobile: the sidebar overlays the content, so close it to bring the opened page into focus
const fromSidebar = a.closest('.global-sidebar-right, .item-sidebar-right, .index-sidebar-right');
if (fromSidebar && window.matchMedia('(max-width: 999px)').matches
&& !document.body.classList.contains('sidebar-right-hidden')
&& typeof window.toggleSidebarRight === 'function') {
window.toggleSidebarRight();
}
open(a.getAttribute('href'), a.getAttribute('title') || '');
return;
}
if (!isOpen || a.target === '_blank') return;
// Outside the modal (navbar brand, sidebar links, …): a real navigation closes it so the new page shows
if (!modal.contains(a)) {
const href = a.getAttribute('href') || '';
if (href.startsWith('#') || href.startsWith('javascript:')) return;
try { if (new URL(a.href, location.origin).origin !== location.origin) return; } catch { return; }
close();
return;
}
// In-page anchors (#section, e.g. the settings quicknav) belong to the loaded page's own scripts:
// never reload the page for them
const rawHref = a.getAttribute('href') || '';
if (rawHref.startsWith('#')) return;
if (isModalPath(a.href)) {
e.preventDefault();
e.stopPropagation();
load(a.getAttribute('href'), '');
return;
}
// Leaving the modal for a regular page: close it and let normal navigation take over
close();
}, true);
modal.querySelector('.pgm-close').addEventListener('click', () => close());
// Back/forward changes the page underneath: don't leave the modal covering it
window.addEventListener('popstate', () => { if (isOpen) close(false); });
// Capture phase + stop: the page sits on top (e.g. over the Onara viewer), so Escape closes only it
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || !isOpen) return;
e.stopImmediatePropagation();
close();
}, true);
// Shift+S toggles the settings (signed-in users: a settings link is on the page). Not while typing,
// not on /settings itself. Plain "s" stays Flash Yank.
document.addEventListener('keydown', (e) => {
if (!e.shiftKey || e.ctrlKey || e.altKey || e.metaKey || e.repeat) return;
if (e.key !== 'S' && e.key !== 's') return;
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
if (document.body.classList.contains('scroller-active')) return;
const link = document.querySelector('a[href="/settings"][data-page-modal]');
if (!link || pathOf(location.pathname) === '/settings' && !isOpen) return;
e.preventDefault();
if (isOpen && currentUrl && pathOf(currentUrl) === '/settings') close();
else open('/settings', link.getAttribute('title') || 'Settings');
});
window.f0ckPageModal = { open, close, get url() { return currentUrl; } };
})();
+73 -275
View File
@@ -36,8 +36,6 @@
const filterResetBtn = document.getElementById('filter-reset-btn');
const filterApplyBtn = document.getElementById('filter-apply-btn');
const filterSummary = document.getElementById('filter-active-summary');
let filterSummaryText = document.getElementById('filter-active-summary-text');
let filterClearBtn = document.getElementById('filter-active-clear');
const tagInput = document.getElementById('filter-tag-input');
const tagClear = document.getElementById('filter-tag-clear');
const tagSuggestEl = document.getElementById('tag-suggestions');
@@ -81,8 +79,8 @@
const CACHE_MAX = 200;
const CACHE_TTL = 30 * 60 * 1000;
const VOL_KEY = 'scroller_volume';
const PREFS_KEY = 'scroller_prefs';
const PRESETS_KEY = 'scroller_presets';
const PREFS_KEY = 'scroller_prefs';
const PRESETS_KEY = 'scroller_presets';
// ── User Preferences ──────────────────────────────────────────────────────
function loadPrefs() {
@@ -99,34 +97,7 @@
let autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10));
let leftHandEnabled = prefs.leftHand === true;
const getIsScrollerGuest = () => {
if (!window.f0ckSession) return true;
if (window.f0ckSession.user && !window.f0ckSession.is_anon) return false;
if (window.f0ckSession.is_anon && (window.f0ckSession.logged_in || window.f0ckSession.user)) return false;
if (window.f0ckAnonSSH && (window.f0ckAnonSSH.isSessionReady || window.f0ckAnonSSH.pubkey)) return false;
if (typeof localStorage !== 'undefined' && localStorage.getItem('f0ck_anon_ssh_pub')) return false;
return !window.f0ckSession.logged_in;
};
const isScrollerGuest = getIsScrollerGuest();
const isScrollerAnon = !(window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon);
const scrollerAllowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
const scrollerSingleMime = (isScrollerAnon && Array.isArray(scrollerAllowedMimes) && scrollerAllowedMimes.length === 1) ? scrollerAllowedMimes[0] : null;
const scrollerAllowedModes = isScrollerGuest ? ['sfw'] : window.f0ckSession?.anon_permissions?.allowed_modes;
const scrollerModeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
let effectiveScrollerMode = defaultMode;
if (isScrollerGuest) {
effectiveScrollerMode = 0;
} else if (isScrollerAnon && Array.isArray(scrollerAllowedModes)) {
const curName = scrollerModeNames[effectiveScrollerMode] || 'sfw';
if (!scrollerAllowedModes.includes(curName)) {
const fallbackName = scrollerAllowedModes[0] || 'sfw';
const fallbackIdx = scrollerModeNames.indexOf(fallbackName);
effectiveScrollerMode = fallbackIdx >= 0 ? fallbackIdx : 0;
}
}
let applied = { mode: effectiveScrollerMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null };
let applied = { mode: defaultMode, mime: '', order: 'random', tags: [], externalUrl: null };
let pending = { ...applied, tags: [] };
// Volume / mute
@@ -221,8 +192,7 @@
if (hid && !cache.items.some(item => String(item.id) === hid)) return false;
if (cache.filters) {
applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], ...cache.filters };
if (scrollerSingleMime) applied.mime = scrollerSingleMime;
applied = { mode: defaultMode, mime: '', order: 'random', tags: [], ...cache.filters };
applied.tags = Array.isArray(cache.filters.tags) ? [...cache.filters.tags] : [];
pending = { ...applied, tags: [...applied.tags] };
}
@@ -558,7 +528,7 @@
}
// ── Filter Presets CRUD ───────────────────────────────────────────────────
const PRESETS_LABELS = { mode: { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 3: 'All', 4: 'NSFL' } };
const PRESETS_LABELS = { mode: { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 3: 'All', 4: 'NSFL' } };
function getPresets() { try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '[]'); } catch { return []; } }
function savePresets(arr) { localStorage.setItem(PRESETS_KEY, JSON.stringify(arr)); }
@@ -1171,23 +1141,7 @@
}
async function toggleFav(slide) {
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
const errMsg = 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
} else if (typeof showShareToast === 'function') {
showShareToast(errMsg);
}
return;
}
if (!window.scrollerLoggedIn) {
if (typeof showShareToast === 'function') {
showShareToast('Login to favorite posts');
} else if (typeof window.flashMessage === 'function') {
window.flashMessage('Login to favorite posts', 3000, 'warning');
}
return;
}
if (!window.scrollerLoggedIn) return;
const id = slide.dataset.localId || slide.dataset.id;
// External items have non-numeric IDs (e.g. "gif/123") — can't fav until rehosted
if (!/^\d+$/.test(id)) { showShareToast('Can\u2019t fav external items'); return; }
@@ -1211,37 +1165,13 @@
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: `postid=${id}&action=${nowFaved ? 'add' : 'delete'}&favorited=${nowFaved}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data.success) {
// Rollback optimistic update
if (favBtn) {
favBtn.classList.toggle('faved', wasFaved);
const icon = favBtn.querySelector('i');
if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart';
const countEl = favBtn.querySelector('.scroll-btn-count');
if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1));
}
const errMsg = (data && (data.msg || data.error)) || 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
} else if (typeof showShareToast === 'function') {
showShareToast(errMsg);
}
return;
}
// Sync state and count to server truth (handles race conditions)
if (data.success && favBtn) {
if (data.favorited !== undefined) {
favBtn.classList.toggle('faved', data.favorited);
const icon = favBtn.querySelector('i');
if (icon) icon.className = (data.favorited ? 'fa-solid' : 'fa-regular') + ' fa-heart';
}
if (data.favs) {
const countEl = favBtn.querySelector('.scroll-btn-count');
if (countEl) countEl.textContent = data.favs.length;
}
const data = await resp.json();
// Sync count to server truth (handles race conditions)
if (data.success && favBtn && data.favs) {
const countEl = favBtn.querySelector('.scroll-btn-count');
if (countEl) countEl.textContent = data.favs.length;
}
} catch {
// Rollback optimistic update on error
@@ -1249,8 +1179,6 @@
favBtn.classList.toggle('faved', wasFaved);
const icon = favBtn.querySelector('i');
if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart';
const countEl = favBtn.querySelector('.scroll-btn-count');
if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1));
}
}
}
@@ -1417,8 +1345,7 @@
const meta = document.createElement('div'); meta.className = 'scroll-meta';
// esc() the color value: a raw '"' in username_color would break out of the
// style attribute and allow arbitrary HTML injection (XSS).
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
const colorStyle = (!isAnonGuest && item.username_color) ? `color:${esc(item.username_color)}` : '';
const colorStyle = item.username_color ? `color:${esc(item.username_color)}` : '';
const ratingHtml = `<span class="scroll-rating ${esc(item.rating_class)}" data-item-id="${item.id}" data-rating="${esc(item.rating_class)}">${esc(item.rating_label)}</span>`;
const ocHtml = item.is_oc ? `<span class="scroll-oc"><i class="fa-solid fa-star" style="font-size:.6rem"></i> OC</span>` : '';
const badgesHtml = `<div class="scroll-badges">${ratingHtml}${ocHtml}</div>`;
@@ -1428,15 +1355,10 @@
item.tags.split(', ').map(t => `<span class="scroll-tag-pill" data-tag="${esc(t.trim())}">${esc(t.trim())}</span>`).join('')
}</div>`
: '';
const userMetaHtml = isAnonGuest
? `<div class="scroll-meta-top">
<img class="scroll-avatar" src="/a/default.png" alt="" loading="lazy">
<div>
<div class="scroll-username">anonymous</div>
<div class="scroll-timeago">${esc(item.stamp ? timeAgo(item.stamp * 1000) : (item.timeago || ''))}</div>
</div>
</div>`
: `<div class="scroll-meta-top">
meta.innerHTML = `
<div class="scroll-meta-inner">
${badgesHtml}
<div class="scroll-meta-top">
<a href="/user/${esc(item.username)}" class="scroll-user-link">
<img class="scroll-avatar" src="${esc(item.avatar)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
</a>
@@ -1446,17 +1368,13 @@
</a>
<div class="scroll-timeago">${esc(item.stamp ? timeAgo(item.stamp * 1000) : (item.timeago || ''))}</div>
</div>
</div>`;
meta.innerHTML = `
<div class="scroll-meta-inner">
${badgesHtml}
${userMetaHtml}
</div>
${tagsHtml}
${item.is_external && item.external_board && item.external_tid
? `<a class="scroll-id-link" href="https://boards.4chan.org/${item.external_board}/thread/${item.external_tid}#p${item.external_id}" target="_blank">/${item.external_board}/thread/${item.external_tid}</a>`
: (item.local_id
? `<a class="scroll-id-link" href="/${item.local_path || item.local_id}" target="_blank">/${item.local_path || item.local_id}</a>`
: `<a class="scroll-id-link" href="/abyss/${item.id}">#${item.id}</a>`)}
? `<a class="scroll-id-link" href="/${item.local_id}" target="_blank">#${item.local_id}</a>`
: `<a class="scroll-id-link" href="/abyss#${item.id}">#${item.id}</a>`)}
</div>`;
slide.appendChild(meta);
@@ -1464,14 +1382,11 @@
const actions = document.createElement('div'); actions.className = 'scroll-actions';
const _i = window.f0ckI18n || {};
actions.innerHTML = `
${(() => {
const isFaved = !!item.is_faved;
return `
<button class="scroll-btn js-fav-btn${isFaved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
<div class="scroll-btn-icon"><i class="${isFaved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
</button>`;
})()}
${window.scrollerLoggedIn ? `
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
</button>` : ''}
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
@@ -1486,7 +1401,7 @@
</button>
${item.is_external ? (
item.local_id
? `<a class="scroll-btn rehost-btn success" href="/${item.local_path || item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
? `<a class="scroll-btn rehost-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
</a>`
@@ -1636,8 +1551,8 @@
const localId = slide?.dataset.localId;
const id = localId || shareBtn.dataset.id;
const abyssUrl = localId
? `${location.origin}/abyss/${localId}`
: (id ? `${location.origin}/abyss/${id}` : `${location.origin}/abyss`);
? `${location.origin}/abyss#${localId}`
: `${location.origin}/abyss#${id}`;
openSharePanel(abyssUrl);
});
const rehostBtn = actions.querySelector('.rehost-btn');
@@ -1761,7 +1676,6 @@
const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`;
const local_id = allRehosts[external_media_url] || null;
const local_path = (local_id && data.rehost_paths && data.rehost_paths[local_id]) || local_id;
return {
id: `${data.board}/${p.no}`,
@@ -1771,7 +1685,6 @@
external_source: '4chan',
is_external: true,
local_id,
local_path,
external_media_url,
mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'),
dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`,
@@ -1938,6 +1851,10 @@
const origClass = icon.className;
icon.className = 'fa-solid fa-spinner';
let rating = applied.mode === 0 ? 'sfw' : (applied.mode === 1 ? 'nsfw' : (applied.mode === 4 ? 'nsfl' : 'sfw'));
if (item.external_board === 'wsg') rating = 'sfw';
else if (item.external_board === 'gif') rating = 'nsfw';
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
const resp = await fetch('/api/v2/scroller/rehost', {
@@ -1948,6 +1865,9 @@
},
body: new URLSearchParams({
url: item.external_media_url || item.dest,
rating: rating,
tags: '',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
...(item.original_filename ? { original_filename: item.original_filename } : {})
})
});
@@ -1974,7 +1894,7 @@
// Update button to link to the new site-internal post
setTimeout(() => {
btn.outerHTML = `
<a href="/${data.item_path || data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
<a href="/${data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
<span class="scroll-btn-label">View</span>
</a>
@@ -1985,8 +1905,8 @@
if (slide) {
const idLink = slide.querySelector('.scroll-id-link');
if (idLink) {
idLink.href = `/${data.item_path || data.item_id}`;
idLink.textContent = `/${data.item_path || data.item_id}`;
idLink.href = `/${data.item_id}`;
idLink.textContent = `#${data.item_id}`;
}
// Reflect rehoster's username and local timestamp
if (window.scrollerUsername) {
@@ -2239,11 +2159,10 @@
function renderCommentEl(c, canReply) {
const el = document.createElement('div'); el.className = 'comment-item';
el.dataset.commentId = c.id || '';
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
const av = isAnonGuest ? '/a/default.png' : (c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png'));
const nc = (!isAnonGuest && c.username_color) ? `color:${esc(c.username_color)}` : '';
const av = c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png');
const nc = c.username_color ? `color:${esc(c.username_color)}` : '';
const _i = window.f0ckI18n || {};
const uname = isAnonGuest ? 'anonymous' : (c.display_name || c.username || 'anon');
const uname = c.display_name || c.username || 'anon';
el.innerHTML = `
<img class="comment-avatar" src="${esc(av)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
<div class="comment-body">
@@ -2251,7 +2170,7 @@
<div class="comment-content" data-raw="${esc(c.content || '')}">${renderCommentContent(c.content || '')}</div>
<div class="comment-meta">
<span class="comment-time">${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')}</span>
${(!isAnonGuest && canReply) ? `
${canReply ? `
<button class="comment-reply-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.reply || 'Reply'}</button>
<button class="comment-quote-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.quote || 'Quote'}</button>
` : ''}
@@ -3057,7 +2976,7 @@
if (addTagSendBtn) addTagSendBtn.addEventListener('click', submitTag);
// ── Filter panel ──────────────────────────────────────────────────────────
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 3: 'All', 4: 'NSFL' };
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 3: 'All', 4: 'NSFL' };
filterOpenBtn.addEventListener('click', () => {
pending = { ...applied, tags: [...applied.tags] }; syncPanelUI();
@@ -3066,23 +2985,6 @@
});
function syncPanelUI() {
const isGuest = getIsScrollerGuest();
if (isGuest) {
pending.mode = 0;
applied.mode = 0;
} else {
document.querySelectorAll('#mode-pills .filter-pill').forEach(pill => {
pill.classList.remove('locked');
pill.disabled = false;
pill.style.cursor = '';
pill.style.opacity = '';
pill.querySelectorAll('.fa-lock').forEach(i => i.remove());
});
}
if (scrollerSingleMime) {
pending.mime = scrollerSingleMime;
applied.mime = scrollerSingleMime;
}
document.querySelectorAll('#mode-pills .filter-pill').forEach(p => p.classList.toggle('active', +p.dataset.mode === pending.mode));
document.querySelectorAll('#mime-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.mime === pending.mime));
document.querySelectorAll('#order-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.order === pending.order));
@@ -3091,32 +2993,13 @@
}
function makePillListener(groupId, key, transform) {
const el = document.getElementById(groupId);
if (!el) return;
el.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', (e) => {
if (groupId === 'mode-pills' && (getIsScrollerGuest() || pill.classList.contains('locked') || pill.disabled)) {
e.preventDefault();
return;
}
if (groupId === 'mime-pills' && scrollerSingleMime) {
e.preventDefault();
return;
}
el.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active'); pending[key] = transform ? transform(pill.dataset[key]) : pill.dataset[key];
});
});
}
window.addEventListener('f0ck:anon_session_ready', () => {
if (window.f0ckSession) {
window.f0ckSession.user = 'anonymous';
window.f0ckSession.is_anon = true;
window.f0ckSession.logged_in = true;
}
syncPanelUI();
});
makePillListener('mode-pills', 'mode', v => +v);
makePillListener('mime-pills', 'mime', v => v);
makePillListener('order-pills', 'order', v => v);
@@ -3141,7 +3024,7 @@
});
}
// ── Auto-load 4chan thread from path or hash (e.g. /abyss/wsg/6211653 or #wsg/6211653) ────────────────
// ── Auto-load 4chan thread from hash (e.g. #wsg/6132740) ────────────────
let chanHashPending = null; // async promise if we need server lookup
{
const hid = hashId();
@@ -3155,29 +3038,21 @@
const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}');
tid = postMap[hid]; // hid is "board/postno"
} catch(_) {}
const effectiveTid = tid || postno;
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${effectiveTid}`;
applied.order = 'oldest';
unlock4chan();
if (!tid) {
// In case postno was a reply post rather than thread OP, query find in background
if (tid) {
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${tid}`;
applied.order = 'oldest';
unlock4chan();
} else {
// No local mapping — ask the server to find the thread
chanHashPending = fetch(`/api/v2/scroller/external/4chan/${board}/find/${postno}`)
.then(r => r.json())
.then(data => {
window.f0ckDebug && window.f0ckDebug('[CHAN] Find result:', data);
window.f0ckDebug('[CHAN] Find result:', data);
if (data.success && data.tid) {
try {
const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}');
postMap[hid] = data.tid;
localStorage.setItem('4chan_post_threads', JSON.stringify(postMap));
} catch(_) {}
if (String(data.tid) !== String(effectiveTid)) {
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`;
applied.order = 'oldest';
reloadFeed();
}
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`;
applied.order = 'oldest';
unlock4chan();
window.f0ckDebug('[CHAN] Set externalUrl:', applied.externalUrl);
}
})
.catch(err => { console.error('[CHAN] Find error:', err); });
@@ -3185,61 +3060,12 @@
}
}
filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); });
function clearActiveFilters() {
applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null };
pending = { ...applied, tags: [] };
if (externalUrlInput) externalUrlInput.value = '';
if (galleryOpen) toggleGallery();
if (chanGalleryBtn) chanGalleryBtn.style.display = 'none';
if (tagInput) tagInput.value = '';
if (tagClear) tagClear.classList.remove('show');
if (tagSuggestEl) tagSuggestEl.innerHTML = '';
lastSugg = [];
renderActiveTags();
syncPanelUI();
renderPresets();
reloadFeed();
}
function setupFilterSummary() {
if (!filterSummary) return;
if (!filterSummaryText || !filterClearBtn) {
if (!filterSummary.querySelector('#filter-active-summary-text')) {
const txt = document.createElement('span');
txt.id = 'filter-active-summary-text';
const btn = document.createElement('button');
btn.id = 'filter-active-clear';
btn.type = 'button';
btn.title = (window.f0ckI18n && window.f0ckI18n.clear_filter) || 'Clear filter';
btn.setAttribute('aria-label', (window.f0ckI18n && window.f0ckI18n.clear_filter) || 'Clear filter');
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
filterSummary.innerHTML = '';
filterSummary.appendChild(txt);
filterSummary.appendChild(btn);
}
filterSummaryText = document.getElementById('filter-active-summary-text');
filterClearBtn = document.getElementById('filter-active-clear');
}
if (filterClearBtn && !filterClearBtn._hasListener) {
filterClearBtn._hasListener = true;
filterClearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
clearActiveFilters();
});
}
}
setupFilterSummary();
filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); });
function updateFilterSummary() {
if (!filterSummary) return;
setupFilterSummary();
const is4chan = !!applied.externalUrl;
const isDef = applied.mode === defaultMode && applied.mime === (scrollerSingleMime || '') && applied.order === 'random' && applied.tags.length === 0 && !is4chan;
if (filterOpenBtn) filterOpenBtn.classList.toggle('has-filter', !isDef);
filterSummary.classList.toggle('show', !isDef);
const isDef = applied.mode === defaultMode && applied.mime === '' && applied.order === 'random' && applied.tags.length === 0 && !is4chan;
filterOpenBtn.classList.toggle('has-filter', !isDef); filterSummary.classList.toggle('show', !isDef);
if (!isDef) {
// Check if current filters exactly match a saved preset
const tagsKey = t => [...t].map(s => s.toLowerCase()).sort().join(',');
@@ -3249,9 +3075,8 @@
p.order === applied.order &&
tagsKey(p.tags) === tagsKey(applied.tags)
);
let summaryText = '';
if (matchedPreset && !is4chan) {
summaryText = matchedPreset.name;
filterSummary.textContent = matchedPreset.name;
} else {
const parts = [];
if (is4chan) parts.push('4chan');
@@ -3259,12 +3084,7 @@
if (applied.mime) parts.push(applied.mime);
if (applied.order !== 'random') parts.push(applied.order);
parts.push(...applied.tags);
summaryText = parts.join(' · ');
}
if (filterSummaryText) {
filterSummaryText.textContent = summaryText;
} else {
filterSummary.textContent = summaryText;
filterSummary.textContent = parts.join(' · ');
}
}
}
@@ -3417,7 +3237,6 @@
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress upload modal shortcut in abyss
else if (e.key === 's' || e.key === 'S') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress flash yank shortcut in scroller
else if (e.key === 'Escape') { window.location.href = '/'; }
}, true); // capture phase — fires before f0ckm.js bubble listeners
@@ -3662,26 +3481,11 @@
const initScrollerSSE = () => {
if (sseEs) sseEs.close();
let sseUrl = `/api/notifications/stream?tabId=${tabId}`;
try {
const fp = localStorage.getItem('f0ck_anon_ssh_fp');
if (fp) sseUrl += `&fp=${encodeURIComponent(fp)}`;
const hw = localStorage.getItem('f0ck_anon_hw_fp');
if (hw) sseUrl += `&hw=${encodeURIComponent(hw)}`;
} catch (e) {}
sseEs = new EventSource(sseUrl);
sseEs = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
sseEs.onopen = () => { sseRetryCount = 0; };
sseEs.onmessage = (e) => {
try {
const data = JSON.parse(e.data);
if (data.type === 'banned') {
try {
const tombstoneData = { banned: true, reason: data.data?.reason, expires: data.data?.expires };
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstoneData))}; Path=/; Max-Age=31536000; SameSite=Lax`;
} catch (err) {}
window.location.href = data.data?.redirect || '/banned';
return;
}
if (data.type === 'notify') {
pollNotifCount(); // instant re-fetch on SSE push
if (navigator.vibrate) navigator.vibrate([200, 80, 200]);
@@ -3714,8 +3518,8 @@
// Tab type arrays
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const sActiveTabEl = sNotifDropdown ? sNotifDropdown.querySelector('.notif-tab.active') : null;
let sActiveTab = sActiveTabEl ? sActiveTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user');
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
let sActiveTab = 'user';
let sCachedNotifs = [];
if (sNotifBtn && sNotifDropdown) {
@@ -3832,9 +3636,8 @@
if (n.type === 'warning') {
thumb = `<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>`;
} else {
// Pending items aren't in /t/ yet: fall back to /pending/t/ (uploader + staff only)
const thumbSrc = n.type === 'admin_pending' ? `/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='/pending/t/${n.item_id}.webp'"></div>` : '';
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
}
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
${thumb}
@@ -3873,24 +3676,19 @@
}
});
// Mark all read (current tab only)
// Mark all read
if (sMarkAll) {
sMarkAll.addEventListener('click', async () => {
try {
const tab = sActiveTab || 'user';
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
await fetch('/api/notifications/read?tab=' + encodeURIComponent(tab), {
await fetch('/api/notifications/read', {
method: 'POST',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
// Mark only the active tab's cached notifications as read
const tabCheck = tab === 'user' ? isUserType : (tab === 'system' ? isSystemType : () => false);
sCachedNotifs = sCachedNotifs.map(n => tabCheck(n.type) ? { ...n, is_read: true } : n);
updateScrollerNotifBadge(0);
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
updateScrollerTabBadges(sCachedNotifs);
renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab));
// Update overall badge
const totalUnread = sCachedNotifs.filter(n => !n.is_read).length;
updateScrollerNotifBadge(totalUnread);
} catch (e) {}
});
}
@@ -3939,7 +3737,7 @@
const restored = tryRestoreFromCache();
if (!restored) {
if (chanHashPending && !applied.externalUrl) {
if (chanHashPending) {
chanHashPending.then(() => fetchItems());
} else {
fetchItems();
+16 -177
View File
@@ -151,10 +151,7 @@
const allowedTypes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
if (chooseBtn && fileInput) {
chooseBtn.addEventListener('click', (e) => {
if (e.target.closest('#avatar-remove-btn')) return; // removing, not choosing
fileInput.click();
});
chooseBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
@@ -197,24 +194,8 @@
progressWrapper.style.display = 'flex';
progressFill.style.width = '0%';
progressText.textContent = '0%';
progressWrapper.setAttribute('aria-valuenow', '0');
showStatus(i18n.uploading || 'Uploading...', '');
// Show the chosen image in the live preview right away; the progress overlay sits on top of it
chooseBtn.classList.add('is-uploading');
const liveAvatar = document.getElementById('live-preview-avatar');
const prevAvatarSrc = liveAvatar ? liveAvatar.src : null;
const localUrl = URL.createObjectURL(file);
if (liveAvatar) liveAvatar.src = localUrl;
const finishUpload = (ok) => {
progressWrapper.style.display = 'none';
chooseBtn.classList.remove('is-uploading');
if (!ok && liveAvatar && prevAvatarSrc) liveAvatar.src = prevAvatarSrc;
// Keep the local image until the server copy has loaded, then free it
if (ok && liveAvatar) liveAvatar.addEventListener('load', () => URL.revokeObjectURL(localUrl), { once: true });
else URL.revokeObjectURL(localUrl);
};
const formData = new FormData();
formData.append('file', file);
@@ -224,19 +205,14 @@
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
progressFill.style.width = percent + '%';
progressWrapper.setAttribute('aria-valuenow', String(percent));
// At 100% the bytes are sent but the server is still processing
if (percent >= 100) progressText.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
else progressText.textContent = percent + '%';
progressText.textContent = percent + '%';
}
});
let uploadOk = false;
xhr.addEventListener('load', () => {
try {
const res = JSON.parse(xhr.responseText);
if (xhr.status === 200 && res.success) {
uploadOk = true;
showStatus(res.msg || 'Avatar uploaded!', 'success');
// Update preview
@@ -260,15 +236,7 @@
// Show remove button if not present
const existingRemoveBtn = document.getElementById('avatar-remove-btn');
if (!existingRemoveBtn && chooseBtn.classList.contains('editable-avatar-container')) {
const btn = document.createElement('button');
btn.type = 'button';
btn.id = 'avatar-remove-btn';
btn.className = 'avatar-quick-remove';
btn.title = 'Remove custom avatar';
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
chooseBtn.appendChild(btn);
} else if (!existingRemoveBtn) {
if (!existingRemoveBtn) {
const actionsDiv = document.querySelector('.avatar-upload-actions');
if (actionsDiv) {
const btn = document.createElement('button');
@@ -290,14 +258,14 @@
showStatus('Upload failed: Invalid response', 'error');
}
finishUpload(uploadOk);
progressWrapper.style.display = 'none';
uploadBtn.disabled = true;
chooseBtn.disabled = false;
});
xhr.addEventListener('error', () => {
showStatus('Upload failed: Network error', 'error');
finishUpload(false);
progressWrapper.style.display = 'none';
uploadBtn.disabled = true;
chooseBtn.disabled = false;
});
@@ -686,43 +654,11 @@
if (bannerUploadBtn) bannerUploadBtn.disabled = true;
if (bannerChooseBtn) bannerChooseBtn.disabled = true;
const bannerLabel = (inner) => '<i class="fa-solid fa-image"></i> ' + inner;
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'block'; bannerProgressWrapper.setAttribute('aria-valuenow', '0'); }
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'flex'; }
if (bannerProgressFill) bannerProgressFill.style.width = '0%';
if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel('0%');
if (bannerProgressText) bannerProgressText.textContent = '0%';
showBannerStatus('Uploading...', '');
// Show the cropped banner on the live card right away; roll back if the upload fails
const liveCard = document.getElementById('live-profile-preview-box');
const prevBanner = liveCard ? {
img: liveCard.style.getPropertyValue('--author-banner'),
size: liveCard.style.getPropertyValue('--author-banner-size'),
pos: liveCard.style.getPropertyValue('--author-banner-position')
} : null;
const localBannerUrl = URL.createObjectURL(payload.file);
if (liveCard) {
liveCard.classList.add('is-banner-uploading');
liveCard.style.setProperty('--author-banner', `url('${localBannerUrl}')`);
liveCard.style.setProperty('--author-banner-size', payload.banner_size);
liveCard.style.setProperty('--author-banner-position', payload.banner_position);
}
const finishBannerUpload = (ok) => {
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
if (liveCard) {
liveCard.classList.remove('is-banner-uploading');
if (!ok && prevBanner) {
['--author-banner', '--author-banner-size', '--author-banner-position'].forEach((prop, i) => {
const v = [prevBanner.img, prevBanner.size, prevBanner.pos][i];
if (v) liveCard.style.setProperty(prop, v); else liveCard.style.removeProperty(prop);
});
}
}
// The server URL replaced the local one on success; give the browser time to swap before freeing it
setTimeout(() => URL.revokeObjectURL(localBannerUrl), ok ? 5000 : 0);
};
let bannerOk = false;
const formData = new FormData();
formData.append('file', payload.file);
if (payload.file_orig) formData.append('file_orig', payload.file_orig);
@@ -735,9 +671,7 @@
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100);
if (bannerProgressFill) bannerProgressFill.style.width = pct + '%';
if (bannerProgressWrapper) bannerProgressWrapper.setAttribute('aria-valuenow', String(pct));
// At 100% the bytes are sent but the server is still processing
if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel(pct >= 100 ? '<i class="fa-solid fa-spinner fa-spin"></i>' : pct + '%');
if (bannerProgressText) bannerProgressText.textContent = pct + '%';
}
});
@@ -745,7 +679,6 @@
try {
const res = JSON.parse(xhr.responseText);
if (xhr.status === 200 && res.success) {
bannerOk = true;
showBannerStatus(res.msg || 'Banner uploaded!', 'success');
const bannerPreview = document.getElementById('banner-preview');
@@ -777,19 +710,7 @@
}
const existingRemoveBtn = document.getElementById('banner-remove-btn');
const descControls = document.getElementById('desc-edit-controls');
if (!existingRemoveBtn && descControls) {
// Live card: add Reposition / Remove next to Change Banner, same as the server render
const mk = (id, cls, icon, label) => {
const b = document.createElement('button');
b.type = 'button'; b.id = id; b.className = cls;
b.style.cssText = 'padding: 4px 10px; font-size: 0.8em;';
b.innerHTML = `<i class="fa-solid ${icon}"></i> ${label}`;
return b;
};
descControls.appendChild(mk('banner-edit-btn', 'button button-sm', 'fa-arrows-up-down-left-right', 'Reposition'));
descControls.appendChild(mk('banner-remove-btn', 'button button-danger button-sm', 'fa-trash', 'Remove Banner'));
} else if (!existingRemoveBtn) {
if (!existingRemoveBtn) {
const actionsDiv = bannerUploadBtn ? bannerUploadBtn.closest('.avatar-upload-actions') : null;
if (actionsDiv) {
const editBtn = document.createElement('button');
@@ -841,12 +762,14 @@
showBannerStatus('Upload failed: Invalid response', 'error');
}
finishBannerUpload(bannerOk);
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
});
xhr.addEventListener('error', () => {
showBannerStatus('Upload failed: Network error', 'error');
finishBannerUpload(false);
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
});
xhr.open('POST', '/api/v2/settings/uploadBanner');
@@ -1575,7 +1498,7 @@
} else {
document.documentElement.classList.remove('blur-untagged-active');
}
showStatus(enabled ? 'Unrated blurring enabled!' : 'Unrated blurring disabled!', 'success');
showStatus(enabled ? 'Untagged blurring enabled!' : 'Untagged blurring disabled!', 'success');
});
}
const blurDetailToggle = document.getElementById('blur_detail_toggle');
@@ -1632,74 +1555,6 @@
});
}
// Background & Onara Sliders Helper
const setupAudioTuningSlider = (sliderId, valId, tuningKey, defaultVal, formatFn, parserFn) => {
const slider = document.getElementById(sliderId);
const valSpan = document.getElementById(valId);
if (!slider) return;
let currentTuning = (window.audioVisualizerTuning && Object.keys(window.audioVisualizerTuning).length > 0) ? window.audioVisualizerTuning : {};
try {
const raw = localStorage.getItem('f0ck_audio_tuning');
if (raw) currentTuning = Object.assign({}, currentTuning, JSON.parse(raw));
} catch (e) {}
const initialVal = currentTuning[tuningKey] !== undefined ? Number(currentTuning[tuningKey]) : defaultVal;
slider.value = initialVal;
if (valSpan) valSpan.textContent = formatFn(initialVal);
slider.addEventListener('input', () => {
const val = parserFn ? parserFn(slider.value) : parseFloat(slider.value);
if (valSpan) valSpan.textContent = formatFn(val);
if (window.audioVisualizerTuning) window.audioVisualizerTuning[tuningKey] = val;
if (window.applyBackgroundOpacitySettings) window.applyBackgroundOpacitySettings(window.audioVisualizerTuning);
try {
const raw = localStorage.getItem('f0ck_audio_tuning');
const t = raw ? JSON.parse(raw) : {};
t[tuningKey] = val;
localStorage.setItem('f0ck_audio_tuning', JSON.stringify(t));
} catch (e) {}
});
};
// Onara enable/disable toggle
const onaraEnabledToggle = document.getElementById('onara_enabled_toggle');
if (onaraEnabledToggle) {
const LS_KEY = 'f0ck_onara_enabled';
const hasGlobalConfig = window.f0ckSession && window.f0ckSession.onara_cfg !== undefined && window.f0ckSession.onara_cfg !== null;
if (hasGlobalConfig) {
const globalVal = !!window.f0ckSession.onara_cfg;
onaraEnabledToggle.checked = globalVal;
onaraEnabledToggle.disabled = true;
window.onara = globalVal;
} else {
// Seed checkbox from localStorage; fall back to the server-set session value
const stored = localStorage.getItem(LS_KEY);
const sessionOnara = !!(window.f0ckSession?.onara);
onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara;
// Reflect current state into window.onara so isOnaraActive() sees it
window.onara = onaraEnabledToggle.checked;
// Only persist an explicit choice; without one the config default keeps applying
if (stored !== null) {
try {
document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`;
} catch {}
}
onaraEnabledToggle.addEventListener('change', () => {
const enabled = onaraEnabledToggle.checked;
localStorage.setItem(LS_KEY, String(enabled));
try {
document.cookie = `f0ck_onara=${enabled ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`;
} catch {}
window.onara = enabled;
showStatus('Onara viewer ' + (enabled ? 'enabled' : 'disabled') + '.', 'success');
});
}
}
// Quote Emojis Toggle
const quoteEmojisToggle = document.getElementById('quote_emojis_toggle');
if (quoteEmojisToggle) {
@@ -2030,17 +1885,6 @@
const liveDesc = document.getElementById('live-preview-description');
const descControls = document.getElementById('desc-edit-controls');
// Inline-editable profile fields are text inputs: keep their key events away from the site-wide
// shortcut handlers (bubble-phase on document/window), not all of which skip contenteditable.
// The fields' own listeners (Enter / Ctrl+Enter to save, Escape) sit on the element and still run.
document.querySelectorAll('.settings [contenteditable="true"]').forEach((el) => {
if (el.dataset.keysIsolated) return;
el.dataset.keysIsolated = '1';
['keydown', 'keyup', 'keypress'].forEach((type) => {
el.addEventListener(type, (e) => e.stopPropagation());
});
});
if (liveDesc && descriptionTextarea) {
liveDesc.addEventListener('focus', () => {
if (descControls) descControls.style.display = 'flex';
@@ -2163,9 +2007,7 @@
if (passwordForm) {
passwordForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Absent on passkey-only accounts (no current password; setting one re-enables password login)
const currentInput = document.getElementById('current_password');
const current_password = currentInput ? currentInput.value : '';
const current_password = document.getElementById('current_password').value;
const new_password = document.getElementById('new_password').value;
const new_password_confirm = document.getElementById('new_password_confirm').value;
@@ -2175,7 +2017,6 @@
}
const btn = passwordForm.querySelector('button');
const btnLabel = btn.textContent;
btn.disabled = true;
btn.textContent = 'Updating...';
@@ -2192,8 +2033,6 @@
if (data.success) {
showAccountStatus(passwordStatus, data.msg || 'Password updated correctly', 'success');
passwordForm.reset();
// Password login was off: reload so the account section shows the normal state again
if (!currentInput) setTimeout(() => location.reload(), 900);
} else {
showAccountStatus(passwordStatus, data.msg || 'Failed to update password', 'error');
}
@@ -2201,7 +2040,7 @@
showAccountStatus(passwordStatus, 'Request failed', 'error');
} finally {
btn.disabled = false;
btn.textContent = btnLabel;
btn.textContent = 'Update Password';
}
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+8 -37
View File
@@ -18,22 +18,12 @@ window.TagAutocomplete = (() => {
const MIN_QUERY_LEN = 1;
function destroy() {
if (activeInstance) {
if (activeInstance.cleanup) {
try { activeInstance.cleanup(); } catch {}
}
const { wrapper } = activeInstance;
if (wrapper && wrapper.parentElement) {
wrapper.parentElement.removeChild(wrapper);
}
activeInstance = null;
if (!activeInstance) return;
const { wrapper } = activeInstance;
if (wrapper && wrapper.parentElement) {
wrapper.parentElement.removeChild(wrapper);
}
document.querySelectorAll('.tag-ac-wrapper').forEach(el => el.remove());
}
function isOpen() {
return !!(activeInstance && activeInstance.wrapper && activeInstance.wrapper.parentElement) ||
!!document.querySelector('.tag-ac-wrapper');
activeInstance = null;
}
function open(opts) {
@@ -226,21 +216,10 @@ window.TagAutocomplete = (() => {
input.addEventListener('input', onInput);
const onEscapeKey = (e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
destroy();
return;
}
};
wrapper.addEventListener('keydown', onEscapeKey);
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
onEscapeKey(e);
e.preventDefault();
destroy();
return;
}
@@ -331,14 +310,6 @@ window.TagAutocomplete = (() => {
}
};
const cleanup = () => {
document.removeEventListener('mousedown', onDocMousedown);
document.removeEventListener('touchstart', onDocTouchstart);
if (outsideTapTimer) clearTimeout(outsideTapTimer);
if (debounceTimer) clearTimeout(debounceTimer);
};
activeInstance.cleanup = cleanup;
// Delay attaching to avoid capturing the opening touch.
setTimeout(() => {
document.addEventListener('mousedown', onDocMousedown);
@@ -367,5 +338,5 @@ window.TagAutocomplete = (() => {
});
}
return { open, destroy, isOpen };
return { open, destroy };
})();
+48 -643
View File
@@ -7,16 +7,6 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
.replace(/'/g, "&#039;");
});
// Manual approval: after an upload, send the uploader to their status page focused on the new item
window.f0ckGoToPending = window.f0ckGoToPending || ((id) => {
const target = '/pending' + (id ? '#i' + id : '');
if (typeof window.loadPageAjax === 'function') {
window.loadPageAjax(target, false, { bypassCache: true, skipCache: true, skipInherit: true });
} else {
window.location.href = target;
}
});
// ============================================================
// URL Upload Tracker — single panel, active jobs only
// ============================================================
@@ -208,9 +198,7 @@ window.f0ckGoToPending = window.f0ckGoToPending || ((id) => {
tag_id: item.rating_tag_id || item.tag_id || 0,
is_oc: !!item.is_oc,
slug: item.slug,
visibility: item.visibility || 0,
is_album: !!item.is_album,
album_count: item.album_count || 0
visibility: item.visibility || 0
});
}
})
@@ -466,17 +454,6 @@ window.initUploadForm = (selector) => {
const statusDiv = form.querySelector('.upload-status');
const thumbSection = form.querySelector('#custom-thumbnail-section');
const thumbInput = form.querySelector('#upload-thumbnail-input');
const redirectCheckbox = form.querySelector('input[name="redirect_to_item"], #upload-redirect-checkbox');
if (redirectCheckbox) {
const savedRedirect = localStorage.getItem('upload_redirect_to_item');
if (savedRedirect !== null) {
redirectCheckbox.checked = (savedRedirect === 'true' || savedRedirect === '1');
}
redirectCheckbox.addEventListener('change', () => {
localStorage.setItem('upload_redirect_to_item', redirectCheckbox.checked ? 'true' : 'false');
});
}
// Capture the SSR-translated initial button text so updateSubmitButton
// always uses the correct language regardless of window.f0ckI18n state.
@@ -523,34 +500,6 @@ window.initUploadForm = (selector) => {
let autoTags = []; // Track tags suggested from metadata
let selectedFiles = []; // Array of files for shitpost_mode
let activeMode = 'file'; // 'file' or 'url'
// Album mode state and helpers
const albumChoiceContainer = form.querySelector('#album-choice-container');
let albumChoiceMode = 'album'; // 'album' | 'batch'
const isAlbumModeActive = () => {
if (activeMode === 'album') {
return selectedFiles.length > 0;
}
return activeMode === 'file' && selectedFiles.length > 1 && (albumChoiceMode === 'album' || !isShitpost);
};
if (albumChoiceContainer) {
const btns = albumChoiceContainer.querySelectorAll('.album-choice-btn');
btns.forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const choice = btn.getAttribute('data-choice');
if (choice === albumChoiceMode) return;
albumChoiceMode = choice;
btns.forEach(b => b.classList.toggle('active', b === btn));
selectedFiles.forEach(item => { delete item._rendered; });
if (filePreview) filePreview.innerHTML = '';
handleFile();
updateSubmitButton();
});
});
}
// Shared emoji cache for per-item pickers (fetched once, reused by all items)
let _emojiCache = null;
let _emojiCachePromise = null;
@@ -700,47 +649,14 @@ window.initUploadForm = (selector) => {
tab.addEventListener('click', () => {
const mode = tab.dataset.mode;
if (mode === activeMode) return;
const prevMode = activeMode;
activeMode = mode;
modeTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
if (modeFile) modeFile.style.display = (mode === 'file' || mode === 'album') ? '' : 'none';
if (modeFile) modeFile.style.display = mode === 'file' ? '' : 'none';
if (modeUrl) modeUrl.style.display = mode === 'url' ? '' : 'none';
if (mode === 'album') {
albumChoiceMode = 'album';
form.classList.add('album-mode-active');
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
if (fileInput) {
try {
const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
fileInput.accept = Object.keys(mimesObj).join(',') + (form.getAttribute('data-beatmaps') === '1' ? ',.osz' : '');
} catch {}
}
if (selectedFiles.length > 0) {
renderAlbumStaging();
}
} else if (mode === 'file') {
if (fileInput) {
try {
const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
fileInput.accept = Object.keys(mimesObj).join(',') + (form.getAttribute('data-beatmaps') === '1' ? ',.osz' : '');
} catch {}
}
if (!isAlbumModeActive()) {
form.classList.remove('album-mode-active');
if (prevMode === 'album' && isShitpost && selectedFiles.length > 0) {
selectedFiles.forEach(item => { delete item._rendered; });
if (filePreview) filePreview.innerHTML = '';
handleFile();
}
}
} else {
form.classList.remove('album-mode-active');
}
// Reset status
if (statusDiv) {
statusDiv.textContent = '';
@@ -1097,17 +1013,11 @@ window.initUploadForm = (selector) => {
}
const isShitpost = !!window.f0ckShitpostMode;
const isAlbum = isAlbumModeActive();
const isAlbumTab = activeMode === 'album';
const isAlbumActive = isAlbum || isAlbumTab;
form.classList.toggle('album-mode-active', isAlbumActive);
const rating = form.querySelector('input[name="rating"]:checked');
// In Shitpost Mode, ratings are per-item unless album mode is active
// In Shitpost Mode, ratings are per-item. If require rating is true, every item must be rated.
let hasRating = true;
if (isShitpost && !isAlbumActive && activeMode === 'file') {
if (isShitpost && activeMode === 'file') {
if (shitpostRequireRating) {
hasRating = selectedFiles.length > 0 && selectedFiles.every(item => ['sfw', 'nsfw', 'nsfl'].includes(item.rating));
}
@@ -1116,7 +1026,7 @@ window.initUploadForm = (selector) => {
}
let hasTags = true;
if (!isShitpost || isAlbumActive) {
if (!isShitpost) {
hasTags = tags.length >= minTags;
} else if (shitpostMinTags > 0 && activeMode === 'file') {
// In shitpost file mode with min-tags enforced: every queued item must meet the threshold.
@@ -1128,21 +1038,17 @@ window.initUploadForm = (selector) => {
const commentSec = form.querySelector('.global-comment-section');
const tagsSec = form.querySelector('.global-tag-section');
const ocSec = form.querySelector('.global-oc-section');
const titleSec = form.querySelector('.global-title-section');
const formActions = form.querySelector('.form-actions');
if (isShitpost) {
if (formActions) {
formActions.style.display = (activeMode === 'url' && !isAlbumActive) ? 'none' : 'block';
formActions.style.display = activeMode === 'url' ? 'none' : 'block';
}
const hide = activeMode === 'file' && !isAlbumActive;
const hide = activeMode === 'file';
const disp = hide ? 'none' : 'block';
if (ratingSec) {
ratingSec.style.display = disp;
ratingSec.querySelectorAll('input').forEach(i => {
i.disabled = hide;
i.required = !hide;
});
ratingSec.querySelectorAll('input').forEach(i => i.disabled = hide);
}
if (commentSec) {
commentSec.style.display = disp;
@@ -1153,19 +1059,13 @@ window.initUploadForm = (selector) => {
tagsSec.querySelectorAll('input').forEach(i => i.disabled = hide);
}
if (ocSec) {
ocSec.style.display = isAlbumActive ? 'block' : 'none';
ocSec.querySelectorAll('input').forEach(i => i.disabled = !isAlbumActive);
}
if (titleSec) {
titleSec.style.display = isAlbumActive ? 'block' : 'none';
titleSec.querySelectorAll('input').forEach(i => i.disabled = !isAlbumActive);
ocSec.style.display = 'none';
ocSec.querySelectorAll('input').forEach(i => i.disabled = true);
}
}
let hasContent = false;
if (activeMode === 'album') {
hasContent = selectedFiles.length >= 2;
} else if (activeMode === 'file') {
if (activeMode === 'file') {
hasContent = selectedFiles.length > 0;
} else {
hasContent = urlInput && urlInput.value.trim().length > 0;
@@ -1177,19 +1077,13 @@ window.initUploadForm = (selector) => {
const btnText = submitBtn.querySelector('.btn-text');
if (btnText) {
const i18n = window.f0ckI18n || {};
if (activeMode === 'album' && selectedFiles.length === 0) {
btnText.textContent = i18n.album_select_pictures || 'Select files for album';
submitBtn.disabled = true;
} else if (activeMode === 'album' && selectedFiles.length === 1) {
btnText.textContent = i18n.album_min_pictures || 'Add at least 2 items for an album';
submitBtn.disabled = true;
} else if (!hasContent) {
if (!hasContent) {
btnText.textContent = activeMode === 'file'
? (ssrSelectFileText || i18n.select_file || 'Select a file')
: (i18n.enter_url || 'Enter a URL');
} else if (!hasTags) {
// non-shitpost or shitpost with min-tags
if (isShitpost && !isAlbumActive && shitpostMinTags > 0) {
if (isShitpost && shitpostMinTags > 0) {
const remaining = shitpostMinTags - Math.min(...selectedFiles.map(item => (item.tags || []).length));
btnText.textContent = `${remaining} more tag${remaining !== 1 ? 's' : ''} required per item`;
} else {
@@ -1201,7 +1095,7 @@ window.initUploadForm = (selector) => {
}
} else if (!hasRating) {
const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]');
if (isShitpost && !isAlbumActive && shitpostRequireRating) {
if (isShitpost && shitpostRequireRating) {
btnText.textContent = 'Select a rating for each item';
} else {
if (nsflEnabled) {
@@ -1211,10 +1105,7 @@ window.initUploadForm = (selector) => {
}
}
} else {
if (isAlbumActive) {
const tpl = i18n.upload_album || 'Upload Album (%s subf0cks)';
btnText.textContent = tpl.replace('%s', selectedFiles.length);
} else if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) {
if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) {
btnText.textContent = i18n.embed_youtube || 'Embed YouTube Video';
} else if (activeMode === 'url') {
btnText.textContent = i18n.upload_from_url || 'Upload from URL';
@@ -1235,237 +1126,15 @@ window.initUploadForm = (selector) => {
}
};
const renderAlbumStaging = () => {
if (!filePreview) return;
filePreview.style.display = 'block';
filePreview.innerHTML = '';
const stagingCont = document.createElement('div');
stagingCont.className = 'album-staging-container';
const stagingHeader = document.createElement('div');
stagingHeader.className = 'album-staging-header';
stagingHeader.innerHTML = `
<div class="album-staging-title">
<i class="fa-solid fa-layer-group"></i>
<span>${(window.f0ckI18n && window.f0ckI18n.album_title) || 'Album'} (${selectedFiles.length} subf0cks)</span>
</div>
<button type="button" class="btn-add-album-pics">
<i class="fa-solid fa-plus"></i> ${(window.f0ckI18n && window.f0ckI18n.album_add_more) || 'Add subf0cks'}
</button>
`;
const addBtn = stagingHeader.querySelector('.btn-add-album-pics');
if (addBtn) {
addBtn.addEventListener('click', (e) => {
e.preventDefault();
if (fileInput) fileInput.click();
});
}
stagingCont.appendChild(stagingHeader);
const grid = document.createElement('div');
grid.className = 'album-staging-grid';
selectedFiles.forEach((item, index) => {
const file = item.file || item;
const card = document.createElement('div');
card.className = 'album-stage-card' + (index === 0 ? ' is-cover' : '');
const badge = document.createElement('div');
badge.className = 'album-stage-badge';
badge.innerHTML = index === 0 ? '<i class="fa-solid fa-star"></i> Cover' : `#${index + 1}`;
card.appendChild(badge);
// URL items (YouTube links etc.) — no File/Blob available
if (item.type === 'url') {
const urlStr = item.url || '';
const ytMatch = urlStr.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([A-Za-z0-9_-]{11})/);
if (ytMatch) {
const thumb = document.createElement('img');
thumb.src = `https://img.youtube.com/vi/${ytMatch[1]}/mqdefault.jpg`;
thumb.alt = 'YouTube thumbnail';
thumb.style.cssText = 'width:100%;height:100%;object-fit:cover;';
card.appendChild(thumb);
const mimeBadge = document.createElement('span');
mimeBadge.className = 'album-stage-mime-badge';
mimeBadge.innerHTML = '<i class="fa-brands fa-youtube"></i>';
card.appendChild(mimeBadge);
} else {
const urlPreview = document.createElement('div');
urlPreview.className = 'album-stage-audio-preview';
const shortUrl = urlStr.replace(/^https?:\/\//, '').substring(0, 40);
urlPreview.innerHTML = `
<i class="fa-solid fa-link"></i>
<span class="album-stage-audio-name" title="${urlStr}">${shortUrl}</span>
`;
card.appendChild(urlPreview);
const mimeBadge = document.createElement('span');
mimeBadge.className = 'album-stage-mime-badge';
mimeBadge.innerHTML = '<i class="fa-solid fa-link"></i>';
card.appendChild(mimeBadge);
}
} else {
const isVideo = (file.type && file.type.startsWith('video/')) || /\.(mp4|webm|mov|mkv)$/i.test(file.name || '');
const isAudio = (file.type && file.type.startsWith('audio/')) || /\.(mp3|ogg|wav|flac|m4a|aac)$/i.test(file.name || '');
if (isVideo) {
const video = document.createElement('video');
video.src = URL.createObjectURL(file);
video.muted = true;
video.playsInline = true;
video.autoplay = false;
video.preload = 'metadata';
card.appendChild(video);
const mimeBadge = document.createElement('span');
mimeBadge.className = 'album-stage-mime-badge';
mimeBadge.innerHTML = '<i class="fa-solid fa-play"></i>';
card.appendChild(mimeBadge);
} else if (isAudio) {
const audioPreview = document.createElement('div');
audioPreview.className = 'album-stage-audio-preview';
audioPreview.innerHTML = `
<i class="fa-solid fa-music"></i>
<span class="album-stage-audio-name" title="${file.name || 'Audio'}">${file.name || 'Audio'}</span>
`;
card.appendChild(audioPreview);
const mimeBadge = document.createElement('span');
mimeBadge.className = 'album-stage-mime-badge';
mimeBadge.innerHTML = '<i class="fa-solid fa-music"></i>';
card.appendChild(mimeBadge);
} else {
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.alt = file.name || `Subf0ck ${index + 1}`;
card.appendChild(img);
}
}
const actions = document.createElement('div');
actions.className = 'album-stage-actions';
// Move Left
const btnLeft = document.createElement('button');
btnLeft.type = 'button';
btnLeft.className = 'album-action-btn btn-album-move-left';
btnLeft.title = window.f0ckI18n?.album_move_left || 'Move left';
btnLeft.innerHTML = '<i class="fa-solid fa-arrow-left"></i>';
if (index === 0) {
btnLeft.disabled = true;
btnLeft.style.opacity = '0.3';
btnLeft.style.cursor = 'not-allowed';
} else {
btnLeft.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const temp = selectedFiles[index];
selectedFiles[index] = selectedFiles[index - 1];
selectedFiles[index - 1] = temp;
renderAlbumStaging();
updateSubmitButton();
});
}
actions.appendChild(btnLeft);
// Move Right
const btnRight = document.createElement('button');
btnRight.type = 'button';
btnRight.className = 'album-action-btn btn-album-move-right';
btnRight.title = window.f0ckI18n?.album_move_right || 'Move right';
btnRight.innerHTML = '<i class="fa-solid fa-arrow-right"></i>';
if (index === selectedFiles.length - 1) {
btnRight.disabled = true;
btnRight.style.opacity = '0.3';
btnRight.style.cursor = 'not-allowed';
} else {
btnRight.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const temp = selectedFiles[index];
selectedFiles[index] = selectedFiles[index + 1];
selectedFiles[index + 1] = temp;
renderAlbumStaging();
updateSubmitButton();
});
}
actions.appendChild(btnRight);
// Remove
const btnRemove = document.createElement('button');
btnRemove.type = 'button';
btnRemove.className = 'album-action-btn btn-album-remove';
btnRemove.title = window.f0ckI18n?.album_remove || 'Remove subf0ck';
btnRemove.innerHTML = '<i class="fa-solid fa-xmark"></i>';
btnRemove.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
selectedFiles.splice(index, 1);
if (selectedFiles.length === 0) {
if (form._f0ckUploader && typeof form._f0ckUploader.reset === 'function') {
form._f0ckUploader.reset();
}
} else {
handleFile();
updateSubmitButton();
}
});
actions.appendChild(btnRemove);
card.appendChild(actions);
const caption = document.createElement('div');
caption.className = 'album-stage-caption';
caption.title = file.name;
card.appendChild(caption);
const tagWrap = document.createElement('div');
tagWrap.className = 'album-stage-tags-wrap';
const tagInput = document.createElement('input');
tagInput.type = 'text';
tagInput.className = 'album-stage-tags-input';
tagInput.placeholder = (window.f0ckI18n && window.f0ckI18n.album_subf0ck_tags_placeholder) || 'Tags for this subf0ck (optional)';
tagInput.value = item.subTags || '';
tagInput.addEventListener('input', () => {
item.subTags = tagInput.value;
});
tagInput.addEventListener('click', (e) => e.stopPropagation());
tagWrap.appendChild(tagInput);
card.appendChild(tagWrap);
grid.appendChild(card);
});
// Add More Card in Grid if under 100 subf0cks
const maxAlbumItems = 100;
if (selectedFiles.length < maxAlbumItems) {
const addMoreCard = document.createElement('div');
addMoreCard.className = 'album-stage-card album-stage-add-more';
addMoreCard.innerHTML = `
<div class="album-add-icon"><i class="fa-solid fa-plus"></i></div>
<div class="album-add-text">${(window.f0ckI18n && window.f0ckI18n.album_add_more) || 'Add subf0cks'}</div>
<div class="album-add-sub">(${selectedFiles.length}/${maxAlbumItems})</div>
`;
addMoreCard.addEventListener('click', (e) => {
e.preventDefault();
if (fileInput) fileInput.click();
});
grid.appendChild(addMoreCard);
}
stagingCont.appendChild(grid);
filePreview.appendChild(stagingCont);
};
const handleFile = (files) => {
const isShitpost = !!window.f0ckShitpostMode;
// If files were provided, process them (append or replace)
if (files && files.length > 0) {
const isMultiAllowed = isShitpost || activeMode === 'album' || files.length > 1 || selectedFiles.length > 0;
const filesToProcess = isMultiAllowed ? Array.from(files) : [files[0]];
if (!isMultiAllowed && selectedFiles.length === 0) {
selectedFiles = []; // Reset for normal mode single non-image file
const filesToProcess = isShitpost ? Array.from(files) : [files[0]];
if (!isShitpost) {
selectedFiles = []; // Reset for normal mode — replace, not append
// Also wipe the preview DOM so the old card doesn't linger
if (filePreview) filePreview.innerHTML = '';
}
@@ -1530,9 +1199,7 @@ window.initUploadForm = (selector) => {
}
const mimeOk = !file.type || allowedMimes.includes(file.type);
// Beatmap sets (.osz): the server turns them into an audio post with Square Clicker maps
const extOk = (allowedExts.length > 0 && allowedExts.includes(fileExt)) ||
(fileExt === 'osz' && form.getAttribute('data-beatmaps') === '1');
const extOk = allowedExts.length > 0 && allowedExts.includes(fileExt);
if (allowedMimes.length > 0 && !mimeOk && !extOk) {
const errorMsg = `File type ${file.type || '.' + fileExt} is not allowed.`;
@@ -1552,32 +1219,18 @@ window.initUploadForm = (selector) => {
}
}
if (activeMode === 'album' && selectedFiles.length >= 100) {
const errorMsg = 'Album limit reached (maximum 100 subf0cks).';
if (typeof window.flashMessage === 'function') window.flashMessage('✕ ' + errorMsg, 4000, 'error');
else if (window.showFlash) window.showFlash(errorMsg, 'error');
else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; }
break;
}
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
if (isShitpost) {
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
} else {
selectedFiles.push(file); // Legacy single file mode uses raw File
}
}
}
}
// Toggle album choice container
if (activeMode === 'album') {
albumChoiceMode = 'album';
}
const isAlbumCandidate = activeMode === 'file' && selectedFiles.length > 1;
if (albumChoiceContainer) {
albumChoiceContainer.style.display = isAlbumCandidate ? 'flex' : 'none';
}
// Rebuild UI state
if (selectedFiles.length === 0) {
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
if (filePreview) {
filePreview.style.display = 'none';
filePreview.innerHTML = '';
@@ -1604,23 +1257,14 @@ window.initUploadForm = (selector) => {
statusDiv.className = 'upload-status';
}
// Force 'file' or 'album' mode tab UI if coming from URL mode
if (activeMode !== 'file' && activeMode !== 'album' && modeTabs.length > 0) {
// Force 'file' mode tab UI
if (activeMode !== 'file' && modeTabs.length > 0) {
modeTabs.forEach(t => t.classList.remove('active'));
const targetMode = isAlbumModeActive() ? 'album' : 'file';
const targetTab = form.querySelector(`.upload-mode-tab[data-mode="${targetMode}"]`);
if (targetTab) targetTab.classList.add('active');
const fileTab = form.querySelector('.upload-mode-tab[data-mode="file"]');
if (fileTab) fileTab.classList.add('active');
if (modeFile) modeFile.style.display = '';
if (modeUrl) modeUrl.style.display = 'none';
activeMode = targetMode;
}
// If Album Mode is active, render Album Staging
if (isAlbumModeActive()) {
renderAlbumStaging();
updateSubmitButton();
form.dispatchEvent(new CustomEvent('fileReady', { detail: { files: selectedFiles } }));
return true;
activeMode = 'file';
}
let lastNewPreviewItem = null;
@@ -2318,7 +1962,7 @@ window.initUploadForm = (selector) => {
// Legacy Global Meta Sync (Non-Shitpost Mode)
if (!isShitpost && selectedFiles.length > 0 && files && files.length > 0) {
const primaryFile = selectedFiles[0].file || selectedFiles[0];
const primaryFile = selectedFiles[0];
autoTags = [];
const metaCont = form.querySelector('.meta-suggestions-container');
const metaList = form.querySelector('.meta-suggestions-list');
@@ -2440,7 +2084,6 @@ window.initUploadForm = (selector) => {
if (el._swfObjectUrl) { URL.revokeObjectURL(el._swfObjectUrl); el._swfObjectUrl = null; }
});
selectedFiles = [];
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
form.querySelector('.gps-privacy-warning')?.remove();
if (fileInput) fileInput.value = '';
if (dropZonePrompt) dropZonePrompt.style.display = 'block';
@@ -2832,22 +2475,17 @@ window.initUploadForm = (selector) => {
}
const isFileMode = activeMode === 'file';
const isAlbum = isAlbumModeActive() || activeMode === 'album';
const globalRatingEl = form.querySelector('input[name="rating"]:checked');
// Validation
if (isShitpost && isFileMode && !isAlbum) {
if (isShitpost && isFileMode) {
if (selectedFiles.length === 0) {
if (window.showFlash) window.showFlash('No files selected', 'error');
return;
}
// No tag or rating requirement in shitpost mode — untagged items are allowed
} else {
if (isAlbum && selectedFiles.length < 2) {
if (window.showFlash) window.showFlash('Add at least 2 pictures for an album', 'error');
return;
}
if (!globalRatingEl) {
if (window.showFlash) window.showFlash('Please select a rating', 'error');
return;
@@ -3014,40 +2652,8 @@ window.initUploadForm = (selector) => {
}
}
if (lastData?.manual_approval && lastData?.itemid) window.f0ckGoToPending(lastData.itemid);
// URL uploads: redirect or stay based on user preference (pending/async jobs skip redirect)
if (!lastData?.pending && !lastData?.manual_approval) {
if (lastData?.itemid && 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,
slug: lastData.slug,
visibility: lastData.visibility || 0,
is_album: !!lastData.is_album,
album_count: lastData.album_count || 0
});
}
const shouldRedirectToItem = redirectCheckbox
? redirectCheckbox.checked
: (localStorage.getItem('upload_redirect_to_item') !== 'false');
if (shouldRedirectToItem && lastData?.itemid) {
const isMultiNonAlbumShitpost = isShitpost && (successCount > 1 || urls.length > 1);
const targetUrl = isMultiNonAlbumShitpost ? '/' : (lastData.slug ? `/${lastData.slug}` : `/${lastData.itemid}`);
if (typeof window.loadPageAjax === 'function') {
window.loadPageAjax(targetUrl, false, { bypassCache: true, skipCache: true, skipInherit: true });
} else {
window.location.href = targetUrl;
}
}
// else: stay on current page
}
// URL uploads: always stay on current page — tracker panel shows progress
// (no redirect here; the file upload path below handles its own redirect)
} else {
restoreBtn();
}
@@ -3055,141 +2661,6 @@ window.initUploadForm = (selector) => {
// --- File Upload ---
if (selectedFiles.length === 0) return;
const isAlbum = isAlbumModeActive() || activeMode === 'album';
if (isAlbum) {
const statusMsg = window.f0ckI18n?.uploading_album || `Uploading album (${selectedFiles.length} pictures)...`;
setBtnLoading(statusMsg);
if (progressContainer) progressContainer.style.display = 'flex';
if (statusDiv) {
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
}
const globalRatingEl = form.querySelector('input[name="rating"]:checked');
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
const formData = new FormData();
formData.append('is_album', 'true');
formData.append('rating', globalRatingEl ? globalRatingEl.value : 'sfw');
formData.append('visibility', globalVisEl ? globalVisEl.value : '0');
formData.append('expiry', globalExpiryEl ? globalExpiryEl.value : 'permanent');
formData.append('tags', tags.join(','));
formData.append('is_oc', isOc ? 'true' : 'false');
if (titleVal) formData.append('title', titleVal);
if (comment) formData.append('comment', comment);
for (let i = 0; i < selectedFiles.length; i++) {
const item = selectedFiles[i];
if (item.type === 'url') {
// URL items (YouTube links etc.) — send as a URL field, not a binary file
formData.append(`subf0ck_url_${i}`, item.url || '');
} else {
const f = item.file || item;
formData.append('files', f);
}
const subTags = item.subTags || '';
if (subTags) {
formData.append(`subf0ck_tags_${i}`, subTags);
}
}
try {
const res = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
if (progressFill) progressFill.style.width = percent + '%';
if (progressText) progressText.textContent = percent + '%';
}
});
xhr.onload = () => {
try {
const data = JSON.parse(xhr.responseText);
resolve(data);
} catch(e) {
let msg = 'Server error';
if (xhr.status === 413) msg = 'File too large';
try {
const errData = JSON.parse(xhr.responseText);
if (errData.msg) msg = errData.msg;
} catch(e2) {}
reject(new Error(msg));
}
};
xhr.onerror = () => reject(new Error('Connection error'));
xhr.open('POST', '/api/v2/upload');
const csrf = window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || '';
xhr.setRequestHeader('X-CSRF-Token', csrf);
xhr.send(formData);
});
if (res.success) {
if (dragModal) dragModal.classList.remove('show');
const dropModal = document.getElementById('upload-drag-modal');
if (dropModal) dropModal.classList.remove('show');
form._f0ckUploader.reset();
const successMsg = res.msg || `Album (${res.album_count || selectedFiles.length} subf0cks) uploaded successfully!`;
if (typeof window.flashMessage === 'function') {
window.flashMessage(successMsg, 3000, 'success');
} else if (!dragModal && statusDiv) {
statusDiv.innerHTML = '✓ ' + successMsg;
statusDiv.className = 'upload-status success';
}
if (res.itemid && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
window.NotificationSystemInstance.handleNewItem({
id: res.itemid,
dest: res.dest,
mime: res.mime,
username: res.username || window.f0ckSession?.user || '',
display_name: res.display_name || window.f0ckSession?.display_name || null,
tag_id: res.tag_id ?? 0,
is_oc: !!res.is_oc,
slug: res.slug,
visibility: res.visibility || 0,
is_album: !!res.is_album,
album_count: res.album_count || 0
});
}
const shouldRedirectToItem = redirectCheckbox
? redirectCheckbox.checked
: (localStorage.getItem('upload_redirect_to_item') !== 'false');
if (shouldRedirectToItem) {
const targetUrl = res.slug ? `/${res.slug}` : (res.itemid ? `/${res.itemid}` : '/');
if (typeof window.loadPageAjax === 'function') {
window.loadPageAjax(targetUrl, false, { bypassCache: true, skipCache: true, skipInherit: true });
} else {
window.location.href = targetUrl;
}
}
// else: stay on current page
return;
} else {
const errMsg = res.msg || 'Upload failed';
const err = new Error(errMsg);
if (res.repost) err.repost = res.repost;
throw err;
}
} catch (err) {
console.error('[ALBUM UPLOAD ERROR]', err);
if (err.repost) {
statusDiv.innerHTML = '✕ ' + window.escapeHtmlUpload(err.message) + ` (<a href="/${err.repost}" class="repost-link">view existing</a>)`;
} else {
statusDiv.textContent = '✕ ' + err.message;
}
statusDiv.className = 'upload-status error';
if (progressContainer) progressContainer.style.display = 'none';
restoreBtn();
return;
}
}
setBtnLoading(isShitpost ? `Uploading 1/${selectedFiles.length}...` : 'Uploading...');
if (progressContainer) progressContainer.style.display = 'flex';
if (statusDiv) {
@@ -3199,7 +2670,6 @@ window.initUploadForm = (selector) => {
let successCount = 0;
let lastData = null;
const uploadedResults = [];
for (let i = 0; i < selectedFiles.length; i++) {
const item = selectedFiles[i];
@@ -3295,7 +2765,6 @@ window.initUploadForm = (selector) => {
if (res.success) {
successCount++;
lastData = res;
uploadedResults.push(res);
if (res.pending) {
// Background URL download — show tracker widget entry
if (window.urlUploadTracker) {
@@ -3360,81 +2829,31 @@ window.initUploadForm = (selector) => {
}
if (successCount > 0) {
const totalFilesToUpload = selectedFiles.length;
const isBgUrlUpload = lastData?.pending && selectedFiles.every(i => i.type === 'url');
if (dragModal) dragModal.classList.remove('show');
const dropModal = document.getElementById('upload-drag-modal');
if (dropModal) dropModal.classList.remove('show');
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
if (window.hideAllModals) window.hideAllModals();
form._f0ckUploader.reset();
if (isBgUrlUpload) {
if (typeof window.flashMessage === 'function') {
window.flashMessage((window.f0ckI18n && window.f0ckI18n.url_upload_started) || 'Background upload started', 3000, 'info');
if (isShitpost) {
if (lastData?.manual_approval && typeof window.flashMessage === 'function') {
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
}
} else {
if (lastData?.manual_approval) {
if (typeof window.flashMessage === 'function') {
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
}
} else {
const successMsg = successCount > 1
? `${successCount} items uploaded successfully!`
: (lastData?.msg || 'Upload successful');
if (typeof window.flashMessage === 'function') {
window.flashMessage(successMsg, 3000, 'success');
} else if (!dragModal && statusDiv) {
statusDiv.innerHTML = '✓ ' + successMsg;
statusDiv.className = 'upload-status success';
}
} else if (!dragModal && statusDiv) {
statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful');
statusDiv.className = 'upload-status success';
}
}
const shouldRedirectToItem = redirectCheckbox
? redirectCheckbox.checked
: (localStorage.getItem('upload_redirect_to_item') !== 'false');
// Prepend items to live grid seamlessly
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
for (const up of uploadedResults) {
if (!up.manual_approval && !up.pending && up.itemid) {
window.NotificationSystemInstance.handleNewItem({
id: up.itemid,
dest: up.dest,
mime: up.mime,
username: up.username || window.f0ckSession?.user || '',
display_name: up.display_name || window.f0ckSession?.display_name || null,
tag_id: up.tag_id ?? 0,
is_oc: !!up.is_oc,
slug: up.slug,
visibility: up.visibility || 0,
is_album: !!up.is_album,
album_count: up.album_count || 0
});
}
}
// 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');
}
if (shouldRedirectToItem && !lastData?.manual_approval) {
const isMultiNonAlbumShitpost = isShitpost && !isAlbum && (uploadedResults.length > 1 || successCount > 1 || totalFilesToUpload > 1);
let targetUrl;
if (isMultiNonAlbumShitpost) {
targetUrl = '/';
} else {
const targetItem = lastData || (uploadedResults && uploadedResults[0]);
targetUrl = targetItem ? (targetItem.slug ? `/${targetItem.slug}` : (targetItem.itemid ? `/${targetItem.itemid}` : '/')) : '/';
}
if (typeof window.loadPageAjax === 'function') {
window.loadPageAjax(targetUrl, false, { bypassCache: true, skipCache: true, skipInherit: true });
} else {
window.location.href = targetUrl;
}
}
if (lastData?.manual_approval) {
const pendingItem = lastData || (uploadedResults && uploadedResults[0]);
window.f0ckGoToPending(pendingItem && pendingItem.itemid);
}
// else (redirect disabled): stay on current page
} else {
window.location.href = '/';
}
} else {
restoreBtn();
@@ -3453,21 +2872,8 @@ window.initUploadForm = (selector) => {
reset: () => {
isUploading = false;
form.reset();
if (redirectCheckbox) {
const savedRedirect = localStorage.getItem('upload_redirect_to_item');
if (savedRedirect !== null) {
redirectCheckbox.checked = (savedRedirect === 'true' || savedRedirect === '1');
}
}
tags = [];
selectedFiles = [];
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
albumChoiceMode = 'album';
if (albumChoiceContainer) {
albumChoiceContainer.querySelectorAll('.album-choice-btn').forEach(b => {
b.classList.toggle('active', b.getAttribute('data-choice') === 'album');
});
}
if (tagsList) tagsList.innerHTML = '';
if (tagsHidden) tagsHidden.value = '';
if (fileInput) fileInput.style.display = 'inline-block';
@@ -3496,7 +2902,6 @@ window.initUploadForm = (selector) => {
// Reset mode to 'file'
activeMode = 'file';
form.classList.remove('album-mode-active');
if (modeTabs.length > 0) {
modeTabs.forEach(t => {
if (t.dataset.mode === 'file') t.classList.add('active');
+76 -400
View File
@@ -6,19 +6,13 @@
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const currentSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
? window.albumGallery.getCurrentSubf0ck()
: null;
const subf0ck_id = currentSub ? (currentSub.slug || currentSub.id) : (tagsContainer?.dataset?.subf0ckSlug || tagsContainer?.dataset?.subf0ckId || null);
return {
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
subf0ck_id,
poster: document.querySelector("a#a_username")?.innerText,
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
};
@@ -52,16 +46,6 @@
const tagsContainer = document.querySelector("#tags");
if (!tagsContainer) return;
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
const canManage = !!(
document.querySelector('#tags[data-can-manage="true"]') ||
(window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) ||
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
);
// Anonymous uploaders may change the rating of their own item without full manage rights
const canRate = canManage || !!document.querySelector('#tags[data-can-rate="true"]');
// Only remove existing dynamically generated tags
[...inner.querySelectorAll(".badge")].forEach(tag => {
@@ -71,66 +55,24 @@
}
});
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
const tagsCopy = [...cleanTags];
tagsCopy.reverse().forEach(tag => {
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized);
let contentEl;
if (isRating) {
contentEl = document.createElement("span");
contentEl.className = "rating-label";
contentEl.textContent = tag.tag;
} else {
contentEl = document.createElement("a");
contentEl.href = "/tag/" + tag.normalized;
contentEl.style = "color: inherit !important";
contentEl.textContent = tag.tag;
}
_tags.reverse().forEach(tag => {
const a = document.createElement("a");
a.href = `/tag/${tag.normalized}`;
a.style = "color: inherit !important";
a.textContent = tag.tag;
const span = document.createElement("span");
span.classList.add("badge");
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
span.classList.add('new-tag-glow');
}
span.setAttribute('tooltip', tag.display_name || tag.user);
tag.badge.split(" ").forEach(b => span.classList.add(b));
if (isRating) {
span.classList.add('rating-tag', `is-${tag.normalized}`);
span.dataset.rating = tag.normalized;
if (activePostId) span.dataset.itemId = activePostId;
if (canRate) {
span.classList.add('can-cycle');
}
} else {
span.classList.add('tag-badge');
span.setAttribute('data-tag-id', tag.id || '');
span.setAttribute('data-tag', tag.tag);
span.setAttribute('data-tag-normalized', tag.normalized);
if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) {
span.setAttribute('tooltip', tag.display_name || tag.user);
}
const isExcl = !!tag.is_excluded;
if (isExcl) span.classList.add('tag-is-excluded');
}
span.insertAdjacentElement("beforeend", a);
span.insertAdjacentElement("beforeend", contentEl);
if (!isRating && tag.can_exclude) {
const excludeBtn = document.createElement('button');
excludeBtn.type = 'button';
excludeBtn.className = 'tag-exclude-btn';
excludeBtn.setAttribute('title', tag.is_excluded ? 'Excluded (click to unexclude)' : 'Exclude tag');
excludeBtn.setAttribute('aria-label', 'Exclude tag');
excludeBtn.innerHTML = `<i class="fa-solid ${tag.is_excluded ? 'fa-circle-check' : 'fa-ban'}"></i>`;
span.insertAdjacentElement('beforeend', excludeBtn);
}
if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator) && !isRating) {
if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) {
const space = document.createTextNode('\u00A0'); // &nbsp;
span.appendChild(space);
@@ -141,42 +83,9 @@
span.insertAdjacentElement("beforeend", del);
}
inner.insertAdjacentElement("afterbegin", span);
inner.insertAdjacentElement("afterbegin", span);
});
const hasRating = !!lastRatingTag;
if (!hasRating) {
const untaggedSpan = document.createElement("span");
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canRate ? ' can-cycle' : ''}`;
untaggedSpan.dataset.rating = 'untagged';
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
const lbl = document.createElement("span");
lbl.className = "rating-label";
lbl.textContent = "unrated";
untaggedSpan.appendChild(lbl);
inner.insertAdjacentElement("afterbegin", untaggedSpan);
}
// Safeguard: remove any extra rating tags in DOM
const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]');
if (allRatingEls.length > 1) {
for (let i = 1; i < allRatingEls.length; i++) {
allRatingEls[i].remove();
}
}
// Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
if (activePostId) {
const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
th.setAttribute('data-mode', modeAttr);
});
if (document.body.classList.contains('onara-modal-open')) {
const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
}
}
// Handle show more/less toggle visibility and count
const allBadges = [...inner.querySelectorAll(".badge")];
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
@@ -209,7 +118,7 @@
if (e) e.preventDefault();
const ctx = getContext();
if (!ctx) return;
const { postid, subf0ck_id, tags } = ctx;
const { postid, tags } = ctx;
const anchor = document.querySelector("a#a_addtag");
if (!anchor) return;
@@ -218,328 +127,95 @@
existingTags: tags,
anchorEl: anchor,
onSubmit: async (tag) => {
const payload = { tagname: tag };
if (subf0ck_id) payload.subf0ck_id = subf0ck_id;
const res = await post("/api/v2/tags/" + postid, payload);
if (res.success) {
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
const cur = window.albumGallery.getCurrentSubf0ck();
if (cur && res.tags) cur.tags = res.tags;
}
if (window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
renderTags: (newTags, hl) => {
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
const cur = window.albumGallery.getCurrentSubf0ck();
if (cur) cur.tags = newTags;
}
renderTags(newTags, hl);
}
renderTags
});
};
const toggleEvent = async (e) => {
if (e) e.preventDefault();
const ratingEl = document.querySelector('.rating-tag.can-cycle, button#a_toggle');
if (window.cycleRating) {
window.cycleRating(ratingEl);
const ctx = getContext();
if (!ctx) return;
const { postid } = ctx;
const res = await (await fetch('/api/v2/tags/' + encodeURIComponent(postid) + '/toggle', {
method: 'PUT',
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
})).json();
renderTags(res.tags);
const isNsfw = res.tags.some(t => t.id == 2);
const isUntagged = res.tags.length === 0;
const toggleBtn = document.querySelector('button#a_toggle');
if (toggleBtn) {
toggleBtn.classList.toggle('is-nsfw', isNsfw && !isUntagged);
toggleBtn.classList.toggle('is-sfw', !isNsfw && !isUntagged);
toggleBtn.classList.toggle('is-untagged', isUntagged);
const labels = { true: 'NSFW', false: 'SFW' };
toggleBtn.textContent = isUntagged ? '?' : (isNsfw ? 'NSFW' : 'SFW');
}
};
let favSeq = 0;
const toggleFavEvent = async (e) => {
// e is the click event or undefined
if (e && typeof e.preventDefault === 'function') e.preventDefault();
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
if (typeof window.flashMessage === 'function') {
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
}
return;
}
const favoBtns = document.querySelectorAll("#a_favo");
if (!favoBtns.length) return;
const firstFavoBtn = favoBtns[0];
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
let localId = firstFavoBtn?.dataset?.localId;
const ctx = getContext();
const postid = localId ? Number(localId) : ctx?.postid;
if (!postid && !chanUrl) return;
if (!ctx) return;
const { postid } = ctx;
const wasAlreadyFav = favoBtns[0].classList.contains('fa-solid') && !favoBtns[0].classList.contains('fa-spinner');
const isNowFav = !wasAlreadyFav;
// Read state BEFORE the API call so we know which direction to toggle
const favoBtn = document.querySelector("#a_favo");
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
const setFavoUi = (isFav) => {
favoBtns.forEach(btn => {
btn.classList.remove('fa-spinner', 'fa-spin');
btn.classList.add('iconset', 'fa-heart');
btn.classList.toggle('fa-solid', isFav);
btn.classList.toggle('fa-regular', !isFav);
btn.title = isFav ? (window.f0ckI18n?.fav_remove || 'Remove from favorites') : (window.f0ckI18n?.fav_add || 'Favorite');
});
};
// 1. Instantly apply client UI
setFavoUi(isNowFav);
// Micro-animation for tactile pop
favoBtns.forEach(btn => {
btn.classList.remove('fav-pop');
void btn.offsetWidth;
btn.classList.add('fav-pop');
const res = await post('/api/v2/togglefav', {
postid: postid
});
// Instant flash message & vibration feedback
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
if (navigator.vibrate) navigator.vibrate(50);
if (postid && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
// Optimistically update #favs badge list
const favcontainer = document.querySelector('#favs');
const prevFavsHtml = favcontainer ? favcontainer.innerHTML : '';
const prevFavsHidden = favcontainer ? favcontainer.hidden : true;
if (favcontainer) {
const currentUser = (window.f0ckSession?.user || '').toLowerCase();
const isAnon = !!(window.f0ckSession?.is_anon || window.f0ckSession?.user === 'anonymous');
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
const findSelfBadge = () => {
const selfBadges = favcontainer.querySelectorAll('[data-self="true"]');
if (selfBadges.length) return selfBadges[0];
if (currentUser && currentUser !== 'anonymous') {
const links = favcontainer.querySelectorAll('a[href]');
for (const a of links) {
const path = a.getAttribute('href') || '';
if (path.toLowerCase().endsWith('/user/' + currentUser)) return a;
}
}
return null;
};
if (!isNowFav) {
const selfBadge = findSelfBadge();
if (selfBadge) selfBadge.remove();
if (favcontainer.children.length === 0) {
favcontainer.hidden = true;
}
} else {
let selfBadge = findSelfBadge();
if (!selfBadge) {
selfBadge = document.createElement('a');
selfBadge.dataset.self = 'true';
selfBadge.setAttribute('flow', 'up');
if (isAnonymized || isAnon) {
selfBadge.className = 'ghost-fav';
selfBadge.setAttribute('tooltip', 'anonymous');
selfBadge.style.cursor = 'default';
const img = document.createElement('img');
img.src = '/a/default.png';
img.style.height = "32px";
img.style.width = "32px";
selfBadge.appendChild(img);
} else {
selfBadge.href = `/user/${currentUser}`;
selfBadge.setAttribute('tooltip', window.f0ckSession?.display_name || window.f0ckSession?.user || 'anonymous');
const img = document.createElement('img');
const avatarFile = window.f0ckSession?.avatar_file;
const avatar = window.f0ckSession?.avatar;
img.src = avatarFile ? `/a/${avatarFile}` : (avatar ? `/t/${avatar}.webp` : '/a/default.png');
img.style.height = "32px";
img.style.width = "32px";
if (window.f0ckSession?.username_color) img.style.borderColor = window.f0ckSession.username_color;
selfBadge.appendChild(img);
}
favcontainer.appendChild(selfBadge);
}
favcontainer.hidden = false;
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;
const mySeq = ++favSeq;
if (favoBtn) {
favoBtn.classList.toggle('fa-solid', isNowFav);
favoBtn.classList.toggle('fa-regular', !isNowFav);
}
// 2. Run server operation in background — can take as long as it needs
(async () => {
try {
// If viewing un-rehosted 4chan post, auto-rehost first
if (isChan && !localId && chanUrl) {
if (mySeq !== favSeq) return;
const csrfToken = window.f0ckSession?.csrf_token || '';
const rehostResp = await fetch('/api/v2/scroller/rehost', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
},
body: new URLSearchParams({
url: chanUrl,
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
})
});
const rehostData = await rehostResp.json();
if (rehostData.success && rehostData.item_id) {
localId = rehostData.item_id;
favoBtns.forEach(btn => {
btn.dataset.itemId = localId;
btn.dataset.localId = localId;
});
const commentsEl = document.querySelector("#comments-container");
if (commentsEl) commentsEl.dataset.itemId = localId;
const infoEl = document.querySelector("#a_info");
if (infoEl) infoEl.dataset.itemId = localId;
// span#favs
const favcontainer = document.querySelector('#favs');
favcontainer.innerHTML = "";
if (res.favs.length > 0) {
res.favs.forEach(f => {
const a = document.createElement('a');
a.href = `/user/${f.user}`;
a.setAttribute('tooltip', f.display_name || f.user);
a.setAttribute('flow', 'up');
const timeEl = document.querySelector('time.timeago');
if (timeEl) {
const nowIso = new Date().toISOString();
timeEl.dataset.iso = nowIso;
const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
timeEl.setAttribute('tooltip', fullDate);
timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
}
const img = document.createElement('img');
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
img.style.height = "32px";
img.style.width = "32px";
if (f.username_color) img.style.borderColor = f.username_color;
if (chanRehostBtn) {
chanRehostBtn.classList.add('rehosted');
chanRehostBtn.outerHTML = `
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
</span>
`;
}
} else {
throw new Error(rehostData.msg || 'Rehost failed');
}
}
const effectivePostId = localId ? Number(localId) : (postid || getContext()?.postid);
if (!effectivePostId) {
throw new Error('Missing post ID');
}
if (mySeq !== favSeq) return;
const res = await post('/api/v2/togglefav', {
postid: effectivePostId,
chan_url: chanUrl,
action: isNowFav ? 'add' : 'delete',
favorited: isNowFav
a.appendChild(img);
favcontainer.appendChild(a);
});
if (mySeq !== favSeq) return;
if (res && res.success) {
if (window.invalidateItemCache) {
window.invalidateItemCache(effectivePostId);
}
const finalIsFav = (res.favorited !== undefined) ? !!res.favorited : isNowFav;
setFavoUi(finalIsFav);
// Render authoritative favs list from server
const curFavContainer = document.querySelector('#favs');
if (curFavContainer && Array.isArray(res.favs)) {
curFavContainer.innerHTML = "";
if (res.favs.length > 0) {
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
const fragment = document.createDocumentFragment();
res.favs.forEach(f => {
const isSelf = window.f0ckSession && (
(window.f0ckSession.id && f.user_id && Number(window.f0ckSession.id) === Number(f.user_id)) ||
(window.f0ckSession.user && f.user && window.f0ckSession.user.toLowerCase() === f.user.toLowerCase()) ||
(window.f0ckSession.login && f.login && window.f0ckSession.login.toLowerCase() === f.login.toLowerCase())
);
const isFavAnon = f.is_anon || f.user === 'anonymous' || (typeof f.user === 'string' && f.user.startsWith('anon_'));
if (f.hide_fav_badge && !isSelf) {
const a = document.createElement('a');
a.className = 'ghost-fav';
a.setAttribute('tooltip', '?');
a.setAttribute('flow', 'up');
a.style.cursor = 'default';
const img = document.createElement('img');
img.src = '/s/img/ghost_fav.svg';
img.style.height = "32px";
img.style.width = "32px";
a.appendChild(img);
fragment.appendChild(a);
} else if (isAnonymized || isFavAnon) {
const a = document.createElement('a');
a.className = 'ghost-fav';
if (isSelf) a.dataset.self = 'true';
a.setAttribute('tooltip', 'anonymous');
a.setAttribute('flow', 'up');
a.style.cursor = 'default';
const img = document.createElement('img');
img.src = '/a/default.png';
img.style.height = "32px";
img.style.width = "32px";
a.appendChild(img);
fragment.appendChild(a);
} else {
const a = document.createElement('a');
if (isSelf) a.dataset.self = 'true';
a.href = `/user/${(f.user || '').toLowerCase()}`;
a.setAttribute('tooltip', f.display_name || f.user || 'anonymous');
a.setAttribute('flow', 'up');
const img = document.createElement('img');
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
img.style.height = "32px";
img.style.width = "32px";
if (f.username_color) img.style.borderColor = f.username_color;
a.appendChild(img);
fragment.appendChild(a);
}
});
curFavContainer.appendChild(fragment);
curFavContainer.hidden = false;
} else {
curFavContainer.hidden = true;
}
}
} else {
if (mySeq !== favSeq) return;
setFavoUi(wasAlreadyFav);
const curFavContainer = document.querySelector('#favs');
if (curFavContainer) {
curFavContainer.innerHTML = prevFavsHtml;
curFavContainer.hidden = prevFavsHidden;
}
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
if (typeof window.flashMessage === 'function') {
window.flashMessage(errMsg, 3000, 'error');
}
}
} catch (err) {
console.error('[CHAN-FAV] Error during background favorite sync:', err);
if (mySeq !== favSeq) return;
setFavoUi(wasAlreadyFav);
const curFavContainer = document.querySelector('#favs');
if (curFavContainer) {
curFavContainer.innerHTML = prevFavsHtml;
curFavContainer.hidden = prevFavsHidden;
}
if (typeof window.flashMessage === 'function') {
window.flashMessage('Failed to update favorite.', 3000, 'error');
}
favcontainer.hidden = false;
} else {
favcontainer.hidden = true;
}
})();
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
if (navigator.vibrate) navigator.vibrate(50);
}
else {
// lul
}
};
// Event Delegation
+1 -1
View File
@@ -419,7 +419,7 @@ if (!window.UserCommentSystem) {
? `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"><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}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
}
startLiveTimestamps() {
+24 -133
View File
@@ -31,6 +31,7 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
</svg>
</button>
<div class="v0ck_settings_menu v0ck_hidden">
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank" tabindex="-1">SWF</button>
<div class="v0ck_menu_item v0ck_bg_row">
<span class="v0ck_switch_label">Background</span>
<div id="togglebg" class="v0ck_cool_switch" title="Toggle Background"></div>
@@ -82,15 +83,11 @@ const updateHoverStates = () => {
const cwModal = document.getElementById('content-warning-modal');
if ((cwModal && cwModal.style.display !== 'none') || isMobile) return;
// A Square Clicker game covers the page: the cursor over the player's area is playing, not hovering
const inGame = document.body.classList.contains('sqc-session');
document.querySelectorAll('.v0ck').forEach(p => {
const rect = p.getBoundingClientRect();
const isOver = !inGame && mouseX >= rect.left && mouseX <= rect.right &&
const isOver = mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom;
p.classList.toggle("v0ck_hover", isOver);
const gal = p.closest('.album-gallery-container');
if (gal) gal.classList.toggle("v0ck_hover", isOver);
});
};
@@ -120,8 +117,6 @@ class v0ck {
if (mouseX >= rect.left && mouseX <= rect.right &&
mouseY >= rect.top && mouseY <= rect.bottom) {
parent.classList.add("v0ck_hover", "v0ck_no_transition");
const gal = parent.closest('.album-gallery-container');
if (gal) gal.classList.add("v0ck_hover");
// Remove no-transition after a frame
setTimeout(() => parent.classList.remove("v0ck_no_transition"), 50);
}
@@ -138,73 +133,18 @@ class v0ck {
window.f0ckDebug("[v0ck] Player initialized for", tagName);
}
if (tagName === "audio") {
const poster = elem.getAttribute('poster');
const player = elem.closest('.v0ck') || elem.parentElement;
const isFallback = !poster || poster === '/s/img/200.gif' || poster.includes('audio.webp') || poster.includes('music.webp');
if (player) {
let ph = player.querySelector(':scope > .sidebar-media-placeholder.audio');
if (!ph) {
ph = document.createElement('div');
ph.className = 'sidebar-media-placeholder audio';
ph.innerHTML = '<div class="audio-cover-circle"><i class="fa-solid fa-music"></i></div>';
player.prepend(ph);
}
let coverCircle = ph.querySelector('.audio-cover-circle');
if (!coverCircle) {
coverCircle = document.createElement('div');
coverCircle.className = 'audio-cover-circle';
coverCircle.innerHTML = '<i class="fa-solid fa-music"></i>';
ph.insertBefore(coverCircle, ph.firstChild);
} else {
if (!coverCircle.querySelector('i')) {
const existingI = ph.querySelector(':scope > i');
if (existingI) {
coverCircle.appendChild(existingI);
} else {
coverCircle.insertAdjacentHTML('beforeend', '<i class="fa-solid fa-music"></i>');
}
}
}
player.style.backgroundImage = 'none';
player.style.backgroundColor = 'transparent';
if (!isFallback) {
coverCircle._origBgImage = `url('${poster}')`;
if (typeof window.updateCoverArtSolidMode === 'function') {
window.updateCoverArtSolidMode();
} else {
const tuning = window.audioVisualizerTuning;
const showInEye = tuning ? (tuning.showCoverInEye !== undefined ? Number(tuning.showCoverInEye) === 1 : (tuning.showCoverArt !== undefined ? Number(tuning.showCoverArt) === 1 : Number(tuning.solidCover) === 0)) : false;
if (showInEye) {
coverCircle.style.backgroundImage = `url('${poster}')`;
ph.classList.add('has-cover');
} else {
coverCircle.style.backgroundImage = 'none';
ph.classList.remove('has-cover');
}
}
} else {
coverCircle._origBgImage = null;
coverCircle.style.backgroundImage = 'none';
ph.classList.remove('has-cover');
if (typeof window.updateCoverArtSolidMode === 'function') {
window.updateCoverArtSolidMode();
}
}
}
if (tagName === "audio" && elem.hasAttribute('poster')) { // set cover
const player = document.querySelector('.v0ck');
player.style.backgroundImage = `url('${elem.getAttribute('poster')}')`;
}
}
else
return console.error("nope");
const inst = this.init(elem);
if (elem && elem.tagName === 'AUDIO' && window.initVisualizer) {
window.initVisualizer(elem);
}
return inst;
return this.init(elem);
}
init(elem) {
const player = elem.closest('.v0ck') || document.querySelector('.v0ck');
const player = document.querySelector('.v0ck');
const video = elem;
video.removeAttribute('controls');
video.removeAttribute('autoplay');
@@ -238,23 +178,6 @@ class v0ck {
let wasPausedWhenStarted = false;
// Mobile tap-to-show-controls: true when this touch revealed the controls bar
let controlsJustShown = false;
const setHover = (active) => {
if (active) {
player.classList.add('v0ck_hover');
const gal = player.closest('.album-gallery-container');
if (gal) gal.classList.add('v0ck_hover');
} else {
player.classList.remove('v0ck_hover');
const gal = player.closest('.album-gallery-container');
if (gal) {
gal.classList.remove('v0ck_hover');
gal.classList.remove('strip-peek');
const strip = gal.querySelector('.album-thumbnails-strip');
if (strip) strip.classList.remove('strip-peek');
}
}
};
const speedIndicator = player.querySelector('.v0ck_speed_indicator');
// (mouse position is now tracked via docMouseX/docMouseY in resetControlsTimer block)
@@ -277,21 +200,10 @@ class v0ck {
return video[video.paused ? 'play' : 'pause']();
}
function updatePlayIcon() {
const isPlaying = !video.paused;
toggle.classList.toggle('playing', isPlaying);
player.classList.toggle('paused', !isPlaying);
toggle.setAttribute('title', isPlaying ? 'Pause' : 'Play');
const playIcon = toggle.querySelector('#v0ck_svg_play');
const pauseIcon = toggle.querySelector('#v0ck_svg_pause');
if (playIcon && pauseIcon) {
playIcon.classList.toggle('v0ck_hidden', isPlaying);
pauseIcon.classList.toggle('v0ck_hidden', !isPlaying);
} else {
[...toggle.querySelectorAll('use')].forEach(icon => {
const isPlaySvg = icon.id === 'v0ck_svg_play' || icon.getAttribute('href')?.includes('play');
icon.classList.toggle('v0ck_hidden', isPlaySvg ? isPlaying : !isPlaying);
});
}
toggle.classList.toggle('playing');
player.classList.toggle('paused');
toggle.setAttribute('title', toggle.classList.contains('playing') ? 'Pause' : 'Play');
[...toggle.querySelectorAll('use')].forEach(icon => icon.classList.toggle('v0ck_hidden'));
}
function toggleMute(e) {
if (video.volume === 0)
@@ -355,7 +267,7 @@ class v0ck {
}
function enterFullScreen() {
if (document.fullscreenElement) return;
const target = player;
const target = document.getElementById('main') || player;
if (/(iPad|iPhone|iPod)/gi.test(navigator.platform))
video.webkitEnterFullscreen();
else
@@ -395,7 +307,7 @@ class v0ck {
if (isMobile && controlsJustShown) {
// First tap: controls were just revealed by this touch — don't toggle play
controlsJustShown = false;
setHover(true);
player.classList.add('v0ck_hover');
return;
}
controlsJustShown = false;
@@ -405,7 +317,7 @@ class v0ck {
toggle.addEventListener('click', togglePlay);
overlay.addEventListener('click', e => {
e.stopPropagation();
setHover(true);
player.classList.add('v0ck_hover');
togglePlay();
});
video.addEventListener('play', updatePlayIcon);
@@ -816,33 +728,13 @@ class v0ck {
// Attempt autoplay and show overlay if blocked
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
if (shouldAutoplay) {
if (!video.paused) {
player.classList.remove('v0ck_initial');
} else {
const playPromise = video.play();
if (playPromise !== undefined) {
playPromise.then(() => {
player.classList.remove('v0ck_initial');
}).catch((err) => {
if (err && err.name === 'AbortError') {
const onCanPlay = () => {
video.removeEventListener('canplay', onCanPlay);
if (video.paused) {
video.play().then(() => {
player.classList.remove('v0ck_initial');
}).catch(() => {
player.classList.add('v0ck_initial');
});
}
};
video.addEventListener('canplay', onCanPlay, { once: true });
} else {
player.classList.add('v0ck_initial');
}
});
} else if (video.paused) {
const playPromise = togglePlay();
if (playPromise !== undefined) {
playPromise.catch(() => {
player.classList.add('v0ck_initial');
}
});
} else if (video.paused) {
player.classList.add('v0ck_initial');
}
} else {
player.classList.add('v0ck_initial');
@@ -892,7 +784,6 @@ class v0ck {
// Close menu/panel when clicking outside
document.addEventListener('click', (e) => {
if (!e.isTrusted) return;
const isFlashYankUI = e.target.closest('#flash-yank-ui');
const isInsidePlayer = player.contains(e.target);
@@ -904,7 +795,7 @@ class v0ck {
}
if (isMobile && !isInsidePlayer && !isFlashYankUI) {
setHover(false);
player.classList.remove('v0ck_hover');
}
});
@@ -1001,7 +892,7 @@ class v0ck {
const isFullscreen = player.classList.contains('v0ck_fullscreen');
if (!video.paused || isFullscreen) {
controlsTimer = setTimeout(() => {
setHover(false);
player.classList.remove('v0ck_hover');
if (settingsMenu && !settingsMenu.classList.contains('v0ck_hidden')) {
settingsMenu.classList.add('v0ck_hidden');
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
@@ -1023,7 +914,7 @@ class v0ck {
docMouseX = e.clientX;
docMouseY = e.clientY;
}
setHover(true);
player.classList.add('v0ck_hover');
resetControlsTimer();
}
@@ -1071,7 +962,7 @@ class v0ck {
}
docMouseX = -1;
docMouseY = -1;
setHover(false);
player.classList.remove('v0ck_hover');
clearTimeout(controlsTimer);
});
Binary file not shown.
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'f0ckm-pwa';
const CACHE_NAME = 'w0bm-pwa-v9';
const ASSETS_TO_CACHE = [
'/',
'/s/css/f0ckm.css',
-4
View File
@@ -1,4 +0,0 @@
{
"lastId": 48551,
"timestamp": "2026-09-13T02:54:17.890Z"
}
-56
View File
@@ -1,56 +0,0 @@
import crypto from "crypto";
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
// Manage upload-only API keys for chat uploads (POST /api/chat/upload).
// The plain key is shown once on creation; only its sha256 is stored.
const [cmd, arg, maxMb] = process.argv.slice(2);
const usage = () => {
console.error("Usage:");
console.error(" node scripts/chat-upload-key.mjs create <name> [max_mb]");
console.error(" node scripts/chat-upload-key.mjs list");
console.error(" node scripts/chat-upload-key.mjs revoke <id>");
process.exit(1);
};
async function run() {
if (cmd === "create") {
if (!arg) usage();
const key = "cu_" + crypto.randomBytes(24).toString("base64url");
const maxBytes = maxMb ? parseInt(maxMb, 10) * 1024 * 1024 : null;
const [row] = await db`
INSERT INTO upload_api_keys ${db({ name: arg, key_hash: lib.sha256(key), max_bytes: maxBytes })}
RETURNING id
`;
console.log(`Created key #${row.id} "${arg}"${maxBytes ? ` (max ${maxMb} MB)` : ""}`);
console.log(`Key (shown once): ${key}`);
} else if (cmd === "list") {
const rows = await db`
SELECT k.id, k.name, k.max_bytes, k.revoked, k.created_at, k.last_used_at,
count(c.id)::int AS files, coalesce(sum(c.size_bytes), 0)::bigint AS bytes
FROM upload_api_keys k
LEFT JOIN chat_uploads c ON c.key_id = k.id
GROUP BY k.id ORDER BY k.id
`;
console.table(rows.map(r => ({
id: r.id,
name: r.name,
revoked: r.revoked,
max_mb: r.max_bytes ? Number(r.max_bytes) / 1048576 : "-",
files: r.files,
used_mb: (Number(r.bytes) / 1048576).toFixed(1),
last_used: r.last_used_at ? r.last_used_at.toISOString() : "-"
})));
} else if (cmd === "revoke") {
const id = parseInt(arg, 10);
if (!id) usage();
const rows = await db`UPDATE upload_api_keys SET revoked = true WHERE id = ${id} RETURNING id`;
console.log(rows.length ? `Revoked key #${id}` : `No key #${id}`);
} else {
usage();
}
}
run().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.end());
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import YAML from 'yaml';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootDir = path.resolve(__dirname, '..');
const DEFAULT_CONFIG_YAML = path.join(rootDir, 'config.yaml');
const DEFAULT_CONFIG_JSON = path.join(rootDir, 'config.json');
const EXAMPLE_CONFIG_YAML = path.join(rootDir, 'config_example.yaml');
const EXAMPLE_CONFIG_JSON = path.join(rootDir, 'config_example.json');
function convertYamlToJson(yamlPath, jsonPath) {
if (!fs.existsSync(yamlPath)) {
console.error(`[CONFIG-GEN] Error: Source YAML file not found at ${yamlPath}`);
return false;
}
try {
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
const parsed = YAML.parse(yamlContent);
if (parsed === null || typeof parsed !== 'object') {
throw new Error('Parsed YAML is not an object');
}
const jsonContent = JSON.stringify(parsed, null, 2) + '\n';
fs.writeFileSync(jsonPath, jsonContent, 'utf8');
console.log(`[CONFIG-GEN] Generated ${path.relative(rootDir, jsonPath)} from ${path.relative(rootDir, yamlPath)}`);
return true;
} catch (err) {
console.error(`[CONFIG-GEN] Failed to generate JSON from ${path.relative(rootDir, yamlPath)}: ${err.message}`);
return false;
}
}
function convertJsonToYaml(jsonPath, yamlPath) {
if (!fs.existsSync(jsonPath)) {
console.error(`[CONFIG-GEN] Error: Source JSON file not found at ${jsonPath}`);
return false;
}
try {
const jsonContent = fs.readFileSync(jsonPath, 'utf8');
const parsed = JSON.parse(jsonContent);
const doc = new YAML.Document(parsed);
const yamlContent = String(doc);
fs.writeFileSync(yamlPath, yamlContent, 'utf8');
console.log(`[CONFIG-GEN] Generated ${path.relative(rootDir, yamlPath)} from ${path.relative(rootDir, jsonPath)}`);
return true;
} catch (err) {
console.error(`[CONFIG-GEN] Failed to generate YAML from ${path.relative(rootDir, jsonPath)}: ${err.message}`);
return false;
}
}
const args = process.argv.slice(2);
const isWatch = args.includes('--watch') || args.includes('-w');
const isToYaml = args.includes('--to-yaml');
const includeAll = args.includes('--all') || args.includes('--example');
if (isToYaml) {
convertJsonToYaml(DEFAULT_CONFIG_JSON, DEFAULT_CONFIG_YAML);
if (includeAll || fs.existsSync(EXAMPLE_CONFIG_JSON)) {
convertJsonToYaml(EXAMPLE_CONFIG_JSON, EXAMPLE_CONFIG_YAML);
}
process.exit(0);
}
// Initial generation
let success = convertYamlToJson(DEFAULT_CONFIG_YAML, DEFAULT_CONFIG_JSON);
if (includeAll && fs.existsSync(EXAMPLE_CONFIG_YAML)) {
convertYamlToJson(EXAMPLE_CONFIG_YAML, EXAMPLE_CONFIG_JSON);
}
if (!success && !isWatch) {
process.exit(1);
}
if (isWatch) {
console.log(`[CONFIG-GEN] Watching ${path.relative(rootDir, DEFAULT_CONFIG_YAML)} for changes... (Press Ctrl+C to stop)`);
let debounceTimer = null;
fs.watch(DEFAULT_CONFIG_YAML, (eventType) => {
if (eventType === 'change' || eventType === 'rename') {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`[CONFIG-GEN] File change detected, re-generating config.json...`);
convertYamlToJson(DEFAULT_CONFIG_YAML, DEFAULT_CONFIG_JSON);
}, 100);
}
});
}
+8 -72
View File
@@ -19,10 +19,6 @@ import queue from "../src/inc/queue.mjs";
import cfg from "../src/inc/config.mjs";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const STATE_FILE = path.join(__dirname, '.regen_state.json');
const args = process.argv.slice(2);
@@ -35,8 +31,6 @@ if (args.length === 0) {
console.log(' node regen.mjs --pdf - Regenerate all PDF items');
console.log(' node regen.mjs --youtube - Regenerate all YouTube thumbnails');
console.log(' node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items');
console.log(' --from <id> - Resume/start from a specific item ID (inclusive)');
console.log(' --resume - Resume from last checkpoint saved in .regen_state.json');
process.exit(0);
}
@@ -51,33 +45,6 @@ const THUMB_SIZE = 512;
const blurOnly = args.includes('--blur');
console.log(`[regen] Thumb size: ${THUMB_SIZE}px\n`);
let fromId = null;
const fromIdx = args.indexOf('--from');
if (fromIdx !== -1 && args[fromIdx + 1]) {
fromId = parseInt(args[fromIdx + 1], 10);
if (isNaN(fromId)) {
console.error('Invalid ID provided for --from');
process.exit(1);
}
}
if (!fromId && args.includes('--resume')) {
try {
const raw = await fs.readFile(STATE_FILE, 'utf8');
const state = JSON.parse(raw);
if (state.lastId) {
fromId = state.lastId;
console.log(`[regen] Resuming from checkpoint at item ID: ${fromId}\n`);
}
} catch (e) {
console.warn(`[regen] No previous state checkpoint found to resume from.\n`);
}
}
if (fromId) {
console.log(`[regen] Starting from item ID >= ${fromId}\n`);
}
const regen = async (item) => {
const { id, dest, mime, src } = item;
@@ -121,78 +88,47 @@ const regen = async (item) => {
};
// Shared NOT IN clause for Flash exclusion
const flashExclude = db`mime NOT IN ${db(FLASH_MIMES)}`;
const fromClause = fromId ? db`AND id >= ${fromId}` : db``;
const saveState = async (id) => {
try {
await fs.writeFile(STATE_FILE, JSON.stringify({ lastId: id, timestamp: new Date().toISOString() }, null, 2));
} catch (_) {}
};
let currentItemId = null;
process.on('SIGINT', async () => {
if (currentItemId) {
await saveState(currentItemId);
console.log(`\n[regen] Interrupted! Saved state at item ID ${currentItemId}.`);
console.log(`[regen] Resume anytime with: node scripts/regen.mjs --resume (or --from ${currentItemId})\n`);
}
process.exit(130);
});
const flashExclude = db`mime NOT IN (${db(FLASH_MIMES)})`;
try {
let items;
if (args.includes('--all')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND ${flashExclude} ${fromClause} ORDER BY id`;
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND ${flashExclude} ORDER BY id`;
console.log(`Regenerating ALL ${items.length} non-Flash items...\n`);
} else if (args.includes('--audio')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ${fromClause} ORDER BY id`;
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime ILIKE 'audio/%' ORDER BY id`;
console.log(`Regenerating ${items.length} audio items...\n`);
} else if (args.includes('--pdf')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'application/pdf' ${fromClause} ORDER BY id`;
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'application/pdf' ORDER BY id`;
console.log(`Regenerating ${items.length} PDF items...\n`);
} else if (args.includes('--youtube')) {
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'video/youtube' ${fromClause} ORDER BY id`;
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false AND mime = 'video/youtube' ORDER BY id`;
console.log(`Regenerating ${items.length} YouTube items...\n`);
} else if (blurOnly) {
items = await db`
SELECT id, dest, mime, src
FROM items
WHERE active = true AND is_deleted = false AND ${flashExclude} ${fromClause}
WHERE active = true AND is_deleted = false AND ${flashExclude}
ORDER BY id
`;
console.log(`Regenerating ONLY blurred thumbnails for all ${items.length} non-Flash items...\n`);
} else {
const positionalArgs = [];
for (let i = 0; i < args.length; i++) {
if (args[i] === '--from') {
i++;
continue;
}
if (args[i].startsWith('--')) continue;
positionalArgs.push(args[i]);
}
const ids = positionalArgs.map(Number).filter(n => !isNaN(n) && n > 0);
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
if (ids.length === 0) {
console.error('No valid item IDs provided.');
process.exit(1);
}
items = await db`SELECT id, dest, mime, src FROM items WHERE id IN ${db(ids)} ${fromClause} ORDER BY id`;
items = await db`SELECT id, dest, mime, src FROM items WHERE id IN ${db(ids)} ORDER BY id`;
const found = items.map(i => i.id);
const missing = ids.filter(id => !found.includes(id));
if (missing.length) console.warn(`Items not found: ${missing.join(', ')}\n`);
}
for (const item of items) {
currentItemId = item.id;
await regen(item);
await saveState(item.id);
}
// Clean up state file on normal completion
await fs.unlink(STATE_FILE).catch(() => {});
console.log(`\nDone. ${items.length} items processed.`);
process.exit(0);
} catch (err) {
-8
View File
@@ -44,10 +44,6 @@ export const handleAvatarUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
console.log('[AVATAR HANDLER] Authorized:', req.session.user);
@@ -210,10 +206,6 @@ export const handleAvatarDelete = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
// CSRF validation — must happen after session lookup since flummpress middlewares run in parallel
-8
View File
@@ -42,10 +42,6 @@ export const handleBannerUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
console.log('[BANNER HANDLER] Authorized:', req.session.user);
@@ -254,10 +250,6 @@ export const handleBannerDelete = async (req, res) => {
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
}
if (user[0].user && user[0].user.startsWith('anon_')) {
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
}
req.session = user[0];
// CSRF validation
-201
View File
@@ -1,201 +0,0 @@
import cfg from "./inc/config.mjs";
import path from "path";
import { promises as fs } from "fs";
import db from "./inc/sql.mjs";
import lib from "./inc/lib.mjs";
import { parseMultipart, collectBody } from "./inc/multipart.mjs";
import { execFile as _execFile } from "child_process";
import { promisify } from "util";
import audit from "./inc/audit.mjs";
import { getBrandImageUrl, setBrandImageUrl } from "./inc/settings.mjs";
const execFile = promisify(_execFile);
const sendJson = (res, data, code = 200) => {
const body = JSON.stringify(data);
res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
/** Shared admin session + CSRF lookup */
async function getAdminSession(req, res) {
if (!req.cookies || !req.cookies.session) {
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
return null;
}
const user = await db`
SELECT "user".id, "user".login, "user".user, "user".admin,
"user_sessions".id AS sess_id, "user_sessions".csrf_token
FROM "user_sessions"
LEFT JOIN "user" ON "user".id = "user_sessions".user_id
WHERE "user_sessions".session = ${lib.sha256(req.cookies.session)}
LIMIT 1
`;
if (user.length === 0 || !user[0].admin) {
sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
return null;
}
const session = user[0];
// CSRF validation via header
if (session.csrf_token) {
const csrfToken = req.headers['x-csrf-token'];
if (!csrfToken || csrfToken !== session.csrf_token) {
console.warn(`[CSRF] Blocked brand image request for user ${session.user}. Invalid token.`);
sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
return null;
}
}
return session;
}
/** Delete the physical file for a stored brand image URL (if any) */
async function deleteOldBrandFile() {
const current = getBrandImageUrl();
if (!current) return;
// Strip query string to get the bare filename
const urlPath = current.split('?')[0];
// Only delete files that live under our navbar img dir
if (!urlPath.startsWith('/s/img/navbar/brand.')) return;
const filename = path.basename(urlPath);
const filePath = path.join(cfg.paths.s, 'img', 'navbar', filename);
await fs.unlink(filePath).catch(() => {});
}
/** Persist brand image URL to site_settings DB and in-memory setting */
async function persistBrandImage(url) {
setBrandImageUrl(url);
await db`
INSERT INTO site_settings (key, value)
VALUES ('brand_image_url', ${url})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
}
// ── Upload ─────────────────────────────────────────────────────────────────
export const handleBrandImageUpload = async (req, res) => {
console.log('[BRAND UPLOAD] Started');
const session = await getAdminSession(req, res);
if (!session) return;
try {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/boundary=(.+)$/);
if (!boundaryMatch) {
return sendJson(res, { success: false, msg: 'Invalid content type — multipart boundary missing' }, 400);
}
const body = await collectBody(req, 10 * 1024 * 1024); // 10 MB request body cap
const parts = parseMultipart(body, boundaryMatch[1]);
const file = parts.file;
if (!file || !file.data || file.data.length === 0) {
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
}
// 5 MB cap
const maxSize = 5 * 1024 * 1024;
if (file.data.length > maxSize) {
return sendJson(res, {
success: false,
msg: `File too large. Maximum is 5 MB, got ${(file.data.length / 1024 / 1024).toFixed(2)} MB`
}, 400);
}
const allowedMimes = ['image/gif', 'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/svg+xml'];
const mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
if (!allowedMimes.includes(mime)) {
return sendJson(res, {
success: false,
msg: `Invalid file type. Allowed: gif, jpg, png, webp, svg. Got: ${mime}`
}, 400);
}
const imgDir = path.join(cfg.paths.s, 'img', 'navbar');
await fs.mkdir(imgDir, { recursive: true });
await fs.mkdir(cfg.paths.tmp, { recursive: true });
const isSvg = mime === 'image/svg+xml';
const ts = Date.now();
// Timestamp baked into the filename — every upload is a unique file
const outFilename = isSvg ? `brand.${ts}.svg` : `brand.${ts}.webp`;
const tmpPath = path.join(cfg.paths.tmp, `brand_tmp_${ts}`);
const finalPath = path.join(imgDir, outFilename);
await fs.writeFile(tmpPath, file.data);
if (!isSvg) {
// Verify actual MIME with file(1)
try {
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
const safeActual = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
if (!safeActual.includes(actualMime.trim())) {
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: `Invalid file type detected: ${actualMime.trim()}` }, 400);
}
} catch (_) {
// file(1) not available — skip magic check
}
// Convert to WebP via ImageMagick
try {
await execFile('magick', [tmpPath, '-coalesce', '-quality', '85', finalPath]);
} catch (err) {
console.error('[BRAND UPLOAD] ImageMagick error:', err);
await fs.unlink(tmpPath).catch(() => {});
return sendJson(res, { success: false, msg: 'Failed to process image (ImageMagick required)' }, 500);
}
} else {
// SVG: copy as-is
await fs.copyFile(tmpPath, finalPath);
}
await fs.unlink(tmpPath).catch(() => {});
// Delete the previous brand file from disk
await deleteOldBrandFile();
const publicUrl = `/s/img/navbar/${outFilename}`;
await persistBrandImage(publicUrl);
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: publicUrl })})`.catch(() => {});
await audit.log(session.id, 'update_brand_image', 'system', 0, { url: publicUrl });
console.log('[BRAND UPLOAD] Done:', publicUrl);
return sendJson(res, { success: true, url: publicUrl, msg: 'Brand image updated' });
} catch (err) {
if (err.code === 'BODY_TOO_LARGE') {
return sendJson(res, { success: false, msg: 'File too large (5 MB max)' }, 413);
}
console.error('[BRAND UPLOAD ERROR]', err);
return sendJson(res, { success: false, msg: 'Upload failed: ' + err.message }, 500);
}
};
// ── Delete ─────────────────────────────────────────────────────────────────
export const handleBrandImageDelete = async (req, res) => {
console.log('[BRAND DELETE] Started');
const session = await getAdminSession(req, res);
if (!session) return;
try {
// Delete the physical file before clearing the setting
await deleteOldBrandFile();
await persistBrandImage('');
await db`SELECT pg_notify('brand_image', ${JSON.stringify({ url: null })})`.catch(() => {});
await audit.log(session.id, 'delete_brand_image', 'system', 0, {});
console.log('[BRAND DELETE] Done');
return sendJson(res, { success: true, msg: 'Brand image removed' });
} catch (err) {
console.error('[BRAND DELETE ERROR]', err);
return sendJson(res, { success: false, msg: 'Delete failed: ' + err.message }, 500);
}
};
-237
View File
@@ -1,237 +0,0 @@
/**
* chat_preview_handler.mjs — Link previews for external chat clients (mumh5).
*
* The server fetches the linked page instead of the client, so people posting links cannot
* learn chat participants' IP addresses. Preview images are proxied through signed URLs.
*
* Routes (registered as bypass middlewares in index.mjs):
* GET /api/chat/preview?url=... — page metadata, X-API-Key (upload key) required
* GET /api/chat/preview/image?url=...&sig=... — proxied preview image, signature required
*
* Fetches refuse private, loopback and link-local addresses (checked again on every redirect),
* are size and time limited, and results are cached.
*/
import http from 'http';
import https from 'https';
import dns from 'dns';
import net from 'net';
import crypto from 'crypto';
import cfg from './inc/config.mjs';
import { authKey } from './chat_upload_handler.mjs';
const isEnabled = () => cfg.websrv.chat_uploads !== false && cfg.websrv.chat_link_previews !== false;
const USER_AGENT = 'Mozilla/5.0 (compatible; mumh5-linkpreview/1.0)';
const TIMEOUT_MS = 6000;
const MAX_HTML = 768 * 1024;
const MAX_IMAGE = 5 * 1024 * 1024;
const MAX_REDIRECTS = 4;
const CACHE_TTL = 6 * 3600 * 1000;
const FAIL_TTL = 30 * 60 * 1000;
const CACHE_MAX = 2000;
// Signs image URLs so the image route cannot be used as an open proxy
const SECRET = crypto.randomBytes(32);
const sign = url => crypto.createHmac('sha256', SECRET).update(url).digest('base64url').slice(0, 32);
const cache = new Map();
function sendJson(res, data, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
// ─── Private address guard ────────────────────────────────────────────────────
function isPrivateV4(ip) {
const [a, b] = ip.split('.').map(Number);
return a === 0 || a === 10 || a === 127 || a >= 224 ||
(a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
(a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
}
export function isPrivateAddress(ip) {
if (net.isIPv4(ip)) return isPrivateV4(ip);
const v6 = ip.toLowerCase();
const mapped = /^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
if (mapped) return isPrivateV4(mapped[1]);
return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || /^ff/.test(v6);
}
// DNS lookup that only returns public addresses; the socket connects to exactly these
function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { all: true }, (err, addresses) => {
if (err) return callback(err);
const ok = addresses.filter(a => !isPrivateAddress(a.address));
if (!ok.length) return callback(new Error('Refusing private address'));
if (options?.all) return callback(null, ok);
callback(null, ok[0].address, ok[0].family);
});
}
// GET with redirects, each hop validated; resolves with the response stream
function safeGet(rawUrl, accept, hops = 0) {
return new Promise((resolve, reject) => {
let url;
try { url = new URL(rawUrl); } catch { return reject(new Error('Invalid URL')); }
if (!/^https?:$/.test(url.protocol)) return reject(new Error('Unsupported protocol'));
const host = url.hostname.replace(/^\[|\]$/g, '');
if (net.isIP(host) && isPrivateAddress(host)) return reject(new Error('Refusing private address'));
const lib = url.protocol === 'https:' ? https : http;
const req = lib.get(url, {
lookup: safeLookup,
timeout: TIMEOUT_MS,
headers: { 'User-Agent': USER_AGENT, 'Accept': accept, 'Accept-Language': 'en,*;q=0.5' }
}, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (hops >= MAX_REDIRECTS) return reject(new Error('Too many redirects'));
return resolve(safeGet(new URL(res.headers.location, url).href, accept, hops + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
res.finalUrl = url.href;
resolve(res);
});
req.on('timeout', () => req.destroy(new Error('Timed out')));
req.on('error', reject);
});
}
function readLimited(res, max, stopAt) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const timer = setTimeout(() => res.destroy(new Error('Timed out')), TIMEOUT_MS);
res.on('data', chunk => {
chunks.push(chunk);
size += chunk.length;
// Metadata lives in <head>; stop reading once it is complete
if (size > max || (stopAt && chunk.toString('latin1').toLowerCase().includes(stopAt))) res.destroy();
});
const done = () => { clearTimeout(timer); resolve(Buffer.concat(chunks).subarray(0, max)); };
res.on('end', done);
res.on('close', done);
res.on('error', e => { clearTimeout(timer); chunks.length ? done() : reject(e); });
});
}
// ─── Metadata parsing ─────────────────────────────────────────────────────────
const decodeEntities = s => s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&');
const clean = (s, max) => {
if (!s) return '';
const t = decodeEntities(s).replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
export function parsePreview(html, pageUrl) {
const head = html.split(/<\/head>/i)[0];
const meta = {};
for (const [, attrs] of head.matchAll(/<meta\s+([^>]+?)\/?>/gi)) {
const a = {};
for (const m of attrs.matchAll(/([a-zA-Z:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
a[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
}
const key = (a.property || a.name || '').toLowerCase();
if (key && a.content !== undefined && !(key in meta)) meta[key] = a.content;
}
const titleTag = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(head)?.[1];
const abs = u => { try { const x = new URL(decodeEntities(u), pageUrl); return /^https?:$/.test(x.protocol) ? x.href : ''; } catch { return ''; } };
const image = abs(meta['og:image:secure_url'] || meta['og:image'] || meta['twitter:image'] || meta['twitter:image:src'] || '');
const color = /^#[0-9a-f]{3,8}$/i.test(meta['theme-color'] ?? '') ? meta['theme-color'] : '';
return {
title: clean(meta['og:title'] || meta['twitter:title'] || titleTag, 200),
description: clean(meta['og:description'] || meta['twitter:description'] || meta['description'], 400),
site: clean(meta['og:site_name'] || new URL(pageUrl).hostname.replace(/^www\./, ''), 80),
image,
large: meta['twitter:card'] === 'summary_large_image' || Number(meta['og:image:width']) >= 600,
color
};
}
// ─── Routes ───────────────────────────────────────────────────────────────────
async function buildPreview(url) {
const res = await safeGet(url, 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1');
const type = String(res.headers['content-type'] || '');
if (!/text\/html|application\/xhtml/i.test(type)) {
res.resume();
throw new Error('Not an HTML page');
}
const charset = /charset=([\w-]+)/i.exec(type)?.[1]?.toLowerCase() || 'utf-8';
const body = await readLimited(res, MAX_HTML, '</head>');
let html;
try { html = new TextDecoder(charset).decode(body); } catch { html = body.toString('utf8'); }
const p = parsePreview(html, res.finalUrl);
if (!p.title && !p.description) throw new Error('No preview data');
return { ...p, url: res.finalUrl };
}
export async function handleChatPreview(req, res) {
if (!isEnabled()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const key = await authKey(req, res);
if (!key) return;
const url = String(req.url.qs?.url || new URLSearchParams(req.url.search || '').get('url') || '').slice(0, 2048);
if (!/^https?:\/\//i.test(url)) return sendJson(res, { success: false, msg: 'Invalid URL' }, 400);
const hit = cache.get(url);
let result;
if (hit && hit.expires > Date.now()) {
result = hit.value;
} else {
try {
result = { success: true, ...(await buildPreview(url)) };
cache.set(url, { value: result, expires: Date.now() + CACHE_TTL });
} catch (e) {
result = { success: false, msg: e.message };
cache.set(url, { value: result, expires: Date.now() + FAIL_TTL });
}
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
}
if (!result.success) return sendJson(res, result, 200);
const base = (cfg.main?.url?.full || '').replace(/\/+$/, '');
const image = result.image ? `${base}/api/chat/preview/image?url=${encodeURIComponent(result.image)}&sig=${sign(result.image)}` : '';
return sendJson(res, { ...result, image });
}
export async function handleChatPreviewImage(req, res) {
const fail = (code = 404) => { res.writeHead(code, { 'Content-Type': 'text/plain' }); res.end('Not available'); };
if (!isEnabled()) return fail();
const params = new URLSearchParams(req.url.search || '');
const url = String(req.url.qs?.url || params.get('url') || '');
const sig = String(req.url.qs?.sig || params.get('sig') || '');
const expected = sign(url);
if (!url || sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return fail(403);
try {
const up = await safeGet(url, 'image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.9');
const type = String(up.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
if (!/^image\/(png|jpeg|gif|webp|avif)$/.test(type)) { up.resume(); return fail(415); }
const body = await readLimited(up, MAX_IMAGE + 1);
if (body.length > MAX_IMAGE) return fail(413);
res.writeHead(200, {
'Content-Type': type,
'Content-Length': String(body.length),
'Cache-Control': 'public, max-age=86400',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Access-Control-Allow-Origin': '*',
'Cross-Origin-Resource-Policy': 'cross-origin'
});
res.end(body);
} catch {
return fail(502);
}
}
-341
View File
@@ -1,341 +0,0 @@
/**
* chat_upload_handler.mjs — Temporary public file hosting for external chat clients (mumh5).
*
* Auth is a dedicated upload-only key from the upload_api_keys table (not a user API key),
* managed with scripts/chat-upload-key.mjs. Files live in cfg.paths.cu and always expire.
*
* Routes (registered as bypass middlewares in index.mjs):
* GET /api/chat/upload — allowed types and limits, X-API-Key required
* POST /api/chat/upload — raw file as request body, X-API-Key required
* DELETE /api/chat/upload/:slug — X-Delete-Token (returned by the upload) required
* GET /cu/:slug[/:name] — public download, Range supported
*
* Allowed types follow the normal upload rules: cfg.allowedMimes categories resolved against
* cfg.mimes, overridable with websrv.chat_upload_mimes (same format as fileupload_comments_mimes).
*
* Upload request headers:
* X-API-Key upload key
* Content-Type client mime type; rejected early if not allowed, then verified with `file`
* X-Filename original file name (URI-encoded)
* X-Upload-Expiry optional lifetime: seconds, or 30m / 1h / 24h / 7d / 30d; clamped to the max
*/
import { promises as fs, createReadStream, createWriteStream } from 'fs';
import path from 'path';
import crypto from 'crypto';
import { execFile } from 'child_process';
import db from './inc/sql.mjs';
import lib from './inc/lib.mjs';
import cfg from './inc/config.mjs';
// ─── Config ──────────────────────────────────────────────────────────────────
const UPLOAD_DIR = cfg.paths.cu;
const isEnabled = () => cfg.websrv.chat_uploads !== false;
const maxBytes = () => parseInt(cfg.websrv.chat_upload_max_bytes, 10) || 100 * 1024 * 1024;
const defaultExpiry = () => (parseInt(cfg.websrv.chat_upload_expiry_days, 10) || 30) * 86400;
const maxExpiry = () => (parseInt(cfg.websrv.chat_upload_max_expiry_days, 10) || 30) * 86400;
const ratePerMinute = () => parseInt(cfg.websrv.chat_upload_rate_per_minute, 10) || 30;
// Same resolution as upload_handler: categories ("image") or exact types ("application/pdf")
export function getAllowedChatMimes() {
const src = Array.isArray(cfg.websrv.chat_upload_mimes) ? cfg.websrv.chat_upload_mimes
: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes : null;
const all = Object.keys(cfg.mimes || {});
if (!src) return all;
const cats = src.map(c => String(c).toLowerCase());
return all.filter(m => cats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)));
}
// Authoritative type check, the same `file` call the other upload handlers use
function detectMime(filePath) {
return new Promise(resolve => {
execFile('file', ['--mime-type', '-b', filePath], (err, stdout) => resolve(err ? '' : stdout.trim()));
});
}
fs.mkdir(UPLOAD_DIR, { recursive: true }).catch(e =>
console.error('[CHAT_UP] Failed to create upload dir:', e.message)
);
// ─── Expiry cleanup ───────────────────────────────────────────────────────────
export async function cleanupExpiredChatUploads() {
try {
const expired = await db`SELECT id, slug FROM chat_uploads WHERE expires_at < now()`;
if (!expired.length) return;
for (const row of expired) {
await fs.unlink(path.join(UPLOAD_DIR, row.slug)).catch(() => {});
}
await db`DELETE FROM chat_uploads WHERE id = ANY(${expired.map(r => r.id)})`;
console.log(`[CHAT_UP] Cleanup: removed ${expired.length} expired upload(s)`);
} catch (e) {
console.error('[CHAT_UP] Cleanup error:', e.message);
}
}
// Run once at startup, then hourly
cleanupExpiredChatUploads();
setInterval(cleanupExpiredChatUploads, 60 * 60 * 1000);
// ─── Helpers ──────────────────────────────────────────────────────────────────
function sendJson(res, data, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
// Sliding one-minute window per key id
const rateBuckets = new Map();
function rateLimited(keyId) {
const now = Date.now();
const hits = (rateBuckets.get(keyId) || []).filter(t => now - t < 60000);
const limited = hits.length >= ratePerMinute();
if (!limited) hits.push(now);
rateBuckets.set(keyId, hits);
return limited;
}
async function resolveKey(req) {
const key = req.headers['x-api-key'];
if (!key || typeof key !== 'string' || key.length > 200) return null;
const rows = await db`
SELECT id, name, max_bytes FROM upload_api_keys
WHERE key_hash = ${lib.sha256(key)} AND revoked = false
LIMIT 1
`;
return rows[0] || null;
}
export function parseExpiry(val) {
if (!val) return defaultExpiry();
const m = String(val).trim().toLowerCase().match(/^(\d+)\s*([smhdw]?)$/);
if (!m) return defaultExpiry();
const mult = { '': 1, s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[m[2]];
const secs = parseInt(m[1], 10) * mult;
if (!secs) return defaultExpiry();
return Math.min(Math.max(secs, 60), maxExpiry());
}
// Media plays in the browser; everything else (pdf, archives, flash) is served as a download
const isInlineMime = mime => /^(image|video|audio)\//.test(mime) && mime !== 'image/svg+xml';
function sanitizeName(raw) {
let name;
try { name = decodeURIComponent(String(raw || '')); } catch { name = String(raw || ''); }
name = path.basename(name).replace(/[\x00-\x1f\x7f"\\/]/g, '').trim().slice(0, 120);
return name || 'file';
}
// ─── Info ─────────────────────────────────────────────────────────────────────
const keyLimit = key => Math.min(maxBytes(), key.max_bytes ? Number(key.max_bytes) : Infinity);
export async function authKey(req, res) {
if (!isEnabled()) { sendJson(res, { success: false, msg: 'Not found' }, 404); return null; }
let key;
try { key = await resolveKey(req); } catch (e) {
console.error('[CHAT_UP] Key lookup error:', e.message);
sendJson(res, { success: false, msg: 'Server error' }, 500);
return null;
}
if (!key) sendJson(res, { success: false, msg: 'Invalid API key' }, 401);
return key;
}
// Lets clients validate files before uploading (and doubles as a key check)
export async function handleChatUploadInfo(req, res) {
const key = await authKey(req, res);
if (!key) return;
const mimes = getAllowedChatMimes();
return sendJson(res, {
success: true,
allowed_mimes: mimes,
allowed_extensions: [...new Set(mimes.map(m => cfg.mimes[m]).filter(Boolean))],
max_bytes: keyLimit(key),
default_expiry: defaultExpiry(),
max_expiry: maxExpiry()
});
}
// ─── Upload ───────────────────────────────────────────────────────────────────
export async function handleChatUpload(req, res) {
const key = await authKey(req, res);
if (!key) return;
if (rateLimited(key.id)) return sendJson(res, { success: false, msg: 'Rate limit exceeded' }, 429);
const limit = keyLimit(key);
const declared = parseInt(req.headers['content-length'] || '0', 10);
if (declared > limit) return sendJson(res, { success: false, msg: 'File too large', max_bytes: limit }, 413);
// Cheap early reject on the client-declared type; the real type is checked after writing
const allowed = getAllowedChatMimes();
const hint = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase().slice(0, 100);
if (hint && hint !== 'application/octet-stream' && !allowed.includes(hint)) {
req.resume();
return sendJson(res, { success: false, msg: `File type not allowed: ${hint}` }, 415);
}
const slug = crypto.randomBytes(9).toString('base64url');
const filePath = path.join(UPLOAD_DIR, slug);
const name = sanitizeName(req.headers['x-filename']);
// Stream to disk, counting bytes
let size = 0;
let tooLarge = false;
try {
await new Promise((resolve, reject) => {
const out = createWriteStream(filePath);
req.on('data', chunk => {
size += chunk.length;
if (size > limit && !tooLarge) {
tooLarge = true;
req.unpipe(out);
out.destroy();
req.resume();
reject(new Error('BODY_TOO_LARGE'));
}
});
req.on('error', reject);
out.on('error', reject);
out.on('finish', resolve);
req.pipe(out);
});
} catch (e) {
await fs.unlink(filePath).catch(() => {});
if (tooLarge) return sendJson(res, { success: false, msg: 'File too large', max_bytes: limit }, 413);
console.error('[CHAT_UP] Write error:', e.message);
return sendJson(res, { success: false, msg: 'Upload failed' }, 500);
}
if (!size) {
await fs.unlink(filePath).catch(() => {});
return sendJson(res, { success: false, msg: 'Empty body' }, 400);
}
const mime = await detectMime(filePath);
if (!allowed.includes(mime)) {
await fs.unlink(filePath).catch(() => {});
return sendJson(res, { success: false, msg: `File type not allowed: ${mime || 'unknown'}` }, 415);
}
const lifetime = parseExpiry(req.headers['x-upload-expiry']);
const expiresAt = new Date(Date.now() + lifetime * 1000);
const deleteToken = crypto.randomBytes(24).toString('base64url');
try {
await db`
INSERT INTO chat_uploads ${db({
slug,
key_id: key.id,
original_name: name,
mime,
mime_hint: hint,
size_bytes: size,
delete_token_hash: lib.sha256(deleteToken),
expires_at: expiresAt
})}
`;
await db`UPDATE upload_api_keys SET last_used_at = now() WHERE id = ${key.id}`;
} catch (e) {
await fs.unlink(filePath).catch(() => {});
console.error('[CHAT_UP] DB error:', e.message);
return sendJson(res, { success: false, msg: 'Server error' }, 500);
}
const base = (cfg.main?.url?.full || '').replace(/\/+$/, '');
return sendJson(res, {
success: true,
slug,
url: `${base}/cu/${slug}/${encodeURIComponent(name)}`,
name,
mime,
inline: isInlineMime(mime),
size,
expires_at: ~~(expiresAt.getTime() / 1000),
delete_token: deleteToken
});
}
// ─── Delete ───────────────────────────────────────────────────────────────────
export async function handleChatUploadDelete(req, res, slug) {
if (!isEnabled()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const token = req.headers['x-delete-token'];
if (!token) return sendJson(res, { success: false, msg: 'Delete token required' }, 401);
const rows = await db`
DELETE FROM chat_uploads
WHERE slug = ${slug} AND delete_token_hash = ${lib.sha256(String(token))}
RETURNING id
`;
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
await fs.unlink(path.join(UPLOAD_DIR, slug)).catch(() => {});
return sendJson(res, { success: true });
}
// ─── Download ─────────────────────────────────────────────────────────────────
export async function handleChatUploadServe(req, res, slug) {
const notFound = () => {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 - file not found.');
};
if (!isEnabled()) return notFound();
const rows = await db`
SELECT original_name, mime, expires_at FROM chat_uploads
WHERE slug = ${slug} AND expires_at > now()
LIMIT 1
`;
if (!rows.length) return notFound();
const row = rows[0];
const filePath = path.join(UPLOAD_DIR, slug);
let stat;
try { stat = await fs.stat(filePath); } catch { return notFound(); }
const inline = isInlineMime(row.mime);
const ttl = Math.max(0, ~~((new Date(row.expires_at).getTime() - Date.now()) / 1000));
const headers = {
'Content-Type': row.mime,
'Accept-Ranges': 'bytes',
'Cache-Control': `public, max-age=${Math.min(ttl, 86400)}`,
'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.original_name)}`,
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Content-Disposition',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
let start = 0, end = stat.size - 1, code = 200;
const range = req.headers.range && /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
if (range && stat.size > 0 && (range[1] !== '' || range[2] !== '')) {
if (range[1] === '') {
start = Math.max(0, stat.size - parseInt(range[2], 10));
} else {
start = parseInt(range[1], 10);
if (range[2] !== '') end = Math.min(parseInt(range[2], 10), end);
}
if (start > end || start >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
return res.end();
}
code = 206;
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
}
headers['Content-Length'] = String(stat.size ? end - start + 1 : 0);
res.writeHead(code, headers);
if (req.method === 'HEAD' || !stat.size) return res.end();
await new Promise(resolve => {
const stream = createReadStream(filePath, { start, end });
stream.on('error', () => { res.destroy(); resolve(); });
stream.on('end', resolve);
res.on('close', () => { stream.destroy(); resolve(); });
stream.pipe(res);
});
}
-7
View File
@@ -5,7 +5,6 @@ import cfg from "./inc/config.mjs";
import queue from "./inc/queue.mjs";
import path from "path";
import { collectBody } from "./inc/multipart.mjs";
import { canAnonDo, isAnonSession } from "./inc/settings.mjs";
// Helper for JSON response
const sendJson = (res, data, code = 200) => {
@@ -142,12 +141,6 @@ export const handleCommentUpload = async (req, res) => {
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('comment') || !canAnonDo('comment_attachments')) {
return sendJson(res, { success: false, msg: 'Anonymous comment file uploads are disabled' }, 403);
}
}
// Check if comment file upload is enabled
if (!cfg.websrv.allow_fileupload_comments) {
return sendJson(res, { success: false, msg: 'Comment file uploads are disabled' }, 403);
-189
View File
@@ -1,189 +0,0 @@
import crypto from 'node:crypto';
import db from './sql.mjs';
import lib from './lib.mjs';
import cfg from './config.mjs';
import security from './security.mjs';
/**
* IP of the request in its storable form (see security.storableIP): null when IP logging is off.
* @param {object} req
* @returns {string|null}
*/
export function resolveAuditIP(req) {
if (!req) return null;
return security.storableIP(security.getRealIP(req));
}
/**
* Log activity for an anonymous user (or session).
* @param {object} req
* @param {{ action: string, targetId?: number|string, details?: object, hwFingerprint?: string }} params
*/
export async function logAnonActivity(req, { action, targetId = null, details = null, hwFingerprint = null } = {}) {
try {
const rawIp = security.getRealIP(req);
const ip = security.storableIP(rawIp);
const userId = req?.session?.id || null;
if (!userId) return;
const fingerprint = req?.session?.fingerprint || req?.session?.anon_fingerprint || null;
const hwFp = hwFingerprint || req?.session?.hw_fingerprint || null;
const numTargetId = targetId ? parseInt(targetId, 10) : null;
await db`
INSERT INTO anon_activity_log (user_id, fingerprint, hw_fingerprint, ip, action, target_id, details)
VALUES (${userId}, ${fingerprint}, ${hwFp}, ${ip}, ${action}, ${!isNaN(numTargetId) ? numTargetId : null}, ${details ? JSON.stringify(details) : null})
`;
await security.logUserIP(userId, rawIp);
} catch (err) {
console.error('[ANON_ACTIVITY_LOG] Failed to log activity:', err);
}
}
/**
* Find or create a shadow user in the database for a passkey-authenticated anonymous identity.
*
* @param {string} credentialId - base64url WebAuthn credential ID
* @param {object} [req]
* @param {string} [hwFingerprint]
* @returns {Promise<{ userId: number, isNew: boolean, fingerprint: string }>}
*/
export async function getOrCreateAnonUserByCredential(credentialId, req = null, hwFingerprint = null) {
const auditIp = req ? resolveAuditIP(req) : null;
// Derive a stable "fingerprint" from the credential ID (for ban checks / display)
const fpBytes = crypto.createHash('sha256').update(Buffer.from(credentialId)).digest();
const fingerprint = 'SHA256:' + fpBytes.toString('base64').replace(/=+$/, '');
// Check if we already have a row for this credential
const existing = await db`
SELECT user_id FROM anon_identities
WHERE credential_id = ${credentialId}
LIMIT 1
`;
if (existing.length > 0) {
await db`
UPDATE anon_identities
SET last_seen = NOW()
${auditIp ? db`, last_ip = ${auditIp}` : db``}
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
WHERE credential_id = ${credentialId}
`.catch(() => {});
return { userId: existing[0].user_id, isNew: false, fingerprint };
}
// Generate unique shadow username based on fingerprint short hash
const shortHash = fpBytes.toString('hex').slice(0, 8);
let baseLogin = `anon_${shortHash}`;
let finalLogin = baseLogin;
let counter = 1;
while (true) {
const check = await db`SELECT id FROM "user" WHERE login = ${finalLogin} LIMIT 1`;
if (check.length === 0) break;
finalLogin = `${baseLogin}_${counter++}`;
}
const userRows = await db`
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated)
VALUES (${finalLogin}, ${finalLogin}, '!', false, false, true)
RETURNING id
`;
const userId = userRows[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, display_name, use_alternative_infobox)
VALUES (${userId}, 0, 'amoled', 0, null, 'Anonymous', ${cfg.websrv.user_alternative_infobox !== false})
ON CONFLICT (user_id) DO NOTHING
`;
await db`
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint})
ON CONFLICT (credential_id) DO UPDATE
SET last_seen = NOW()
${auditIp ? db`, last_ip = ${auditIp}` : db``}
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
`;
return { userId, isNew: true, fingerprint };
}
/**
* Create a valid session in user_sessions for an anonymous user.
* @param {number} userId
* @param {object} req
* @param {string} [hwFingerprint]
* @returns {Promise<{ session: string, csrf_token: string }>}
*/
export async function createAnonSession(userId, req, hwFingerprint = null, credentialId = null) {
const auditIp = resolveAuditIP(req);
// Update anon_identities last_ip and hw_fingerprint
await db`
UPDATE anon_identities
SET last_ip = ${auditIp},
created_ip = COALESCE(created_ip, ${auditIp})
${hwFingerprint ? db`, hw_fingerprint = COALESCE(${hwFingerprint}, hw_fingerprint)` : db``}
WHERE user_id = ${userId}
`.catch(() => {});
// Remember which passkey this session runs on (settings: can't delete the one in use)
const markCredential = async (sessionHash) => {
if (!credentialId) return;
await db`UPDATE user_sessions SET passkey_credential_id = ${credentialId} WHERE session = ${sessionHash}`.catch(() => {});
};
// If req.session is already active for this exact userId, reuse it
if (req?.session && req.session.id === userId && req.session.csrf_token && req.cookies?.session) {
await markCredential(lib.sha256(req.cookies.session));
await logAnonActivity(req, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: req.session.csrf_token };
}
// If client has a session cookie that maps to this userId in DB, reuse it
if (req?.cookies?.session) {
const existingHash = lib.sha256(req.cookies.session);
const existing = await db`
SELECT session, csrf_token FROM user_sessions
WHERE user_id = ${userId} AND session = ${existingHash}
LIMIT 1
`;
if (existing.length > 0) {
await db`UPDATE user_sessions SET last_used = ${~~(Date.now() / 1e3)} WHERE session = ${existingHash}`;
await markCredential(existingHash);
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
return { session: req.cookies.session, csrf_token: existing[0].csrf_token };
}
}
const session = crypto.randomBytes(32).toString('hex');
const sessionHash = lib.sha256(session);
const csrfToken = crypto.randomBytes(24).toString('hex');
const stamp = ~~(Date.now() / 1e3);
const ip = auditIp;
const ua = req?.headers ? (req.headers['user-agent'] || '') : '';
const sessRecord = {
user_id: userId,
session: sessionHash,
csrf_token: csrfToken,
browser: ua,
created_at: stamp,
last_used: stamp,
last_action: '/anon/passkey/auth',
kmsi: 1,
ip: ip
};
await db`
INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip')}
`;
await markCredential(sessionHash);
await logAnonActivity({ ...req, session: { id: userId, is_anon: true } }, { action: 'handshake', hwFingerprint });
return { session, csrf_token: csrfToken };
}
-342
View File
@@ -1,342 +0,0 @@
import JSZip from 'jszip';
import db from './sql.mjs';
import { MAX_NOTES, MAX_HOLD_MS, cleanNotes, cleanTitle, insertMap } from './square_clicker.mjs';
// Beatmap import for Square Clicker: .osz sets (zip: audio, background, one .osu text file per
// difficulty) and loose .osu files, converted to Square Clicker notes { t, x, y, d, p? }.
//
// Standard and catch: circle / fruit -> tap, slider / juice stream -> slider (curve sampled, repeats
// become a path that goes back and forth, duration from length, timing and slider velocity),
// spinner / banana shower -> hold in the middle.
// Taiko: every object sits in the middle of the playfield, so positions are generated: a path that
// flows over the screen (don turns gently, kat turns sharply, distance grows with the time gap).
// Drumroll and denden -> hold.
// Mania: columns become lanes side by side, long notes holds; chords collapse to one note (one cursor).
const PF_W = 512, PF_H = 384; // playfield in osu pixels
// playfield -> viewport fractions, leaving room for the top bar and the edges
const toX = (x) => Math.max(0.02, Math.min(0.98, 0.1 + (x / PF_W) * 0.8));
const toY = (y) => Math.max(0.02, Math.min(0.98, 0.14 + (y / PF_H) * 0.74));
const MAX_OSU_BYTES = 4 * 1024 * 1024; // one .osu file
const MAX_DIFFS = 40;
// ── .osu text -> sections ─────────────────────────────────────────────────────
export const parseOsu = (text) => {
const out = { General: {}, Metadata: {}, Difficulty: {}, Events: [], TimingPoints: [], HitObjects: [] };
let sec = null;
for (let line of String(text).replace(/^/, '').split(/\r?\n/)) {
line = line.trim();
if (!line || line.startsWith('//')) continue;
const m = /^\[(\w+)\]$/.exec(line);
if (m) { sec = m[1]; continue; }
if (!sec) continue;
if (sec === 'General' || sec === 'Metadata' || sec === 'Difficulty') {
const i = line.indexOf(':');
if (i > 0) out[sec][line.slice(0, i).trim()] = line.slice(i + 1).trim();
} else if (Array.isArray(out[sec])) {
out[sec].push(line);
}
}
return out;
};
// ── curves ────────────────────────────────────────────────────────────────────
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);
const lerp = (a, b, t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const bezier = (pts) => {
let len = 0;
for (let i = 1; i < pts.length; i++) len += dist(pts[i - 1], pts[i]);
const n = Math.max(2, Math.min(200, Math.ceil(len / 4)));
const out = [];
for (let s = 0; s <= n; s++) {
let q = pts.slice();
const t = s / n;
while (q.length > 1) q = q.slice(1).map((p, i) => lerp(q[i], p, t)); // de Casteljau
out.push(q[0]);
}
return out;
};
const catmull = (pts) => {
const out = [];
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;
for (let s = 0; s < 16; s++) {
const t = s / 16, t2 = t * t, t3 = t2 * t;
out.push([0, 1].map(k => 0.5 * (2 * p1[k] + (-p0[k] + p2[k]) * t + (2 * p0[k] - 5 * p1[k] + 4 * p2[k] - p3[k]) * t2 + (-p0[k] + 3 * p1[k] - 3 * p2[k] + p3[k]) * t3)));
}
}
out.push(pts[pts.length - 1]);
return out;
};
// Perfect circle through three points; null when they are (nearly) on a line
const arc = (a, b, c) => {
const d = 2 * (a[0] * (b[1] - c[1]) + b[0] * (c[1] - a[1]) + c[0] * (a[1] - b[1]));
if (Math.abs(d) < 1e-3) return null;
const sq = (p) => p[0] * p[0] + p[1] * p[1];
const cx = (sq(a) * (b[1] - c[1]) + sq(b) * (c[1] - a[1]) + sq(c) * (a[1] - b[1])) / d;
const cy = (sq(a) * (c[0] - b[0]) + sq(b) * (a[0] - c[0]) + sq(c) * (b[0] - a[0])) / d;
const r = Math.hypot(a[0] - cx, a[1] - cy);
const a0 = Math.atan2(a[1] - cy, a[0] - cx);
let a2 = Math.atan2(c[1] - cy, c[0] - cx);
// direction: the way that passes through b (sign of the turn a -> b -> c)
const ccw = ((b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0])) > 0;
if (ccw) { while (a2 < a0) a2 += 2 * Math.PI; } else { while (a2 > a0) a2 -= 2 * Math.PI; }
// sample well past the end point: the slider length decides where it stops
const span = (a2 - a0) * 2;
const n = Math.max(8, Math.min(200, Math.ceil(Math.abs(span) * r / 4)));
const out = [];
for (let s = 0; s <= n; s++) { const t = a0 + span * s / n; out.push([cx + r * Math.cos(t), cy + r * Math.sin(t)]); }
return out;
};
// Slider curve as a polyline (osu pixels), before cutting to the slider length
const curve = (type, pts) => {
if (type === 'P' && pts.length === 3) {
const c = arc(pts[0], pts[1], pts[2]);
if (c) return c;
return pts;
}
if (type === 'L') return pts;
if (type === 'C') return catmull(pts);
// Bezier (also the fallback): a repeated point starts a new segment
const out = [];
let seg = [pts[0]];
for (let i = 1; i < pts.length; i++) {
if (pts[i][0] === pts[i - 1][0] && pts[i][1] === pts[i - 1][1]) { out.push(...bezier(seg)); seg = [pts[i]]; }
else seg.push(pts[i]);
}
out.push(...bezier(seg));
return out;
};
// Cut / extend a polyline to exactly `length`, then resample it to k points evenly along it
const fitLength = (poly, length, k) => {
const cum = [0];
for (let i = 1; i < poly.length; i++) cum.push(cum[i - 1] + dist(poly[i - 1], poly[i]));
const total = cum[cum.length - 1];
const at = (s) => {
if (poly.length < 2) return poly[0];
if (s >= total) { // past the end: go on along the last direction
const a = poly[poly.length - 2], b = poly[poly.length - 1], l = dist(a, b) || 1;
return [b[0] + (b[0] - a[0]) / l * (s - total), b[1] + (b[1] - a[1]) / l * (s - total)];
}
let i = 1;
while (i < cum.length - 1 && cum[i] < s) i++;
const seg = cum[i] - cum[i - 1] || 1;
return lerp(poly[i - 1], poly[i], (s - cum[i - 1]) / seg);
};
return Array.from({ length: k }, (_, i) => at(length * i / (k - 1)));
};
// ── timing ────────────────────────────────────────────────────────────────────
// -> sorted [{ time, beatLength, sv }]: the beat length and slider velocity in effect from `time` on
const timingOf = (lines) => {
const out = [];
let beat = 500, sv = 1;
const pts = lines.map(l => l.split(',')).filter(p => p.length >= 2)
.map(p => ({ time: +p[0], bl: +p[1], unin: p.length > 6 ? p[6] === '1' : +p[1] > 0 }))
.filter(p => isFinite(p.time) && isFinite(p.bl))
.sort((a, z) => a.time - z.time || (z.unin - a.unin)); // uninherited first at the same time
for (const p of pts) {
if (p.unin && p.bl > 0) { beat = p.bl; sv = 1; }
else if (!p.unin && p.bl < 0) sv = Math.max(0.1, Math.min(10, -100 / p.bl));
out.push({ time: p.time, beatLength: beat, sv });
}
if (!out.length) out.push({ time: 0, beatLength: 500, sv: 1 });
return out;
};
const timingAt = (tps, t) => {
let cur = tps[0];
for (const p of tps) { if (p.time <= t + 1) cur = p; else break; }
return cur;
};
// ── generated positions (taiko) ───────────────────────────────────────────────
// A path that wanders over the screen: every note turns it a little (don) or a lot (kat), notes further
// apart in time are further apart on screen, and a spot that would cover a note still on screen (or leave
// the play area) is avoided by turning further until the spot is free
const flowPlace = (list) => {
const ASPECT = 1.6; // x/y are fractions of a wide viewport: a y step this much larger is the same distance
const ON_SCREEN_MS = 1200; // notes this close in time are visible together
const FREE_X = 0.065, FREE_Y = 0.12; // about a note and a bit, as viewport fractions
const inside = (x, y) => x >= 0.15 && x <= 0.85 && y >= 0.2 && y <= 0.82;
const out = [];
let x = 0.5, y = 0.5, ang = -Math.PI / 2, prevEnd = null;
for (const n of list) {
if (prevEnd !== null) {
const want = ang + (n.kat ? 2.2 : 0.3);
const step = Math.max(0.11, Math.min(0.3, (n.t - prevEnd) / 1000 * 0.35));
const recent = out.filter(o => n.t - o.t < ON_SCREEN_MS);
const free = (px, py) => inside(px, py) && !recent.some(o => Math.abs(o.x - px) < FREE_X && Math.abs(o.y - py) < FREE_Y);
let best = null;
for (let k = 0; k < 24 && !best; k++) { // want, want+0.26, want-0.26, want+0.52, ...
const a = want + (k % 2 ? 1 : -1) * Math.ceil(k / 2) * 0.26;
const px = x + Math.cos(a) * step, py = y + Math.sin(a) * step * ASPECT;
if (free(px, py)) best = [a, px, py];
}
if (!best) { // boxed in: the farthest in-bounds spot from the recent notes
let far = -1;
for (let k = 0; k < 24; k++) {
const a = want + k * Math.PI / 12;
const px = Math.max(0.15, Math.min(0.85, x + Math.cos(a) * step)), py = Math.max(0.2, Math.min(0.82, y + Math.sin(a) * step * ASPECT));
const dmin = recent.reduce((m, o) => Math.min(m, Math.hypot(o.x - px, (o.y - py) / ASPECT)), Infinity);
if (dmin > far) { far = dmin; best = [a, px, py]; }
}
}
[ang, x, y] = best;
}
prevEnd = n.t + (n.d || 0); // after a hold the gap counts from its end
out.push({ t: n.t, x: +x.toFixed(4), y: +y.toFixed(4), d: n.d || 0 });
}
return out;
};
// ── one difficulty -> notes ───────────────────────────────────────────────────
const MODES = ['standard', 'taiko', 'catch', 'mania'];
// -> { version, creator, notes, mode, error? }
export const convertOsu = (text) => {
const o = parseOsu(text);
const mode = parseInt(o.General.Mode || '0', 10) || 0;
const version = o.Metadata.Version || 'Normal';
const res = { version, creator: o.Metadata.Creator || '', mode, meta: o.Metadata, audioFile: o.General.AudioFilename || '', notes: [] };
if (!MODES[mode]) { res.error = 'unknown game mode ' + mode; return res; }
const tps = timingOf(o.TimingPoints);
const mult = parseFloat(o.Difficulty.SliderMultiplier) || 1.4;
const keys = Math.max(1, Math.min(18, Math.round(parseFloat(o.Difficulty.CircleSize) || 4))); // mania columns
const holdUntil = (t, end) => Math.max(0, Math.min(MAX_HOLD_MS, Math.round(end) - t));
const sliderMs = (t, p) => {
const tp = timingAt(tps, t);
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
return (Math.max(1, +p[7] || 0) / (mult * 100 * tp.sv) * tp.beatLength) * slides;
};
let notes = [];
const flow = []; // taiko: { t, d, kat } placed afterwards
for (const line of o.HitObjects) {
const p = line.split(',');
if (p.length < 4) continue;
const x = +p[0], y = +p[1], t = Math.round(+p[2]), type = +p[3];
if (!isFinite(x) || !isFinite(y) || !isFinite(t) || t < 0) continue;
if (mode === 1) { // taiko: hitSound whistle (2) or clap (8) = kat
if (type & 2) flow.push({ t, d: holdUntil(t, t + sliderMs(t, p)) }); // drumroll
else if (type & 8) flow.push({ t, d: holdUntil(t, +p[5] || t) }); // denden
else if (type & 1) flow.push({ t, d: 0, kat: !!((+p[4] || 0) & 10) });
continue;
}
if (mode === 3) { // mania: x picks the column; type 128 = long note, end time in the extras
const col = Math.max(0, Math.min(keys - 1, Math.floor(x * keys / PF_W)));
const d = type & 128 ? holdUntil(t, parseInt(String(p[5] || '').split(':')[0], 10) || t) : 0;
notes.push({ t, x: +(0.15 + (col + 0.5) / keys * 0.7).toFixed(4), y: 0.62, d });
continue;
}
if (type & 2) { // slider: x,y,time,type,hitSound,curve,slides,length,...
const [ctype, ...cps] = String(p[5] || '').split('|');
const ctrl = [[x, y], ...cps.map(s => s.split(':').map(Number)).filter(q => q.length === 2 && q.every(isFinite))];
const slides = Math.max(1, Math.min(50, parseInt(p[6], 10) || 1));
const length = Math.max(1, +p[7] || 0);
const tp = timingAt(tps, t);
const perSlide = length / (mult * 100 * tp.sv) * tp.beatLength;
const d = Math.round(perSlide * slides);
if (!isFinite(d) || d <= 0 || ctrl.length < 2) { notes.push({ t, x: toX(x), y: toY(y), d: 0 }); continue; }
const k = Math.max(2, Math.min(Math.ceil(length / 12) + 1, 40, Math.floor(900 / slides)));
const leg = fitLength(curve(ctype, ctrl), length, k);
const path = [];
for (let s = 0; s < slides; s++) {
const pts = s % 2 ? leg.slice().reverse() : leg;
pts.forEach((q, i) => {
if (s > 0 && i === 0) return; // the turn point is already there
path.push([Math.round(perSlide * (s + i / (k - 1))), +toX(q[0]).toFixed(4), +toY(q[1]).toFixed(4)]);
});
}
notes.push({ t, x: toX(x), y: toY(y), d: Math.min(MAX_HOLD_MS, d), p: path });
} else if (type & 8) { // spinner: x,y,time,type,hitSound,endTime -> hold in the middle
const end = Math.round(+p[5] || t);
notes.push({ t, x: 0.5, y: toY(PF_H / 2), d: Math.max(0, Math.min(MAX_HOLD_MS, end - t)) });
} else if (type & 1) {
notes.push({ t, x: toX(x), y: toY(y), d: 0 });
}
}
if (mode === 1) notes = flowPlace(flow.sort((a, z) => a.t - z.t));
notes.sort((a, z) => a.t - z.t);
// one cursor: notes at the same moment (mania chords) collapse to the first
notes = notes.filter((n, i) => i === 0 || n.t - notes[i - 1].t > 1);
if (!notes.length) res.error = 'no hit objects';
else if (notes.length > MAX_NOTES) res.error = 'too many notes (' + notes.length + ', max ' + MAX_NOTES + ')';
res.notes = notes;
return res;
};
// Title of an imported map: "Insane (mapper)", other modes marked: "ONI (mapper, taiko)"
export const importedTitle = (diff) => {
const extra = [diff.creator, diff.mode ? MODES[diff.mode] : ''].filter(Boolean).join(', ');
return diff.version + (extra ? ' (' + extra + ')' : '');
};
// ── .osz -> { meta, audio: { name, data }, background: { name, data } | null, diffs, skipped } ──
// null when the zip has no .osu files (then it is a normal archive)
export const readOsz = async (buffer) => {
let zip;
try { zip = await JSZip.loadAsync(buffer); } catch (_) { return null; }
const files = Object.values(zip.files).filter(f => !f.dir);
const osuFiles = files.filter(f => /\.osu$/i.test(f.name)).slice(0, MAX_DIFFS);
if (!osuFiles.length) return null;
const byName = (name) => {
if (!name) return null;
const want = name.replace(/\\/g, '/').toLowerCase();
return files.find(f => f.name.toLowerCase() === want) || files.find(f => f.name.toLowerCase().endsWith('/' + want)) || null;
};
const diffs = [], skipped = [];
let meta = null, audioName = null, bgName = null;
for (const f of osuFiles) {
const text = await f.async('string');
if (text.length > MAX_OSU_BYTES) { skipped.push({ version: f.name, reason: 'file too large' }); continue; }
const d = convertOsu(text);
if (d.error) { skipped.push({ version: d.version, reason: d.error }); continue; }
diffs.push(d);
if (!meta) meta = d.meta;
if (!audioName) audioName = d.audioFile;
if (!bgName) {
const ev = parseOsu(text).Events.find(l => /^0\s*,\s*0\s*,/.test(l));
const m = ev && /^0\s*,\s*0\s*,\s*"?([^",]+)"?/.exec(ev);
if (m) bgName = m[1].trim();
}
}
// audio: the file the difficulties name, or any audio file in the set
const audioFile = byName(audioName) || files.find(f => /\.(mp3|ogg|wav|flac|m4a)$/i.test(f.name));
const bgFile = byName(bgName) || files.find(f => /\.(jpe?g|png)$/i.test(f.name));
diffs.sort((a, z) => a.notes.length - z.notes.length); // easiest first
return {
meta: meta || {},
audio: audioFile ? { name: audioFile.name, data: await audioFile.async('nodebuffer') } : null,
background: bgFile ? { name: bgFile.name, data: await bgFile.async('nodebuffer') } : null,
diffs,
skipped,
};
};
// "Artist - Title" for the item title
export const setTitle = (meta) => [meta.Artist || meta.ArtistUnicode, meta.Title || meta.TitleUnicode].filter(Boolean).join(' - ');
// Store converted difficulties as maps of an item (or album entry). A difficulty that is already
// there (same title and note count) is skipped, so importing a set twice adds nothing.
// -> { ids, skipped: [{ version, reason }] }
export const saveDiffs = async ({ itemId, albumItemId = null, userId, diffs, durationMs = 0 }) => {
const ids = [], skipped = [];
for (const d of diffs) {
const v = cleanNotes(d.notes, 0);
if (v.error) { skipped.push({ version: d.version, reason: v.error }); continue; }
const title = cleanTitle(importedTitle(d));
const [dupe] = await db`
SELECT id FROM sqc_maps
WHERE item_id = ${itemId} AND album_item_id IS NOT DISTINCT FROM ${albumItemId}::int
AND title = ${title} AND note_count = ${v.notes.length}
LIMIT 1
`;
if (dupe) { skipped.push({ version: d.version, reason: 'already imported' }); continue; }
const last = v.notes[v.notes.length - 1];
ids.push(await insertMap({ itemId, albumItemId, userId, title, notes: v.notes, durationMs: durationMs || last.t + last.d + 2000 }));
}
return { ids, skipped };
};
-137
View File
@@ -1,137 +0,0 @@
/**
* chan_http.mjs — curl invocation for all outgoing 4chan requests (API JSON, media, rehost downloads).
*
* Every call gets a randomized browser identity:
* - If curl-impersonate is installed (wrapper binaries like curl_chrome116, curl_ff117, ...), a random
* profile is used. These reproduce a real browser's TLS + HTTP/2 handshake and send matching headers,
* so we must NOT override the User-Agent (a mismatch would defeat the point).
* - Otherwise plain curl is used with a random, current User-Agent and a matching Accept-Language.
*
* config: main.curl_impersonate
* (unset) / true → auto-detect wrappers on PATH
* false → never use curl-impersonate
* "<dir>" → look for wrappers in that directory (in addition to PATH)
*/
import fs from 'fs';
import path from 'path';
import cfg from './config.mjs';
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
// Browser majors derived from the date so the pool never goes stale.
// Chrome 100 shipped 2022-03-29, Firefox 100 on 2022-05-03; both release every 4 weeks.
const majorSince = (base, isoDate) => base + Math.floor((Date.now() - Date.parse(isoDate)) / (28 * 86400000));
const randomUserAgent = () => {
const chrome = majorSince(100, '2022-03-29') - Math.floor(Math.random() * 3); // current or up to 2 behind
const firefox = majorSince(100, '2022-05-03') - Math.floor(Math.random() * 3);
const templates = [
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36 Edg/${chrome}.0.0.0`,
`Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
`Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`
];
return pick(templates);
};
const ACCEPT_LANGUAGES = [
'en-US,en;q=0.9',
'en-US,en;q=0.8',
'en-GB,en;q=0.9,en-US;q=0.8',
'en-US,en;q=0.9,de;q=0.8',
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
];
// ── curl-impersonate detection (cached after first lookup) ──────────────────
// Wrapper names look like curl_chrome146, curl_firefox147, curl_edge101, curl_safari260, curl_chrome131_android.
const IMPERSONATE_RE = /^curl_(chrome|edge|firefox|ff|safari)(\d+)([a-z0-9_]*)$/i;
// Only the newest desktop profiles per browser: an old fingerprint (chrome99) stands out as much as plain curl.
const PROFILES_PER_BROWSER = 3;
let _impersonateBins = null;
const findImpersonateBins = () => {
if (_impersonateBins) return _impersonateBins;
const setting = cfg.main?.curl_impersonate;
if (setting === false) return (_impersonateBins = []);
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
if (typeof setting === 'string' && setting.trim()) dirs.unshift(setting.trim());
const byBrowser = new Map(); // family → [{ version, full }]
const seen = new Set();
for (const dir of dirs) {
let entries;
try { entries = fs.readdirSync(dir); } catch { continue; }
for (const name of entries) {
const m = name.match(IMPERSONATE_RE);
if (!m || m[3] || seen.has(name)) continue; // m[3] = suffix like _android/_ios/a → skip non-desktop variants
const full = path.join(dir, name);
try { fs.accessSync(full, fs.constants.X_OK); } catch { continue; }
seen.add(name);
const family = m[1].toLowerCase() === 'ff' ? 'firefox' : m[1].toLowerCase();
if (!byBrowser.has(family)) byBrowser.set(family, []);
byBrowser.get(family).push({ version: parseInt(m[2], 10), full });
}
}
// Edge shares Chrome's version numbers; drop Edge profiles that lag far behind the newest Chrome
// (releases have shipped edge99/edge101 next to chrome146, which would stand out as ancient).
const newestChrome = Math.max(0, ...(byBrowser.get('chrome') || []).map(p => p.version));
if (newestChrome && byBrowser.has('edge')) {
byBrowser.set('edge', byBrowser.get('edge').filter(p => p.version >= newestChrome - 10));
}
_impersonateBins = [];
for (const list of byBrowser.values()) {
list.sort((a, b) => b.version - a.version);
_impersonateBins.push(...list.slice(0, PROFILES_PER_BROWSER).map(p => p.full));
}
console.log(_impersonateBins.length
? `[BOOT] 4chan requests: curl-impersonate enabled (${_impersonateBins.map(b => path.basename(b)).join(', ')})`
: '[BOOT] 4chan requests: curl-impersonate not found, using curl with randomized User-Agent');
return _impersonateBins;
};
// The static curl-impersonate build looks for CAs at the Debian/Alpine path only. On other distros
// (openSUSE, Fedora, macOS) point it at the local bundle explicitly.
const CA_DEFAULT = '/etc/ssl/certs/ca-certificates.crt';
const CA_FALLBACKS = ['/etc/ssl/ca-bundle.pem', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'];
let _caArgs = null;
const caArgs = () => {
if (_caArgs) return _caArgs;
if (fs.existsSync(CA_DEFAULT)) return (_caArgs = []);
const found = CA_FALLBACKS.find(f => fs.existsSync(f));
return (_caArgs = found ? ['--cacert', found] : []);
};
const socksArgs = () => {
const socks = cfg.main?.socks;
if (!socks || socks === 'undefined') return [];
const host = socks.includes('://') ? socks.split('://')[1] : socks;
return ['--socks5-hostname', host];
};
/**
* Build a curl command for a 4chan URL with a randomized browser identity.
* @param {string} url
* @param {string[]} [extraArgs] additional curl flags (e.g. ['-o', file, '--max-time', '300'])
* @returns {{ bin: string, args: string[] }}
*/
export const chanCurl = (url, extraArgs = []) => {
const base = ['-s', '-f', '-L', ...extraArgs, ...socksArgs()];
const impersonate = findImpersonateBins();
if (impersonate.length) {
// Wrapper supplies its own UA, header order and TLS/HTTP2 fingerprint
return { bin: pick(impersonate), args: [...base, ...caArgs(), url] };
}
return {
bin: 'curl',
args: [...base, '-A', randomUserAgent(), '-H', `Accept-Language: ${pick(ACCEPT_LANGUAGES)}`, url]
};
};
export default { chanCurl };
+7 -63
View File
@@ -39,42 +39,20 @@ const storage = process.env.STORAGE_DIR;
const resolvePath = (defaultRel) => {
const local = path.resolve(path.join(base, defaultRel));
let target = local;
if (storage) {
const absStorage = path.resolve(storage);
if (defaultRel.startsWith('public/')) {
const sub = defaultRel.replace('public/', '');
if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') {
const storagePath = path.join(absStorage, sub.split('/').pop());
if (fs.existsSync(storagePath)) target = path.resolve(storagePath);
else target = local;
} else {
target = path.resolve(path.join(absStorage, sub));
if (fs.existsSync(storagePath)) return path.resolve(storagePath);
return local;
}
} else {
target = path.resolve(path.join(absStorage, defaultRel));
return path.resolve(path.join(absStorage, sub));
}
return path.resolve(path.join(absStorage, defaultRel));
}
try {
if (fs.existsSync(target)) {
return fs.realpathSync(target);
}
const parent = path.dirname(target);
if (fs.existsSync(parent)) {
return path.join(fs.realpathSync(parent), path.basename(target));
}
} catch (_) {}
return target;
};
const resolveImportPath = () => {
const p = storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'));
try {
if (fs.existsSync(p)) return fs.realpathSync(p);
const parent = path.dirname(p);
if (fs.existsSync(parent)) return path.join(fs.realpathSync(parent), path.basename(p));
} catch (_) {}
return p;
return local;
};
config.paths = {
@@ -83,51 +61,17 @@ config.paths = {
c: resolvePath('public/c'),
t: resolvePath('public/t'),
ca: resolvePath('public/ca'),
s: (() => {
const sPath = path.join(base, 'public/s');
try { if (fs.existsSync(sPath)) return fs.realpathSync(sPath); } catch (_) {}
return sPath;
})(),
s: path.join(base, 'public/s'),
emojis: resolvePath('public/s/emojis'),
koepfe: resolvePath('public/s/koepfe'),
fonts: resolvePath('public/s/fonts'),
memes: resolvePath('public/memes'),
e: resolvePath('public/e'),
cu: resolvePath('public/cu'),
pending: resolvePath('pending'),
deleted: resolvePath('deleted'),
logs: resolvePath('logs'),
tmp: resolvePath('tmp'),
import: resolveImportPath()
import: storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'))
};
const configFilePath = path.resolve(base, "config.json");
let watchDebounceTimer = null;
try {
if (fs.existsSync(configFilePath)) {
const watcher = fs.watch(configFilePath, (eventType) => {
if (eventType === 'change' || eventType === 'rename') {
if (watchDebounceTimer) clearTimeout(watchDebounceTimer);
watchDebounceTimer = setTimeout(() => {
try {
const raw = fs.readFileSync(configFilePath, 'utf8');
const updated = JSON.parse(raw);
Object.assign(config, updated);
if (config.sql) {
config.sql.host = process.env.DB_HOST || process.env.POSTGRES_HOST || process.env.PGHOST || config.sql.host;
config.sql.port = parseInt(process.env.DB_PORT || process.env.POSTGRES_PORT || process.env.PGPORT || config.sql.port, 10);
config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.env.PGUSER || config.sql.user;
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;
}
console.log('[CONFIG] config.json reloaded dynamically');
} catch (_) {}
}, 100);
if (watchDebounceTimer.unref) watchDebounceTimer.unref();
}
});
if (watcher.unref) watcher.unref();
}
} catch (_) {}
export default config;
-30
View File
@@ -1,30 +0,0 @@
import db from "./sql.mjs";
/**
* IDs of items that are not live (pending approval, soft-deleted, purged).
* The public media routes (/t/, /ca/) consult this so a leftover file on disk can never
* expose a pending or removed item. Cached briefly because every thumbnail request hits it.
*/
const TTL_MS = 5000;
let cache = new Set();
let loadedAt = 0;
let inflight = null;
const refresh = () => {
if (!inflight) {
inflight = db`select id from items where active = false`
.then(rows => { cache = new Set(rows.map(r => r.id)); loadedAt = Date.now(); })
.catch(e => { console.warn('[HIDDEN ITEMS] refresh failed:', e.message); })
.finally(() => { inflight = null; });
}
return inflight;
};
export const isHiddenItem = async (id) => {
if (Date.now() - loadedAt > TTL_MS) await refresh();
return cache.has(+id);
};
// Call after an item changes state (approve / withdraw) so the next lookup reloads
export const invalidateHiddenItems = () => { loadedAt = 0; };
+12 -175
View File
@@ -4,7 +4,6 @@ import db from "./sql.mjs";
import cfg from "./config.mjs";
import { createI18n } from "./i18n.mjs";
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo, canUseChan } from "./settings.mjs";
@@ -49,18 +48,7 @@ export default new class {
return slug;
}
formatSize(size) {
if (size === undefined || size === null || size === '') return '0 B';
if (typeof size === 'string') {
if (/^\d+(\.\d+)?\s*(B|kB|KB|MB|GB|TB)$/i.test(size.trim())) {
return size.trim();
}
const num = Number(size);
if (isNaN(num)) return '0 B';
size = num;
}
if (isNaN(size) || size <= 0) return '0 B';
const i = Math.min(4, Math.max(0, ~~(Math.log(size) / Math.log(1024))));
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
};
calcSpeed(b, s) {
@@ -170,7 +158,6 @@ export default new class {
if (env.tag) link.push("tag", encodeURIComponent(env.tag));
if (env.hall) link.push("h", encodeURIComponent(env.hall));
if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
else if (env.type === 'favs') link.push("favs");
let tmp = link.length === 0 ? '/' : link.join('/');
if (!tmp.endsWith('/'))
@@ -295,110 +282,24 @@ export default new class {
return false;
};
userHasFavorited(session, favorites) {
if (!session || !Array.isArray(favorites) || favorites.length === 0) return false;
const sessId = session.id ? Number(session.id) : (session.user_id ? Number(session.user_id) : null);
const sessUser = (session.user || '').toLowerCase();
const sessLogin = (session.login || '').toLowerCase();
const sessAnonLogin = (session.anon_login || '').toLowerCase();
return favorites.some(f => {
const fUserId = f.user_id ? Number(f.user_id) : (f.id ? Number(f.id) : null);
if (sessId && fUserId && fUserId === sessId) return true;
const fUser = (f.user || '').toLowerCase();
const fLogin = (f.login || '').toLowerCase();
if (sessUser && sessUser !== 'anonymous' && (fUser === sessUser || fLogin === sessUser)) return true;
if (sessLogin && (fUser === sessLogin || fLogin === sessLogin)) return true;
if (sessAnonLogin && (fUser === sessAnonLogin || fLogin === sessAnonLogin)) return true;
return false;
});
}
async getTags(itemid, session = null, subf0ckId = null) {
const isAnonymized = isAnonymizeSession(session);
const hasSession = !isAnonymized && !!(session && !session.is_anon && (typeof session === 'object' ? (session.id || session.user) : session));
let albumItemId = null;
if (subf0ckId !== null && subf0ckId !== undefined && subf0ckId !== '') {
if (Number.isInteger(+subf0ckId) && +subf0ckId > 0) {
const row = await db`
SELECT id FROM album_items
WHERE (id = ${+subf0ckId} OR (item_id = ${+itemid} AND order_index = ${+subf0ckId}))
AND item_id = ${+itemid}
LIMIT 1
`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`
SELECT id FROM album_items
WHERE slug = ${String(subf0ckId)}
AND item_id = ${+itemid}
LIMIT 1
`;
albumItemId = row?.[0]?.id || null;
}
}
let tags;
if (albumItemId) {
tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
from "album_items_tags_assign"
left join "tags" on "tags".id = "album_items_tags_assign".tag_id
${hasSession ? db`left join "user" on "user".id = "album_items_tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
where "album_items_tags_assign".album_item_id = ${+albumItemId}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
`;
// If album sub-item has no rating tag of its own, inherit the parent post's rating tag
const hasSubRating = tags.some(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t.id === 1 || t.id === 2);
if (!hasSubRating) {
const parentRatingTags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
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``}
where "tags_assign".item_id = ${+itemid}
and ("tags".id in (1, 2) or "tags".normalized in ('sfw', 'nsfw', 'nsfl'))
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc
limit 1
`;
if (parentRatingTags.length > 0) {
tags.unshift(parentRatingTags[0]);
}
}
} else {
tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
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``}
where "tags_assign".item_id = ${+itemid}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
`;
}
let hasRating = false;
const cleanTags = [];
const excludedTagIds = (session && Array.isArray(session.excluded_tags)) ? session.excluded_tags : [];
const canExclude = (session && !session.is_anon) ? true : canAnonDo('exclude_tags');
async getTags(itemid, session = null) {
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
const tags = await db`
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
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``}
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++) {
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
if (isRating) {
if (hasRating) continue;
hasRating = true;
}
tags[t].badge = this.getBadge(tags[t]);
tags[t].is_excluded = !isRating && excludedTagIds.includes(tags[t].id);
tags[t].can_exclude = canExclude;
if (!hasSession) {
delete tags[t].user;
delete tags[t].display_name;
}
cleanTags.push(tags[t]);
}
return cleanTags;
return tags;
};
getBadge(tagObj) {
if (tagObj.tag.startsWith(">"))
@@ -466,46 +367,13 @@ export default new class {
body: "401 - Unauthorized"
});
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
if (pathname.startsWith('/api/')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Registered account required" }), type: 'application/json' });
}
return res.redirect('/login');
}
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout' && req.url.pathname !== '/settings') {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
}
return next();
};
// Require a real registered user account (explicitly denies anonymous SSH identities)
async registeredUser(req, res, next) {
if (!req.session) {
return res.reply({
code: 401,
body: "401 - Unauthorized"
});
}
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
if (pathname.startsWith('/api/')) {
return res.reply({
code: 403,
body: JSON.stringify({ success: false, msg: "Action requires a registered account" }),
type: 'application/json'
});
}
return res.redirect('/login');
}
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
}
return next();
};
async loggedin(req, res, next) {
// SSH header auth removed (hard cut) — anonymous users must use passkey sessions
if (!req.session) {
return res.reply({
code: 401,
@@ -544,27 +412,6 @@ export default new class {
return next();
};
async chanAuth(req, res, next) {
const isApi = (req.url?.pathname && req.url.pathname.startsWith('/api/')) || req.headers['x-requested-with'] === 'XMLHttpRequest' || req.headers['accept']?.includes('application/json');
if (!req.session || !req.session.user) {
if (isApi) {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: "Unauthorized" }), type: 'application/json' });
}
return res.redirect('/login');
}
const hasGroup = canUseChan(req.session);
if (!hasGroup) {
if (isApi) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
}
return res.reply({
code: 403,
body: `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Access Denied</title><link rel="stylesheet" href="/s/css/f0ck.css"></head><body style="background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:sans-serif;"><div style="text-align:center;padding:30px;background:#1a1a1a;border-radius:12px;border:1px solid #333;max-width:460px;"><div style="font-size:3rem;color:#4ade80;margin-bottom:15px;">🍀</div><h2 style="margin:0 0 10px;">Access Restricted</h2><p style="color:#aaa;line-height:1.5;">This 4chan viewer is only accessible to users with the <strong>4chan</strong> group.</p><a href="/" style="display:inline-block;margin-top:15px;color:#4ade80;text-decoration:none;">← Return to Home</a></div></body></html>`
});
}
return next();
};
// Middleware: authenticate via X-Api-Key header (upload-only)
async apiKeyAuth(req, res, next) {
const key = req.headers['x-api-key'];
@@ -663,15 +510,5 @@ export default new class {
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort.endsWith('.onion');
}
isLocalhostRequest(req) {
if (!req) 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 === 'localhost' || hostNoPort === '127.0.0.1' || hostNoPort === '::1';
}
};
-23
View File
@@ -23,7 +23,6 @@ import { promises as fs } from 'fs';
import path from 'path';
import db from './sql.mjs';
import cfg from './config.mjs';
import { removePrivateItem } from './private_items.mjs';
/**
* Safely remove the media file for a deleted item.
@@ -231,14 +230,12 @@ export async function purgeExpiredUploads() {
if (item.dest) {
await safeDeleteMediaFile(item.dest, item.id);
}
await safeDeleteAlbumFiles(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}`;
removePrivateItem(item.id, item.dest);
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
} catch (e) {
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
@@ -250,23 +247,3 @@ export async function purgeExpiredUploads() {
}
}
/**
* Safely delete all album image files associated with an item.
* @param {number} itemId
*/
export async function safeDeleteAlbumFiles(itemId) {
try {
const albumRows = await db`SELECT dest FROM album_items WHERE item_id = ${itemId}`;
for (const row of albumRows) {
if (row.dest) {
await safeDeleteMediaFile(row.dest, itemId);
const thumbName = row.dest.replace(/\.[^.]+$/, '.webp');
await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {});
}
}
await db`DELETE FROM album_items WHERE item_id = ${itemId}`.catch(() => {});
} catch (e) {
console.error(`[DELETE] Failed to delete album files for item #${itemId}:`, e);
}
}
+5 -54
View File
@@ -59,7 +59,6 @@
"comment_optional": "(optional)",
"comment_placeholder": "Kommentar zum Upload hinzufügen...",
"select_file": "Datei auswählen",
"redirect_to_item": "Nach dem Upload zum Post weiterleiten",
"uploading": "Wird hochgeladen...",
"pending_approval_patient": "Upload wartet auf Freigabe, bitte haben Sie etwas Geduld",
"remove_file": "Datei entfernen",
@@ -159,7 +158,7 @@
"alternative_steuerung": "Icon-Navigationsstil",
"alternative_steuerung_hint": "Ersetzt die Text-Navigation (← zurück | Zufall | weiter →) durch kompakte Chevron-Icons",
"f_for_fullscreen": "F is for Fullscreen",
"f_for_fullscreen_hint": "Das Drücken von F schaltet das Vollbild im Videoplayer statt den Beitrag zu favorisieren (Shift+F zum Favorisieren).",
"f_for_fullscreen_hint": "Das Drücken von F schaltet das Vollbild im Videoplayer statt den Beitrag zu favorisieren.",
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
"disable_autoplay_hint": "Verhindert die automatische Wiedergabe von Videos und Audio",
"hide_item_ratings": "Item-Bewertungen ausblenden",
@@ -271,12 +270,6 @@
"start_export": "Export generieren (ZIP)"
},
"filter": {
"excluded_tags": "Ausgeschlossene Tags",
"exclude_tag": "Tag ausschließen",
"unexclude_tag": "Ausgeschlossen (klicken zum Aufheben)",
"no_tags_excluded": "Noch keine Tags ausgeschlossen",
"tag_excluded_msg": "Tag '{tag}' zu ausgeschlossenen Tags hinzugefügt",
"tag_unexcluded_msg": "Tag '{tag}' aus ausgeschlossenen Tags entfernt",
"tag_placeholder": "Tag ausschließen",
"random_mode": "RAND",
"min_xd_score": "Min. xD-Score",
@@ -284,8 +277,7 @@
"video": "Video",
"audio": "Audio",
"image": "Bild",
"flash": "Flash",
"grid_mode": "Thumbnailgröße"
"flash": "Flash"
},
"shortcuts": {
"title": "Tastaturkürzel",
@@ -299,8 +291,6 @@
"focus_comment": "Kommentarfeld fokussieren",
"send_comment": "Kommentar senden",
"flash_yank": "Flash-Yank ein-/ausschalten",
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
"open_settings": "Einstellungen öffnen/schließen",
"tag_exclude": "Tag-Ausschluss öffnen",
"tag_input": "Tag-Eingabe öffnen",
"toggle_bg": "Hintergrund ein-/ausschalten",
@@ -351,8 +341,7 @@
"label": "Fehler",
"post_not_visible": "Dieser Beitrag ist derzeit leider nicht sichtbar.",
"filter_hint": "Dein aktueller Filter ist auf {mode} gesetzt.",
"filter_hint_link": "Um diesen Inhalt zu sehen, ändere deinen Filter entsprechend.",
"see_anyways": "Trotzdem ansehen"
"filter_hint_link": "Um diesen Inhalt zu sehen, ändere deinen Filter entsprechend."
},
"drop": {
"drop_anywhere": "Überall ablegen zum Hochladen"
@@ -404,11 +393,7 @@
"name": "Name",
"description": "Beschreibung",
"private": "Privat",
"view": "Ansehen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Bearbeiten",
"dismiss": "Schließen"
"view": "Ansehen"
},
"mod": {
"confirm_action": "Aktion bestätigen",
@@ -475,9 +460,6 @@
"acknowledge": "Verstanden"
},
"sidebar": {
"recent_comments": "Neueste Kommentare",
"recommendations": "Empfehlungen",
"recommended_videos": "Video-Empfehlungen",
"loading_activity": "Aktivität wird geladen...",
"no_activity": "Keine kürzliche Aktivität.",
"failed_to_load": "Laden fehlgeschlagen.",
@@ -486,10 +468,7 @@
"view": "Ansehen",
"read_more": "mehr sehen",
"see_less": "weniger anzeigen",
"show_full_comment": "ganzen Kommentar anzeigen",
"loading_recommendations": "Lade Empfehlungen...",
"no_recommendations": "Keine Empfehlungen gefunden.",
"refresh_recommendations": "Empfehlungen aktualisieren"
"show_full_comment": "ganzen Kommentar anzeigen"
},
"subscriptions": {
"title": "Meine Abonnements",
@@ -527,7 +506,6 @@
"stat_favs": "Gesamt Favoriten",
"stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer",
"stat_anon_users": "Anonyme Benutzer",
"most_favorited": "Meiste Favs",
"favs": "Favs",
"top_xd": "Top xD-Score"
@@ -563,9 +541,6 @@
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
"no_uploads": "Keine Uploads gefunden",
"no_favs": "Keine Favoriten",
"guest_favs_saved": "Du hast {count} Gast-Favoriten auf diesem Gerät gespeichert.",
"sync_guest_favs": "In Account importieren",
"guest_favs_imported": "Favoriten erfolgreich in deinen Account importiert!",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
@@ -668,8 +643,6 @@
"reason_required": "Grund ist erforderlich.",
"reason_optional": "Grund (optional)",
"reason_required_label": "Grund (erforderlich)",
"captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
"captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
"processing": "Wird verarbeitet...",
"yes": "Ja",
"no": "Nein",
@@ -742,7 +715,6 @@
"settings": "Einstellungen",
"filters": "Filter",
"volume": "Lautstärke",
"clear_filter": "Filter löschen",
"reset_all": "Alles zurücksetzen",
"rating": "Bewertung",
"all": "Alle",
@@ -836,26 +808,5 @@
"private": "Privat",
"change_visibility": "Sichtbarkeit ändern"
}
},
"album": {
"title": "Album",
"multiple_selected": "Mehrere Dateien ausgewählt",
"mode_album": "Album (1 Beitrag)",
"mode_batch": "Einzelne Beiträge",
"cover": "Titelbild",
"pictures": "Subf0cks",
"counter": "%s von %s",
"prev": "Vorheriger Subf0ck",
"next": "Nächster Subf0ck",
"hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
"move_left": "Nach links",
"move_right": "Nach rechts",
"remove_picture": "Subf0ck entfernen",
"add_more": "Weitere Subf0cks hinzufügen",
"drop_hint": "Wähle oder ziehe mehrere Dateien (Subf0cks) hierher, um ein Album zu erstellen",
"select_pictures": "Dateien für Album auswählen",
"min_pictures": "Mindestens 2 Subf0cks für ein Album erforderlich",
"subf0ck_tags": "Tags für diesen Subf0ck",
"subf0ck_tags_placeholder": "Tags für diesen Subf0ck (optional)"
}
}
+12 -59
View File
@@ -43,7 +43,7 @@
"url_tab_yt": "URL / YouTube",
"url_placeholder": "Paste a URL to download...",
"url_placeholder_yt": "Paste a URL or YouTube link...",
"url_placeholder_shitpost": "Paste a URL or YouTube link...",
"url_placeholder_shitpost": "Paste multiple URLs here (one per line)...",
"drop_here": "Drop your file here",
"admin_boost": "Admin Boost",
"custom_thumbnail": "Custom Thumbnail",
@@ -59,7 +59,6 @@
"comment_optional": "(optional)",
"comment_placeholder": "Add a comment to your upload...",
"select_file": "Select a file",
"redirect_to_item": "Redirect to item after upload",
"uploading": "Uploading...",
"pending_approval_patient": "Upload awaits approval, please be patient",
"remove_file": "Remove File",
@@ -159,7 +158,7 @@
"alternative_steuerung": "Icon nav style",
"alternative_steuerung_hint": "Replace text navigation (← prev | random | next →) with compact chevron icons",
"f_for_fullscreen": "F is for Fullscreen",
"f_for_fullscreen_hint": "Pressing F will toggle fullscreen in the video player instead of favoriting the post (Shift+F to favorite).",
"f_for_fullscreen_hint": "Pressing F will toggle fullscreen in the video player instead of favoriting the post.",
"disable_autoplay": "Disable Autoplay",
"disable_autoplay_hint": "Prevent videos and audio from playing automatically",
"hide_item_ratings": "Hide Item Ratings",
@@ -182,8 +181,8 @@
"blur_nsfl_hint": "Blur NSFL-rated/shock thumbnails.",
"blur_sfw": "Blur SFW",
"blur_sfw_hint": "Blur SFW-rated thumbnails.",
"blur_untagged": "Blur Unrated",
"blur_untagged_hint": "Blur thumbnails with no rating.",
"blur_untagged": "Blur Untagged",
"blur_untagged_hint": "Blur thumbnails with no tags or rating.",
"blur_detail": "Click to reveal item on detail page",
"blur_detail_hint": "Require clicking to reveal blurred media on the post detail page.",
"render_emojis": "Render emojis in quote replies",
@@ -271,12 +270,6 @@
"start_export": "Generate Export (ZIP)"
},
"filter": {
"excluded_tags": "Excluded Tags",
"exclude_tag": "Exclude tag",
"unexclude_tag": "Excluded (click to unexclude)",
"no_tags_excluded": "No tags excluded yet",
"tag_excluded_msg": "Tag '{tag}' added to excluded tags",
"tag_unexcluded_msg": "Tag '{tag}' removed from excluded tags",
"tag_placeholder": "Tag to exclude",
"random_mode": "RAND",
"min_xd_score": "Min xD Score",
@@ -284,8 +277,7 @@
"video": "Video",
"audio": "Audio",
"image": "Image",
"flash": "Flash",
"grid_mode": "Thumbnail size"
"flash": "Flash"
},
"shortcuts": {
"title": "Keyboard Shortcuts",
@@ -299,8 +291,6 @@
"focus_comment": "focus comment input",
"send_comment": "send comment",
"flash_yank": "enable/disable flash yank",
"square_clicker": "Square Clicker (audio / video items)",
"open_settings": "open/close settings",
"tag_exclude": "open tag exclude",
"tag_input": "open tag input",
"toggle_bg": "turns on/off the background",
@@ -351,8 +341,7 @@
"label": "Error",
"post_not_visible": "Sorry, this post is currently not visible.",
"filter_hint": "Your current filter is set to {mode}.",
"filter_hint_link": "To view this content, change your filter accordingly.",
"see_anyways": "See anyways"
"filter_hint_link": "To view this content, change your filter accordingly."
},
"drop": {
"drop_anywhere": "Drop anywhere to upload"
@@ -407,8 +396,7 @@
"view": "View",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"dismiss": "Dismiss"
"edit": "Edit"
},
"mod": {
"confirm_action": "Confirm Action",
@@ -467,7 +455,7 @@
},
"report": {
"title": "Submit Report",
"placeholder": "",
"placeholder": "Please provide details for this report (required)...",
"submit": "Submit"
},
"account_warning": {
@@ -476,10 +464,6 @@
"acknowledge": "I Understand"
},
"sidebar": {
"recent_comments": "Recent Comments",
"recommendations": "Recommendations",
"recommended_videos": "Recommended Videos",
"no_recommendations": "No recommendations found.",
"loading_activity": "Loading activity...",
"no_activity": "No recent activity.",
"failed_to_load": "Failed to load.",
@@ -488,10 +472,7 @@
"view": "View",
"read_more": "read more",
"see_less": "see less",
"show_full_comment": "show full comment",
"loading_recommendations": "Loading recommendations...",
"no_recommendations": "No video recommendations found.",
"refresh_recommendations": "Refresh recommendations"
"show_full_comment": "show full comment"
},
"subscriptions": {
"title": "My Subscriptions",
@@ -520,7 +501,7 @@
"tag_stats": "Stats",
"stat_total": "Total Items",
"stat_tagged": "Tagged",
"stat_untagged": "Unrated",
"stat_untagged": "Untagged",
"stat_sfw": "SFW content",
"stat_nsfw": "NSFW content",
"stat_nsfl": "NSFL content",
@@ -529,7 +510,6 @@
"stat_favs": "Total Favorites",
"stat_disk_usage": "Total File Size",
"stat_users": "Total Users",
"stat_anon_users": "Anonymous Users",
"most_favorited": "Most Favorited",
"favs": "favs",
"top_xd": "Top xD Scores"
@@ -540,7 +520,7 @@
"limit_reached": "Upload limit reached (0/{limit})",
"limit_remaining": "{remaining}/{limit} uploads remaining",
"auth_required_title": "Authentication Required",
"auth_required_text": "You must be logged in to upload content.",
"auth_required_text": "You must be logged in to upload content to w0bm.",
"login_btn": "Login"
},
"messages": {
@@ -565,9 +545,6 @@
"subscribe_uploads_btn": "Subscribe user to uploads",
"no_uploads": "no uploads found",
"no_favs": "no favorites",
"guest_favs_saved": "You have {count} guest favorites saved on this device.",
"sync_guest_favs": "Import to Account",
"guest_favs_imported": "Imported favorites to your account!",
"private_favorites": "private favorites",
"back_to_profile": "Back to Profile",
"ban_modal_title": "Ban User",
@@ -670,8 +647,6 @@
"reason_required": "Reason is required.",
"reason_optional": "Reason (optional)",
"reason_required_label": "Reason (required)",
"captcha_required": "Please complete the CAPTCHA.",
"captcha_loading": "CAPTCHA is still loading. Please wait a moment.",
"processing": "Processing...",
"yes": "Yes",
"no": "No",
@@ -742,11 +717,10 @@
"settings": "Settings",
"filters": "Filters",
"volume": "Volume",
"clear_filter": "Clear filter",
"reset_all": "Reset all",
"rating": "Rating",
"all": "All",
"untagged": "Unrated",
"untagged": "Untagged",
"media_type": "Media type",
"video": "Video",
"image": "Image",
@@ -836,26 +810,5 @@
"private": "Private",
"change_visibility": "Change Visibility"
}
},
"album": {
"title": "Album",
"multiple_selected": "Multiple files selected",
"mode_album": "Album (1 post)",
"mode_batch": "Separate posts",
"cover": "Cover",
"pictures": "subf0cks",
"counter": "%s of %s",
"prev": "Previous subf0ck",
"next": "Next subf0ck",
"hotkey_tip": "Use [ and ] or hover to navigate subf0cks",
"move_left": "Move left",
"move_right": "Move right",
"remove_picture": "Remove subf0ck",
"add_more": "Add more subf0cks",
"drop_hint": "Select or drop multiple files (subf0cks) to create an Album",
"select_pictures": "Select files for album",
"min_pictures": "Add at least 2 subf0cks for an album",
"subf0ck_tags": "Tags for this subf0ck",
"subf0ck_tags_placeholder": "Tags for this subf0ck (optional)"
}
}
+6 -55
View File
@@ -59,7 +59,6 @@
"comment_optional": "(optioneel)",
"comment_placeholder": "Voeg een opmerking toe aan je upload...",
"select_file": "Selecteer een bestand",
"redirect_to_item": "Na het uploaden naar het item doorsturen",
"uploading": "Uploaden...",
"pending_approval_patient": "Upload wacht op goedkeuring, even geduld alstublieft",
"remove_file": "Bestand Verwijderen",
@@ -159,7 +158,7 @@
"alternative_steuerung": "Icoon-navigatiestijl",
"alternative_steuerung_hint": "Vervangt tekstnavigatie (← terug | willekeurig | verder →) door compacte chevron-iconen",
"f_for_fullscreen": "F is for Fullscreen",
"f_for_fullscreen_hint": "Drukken op F schakelt het volledige scherm in de videospeler in plaats van de post te favorieten (Shift+F om te favorieten).",
"f_for_fullscreen_hint": "Drukken op F schakelt het volledige scherm in de videospeler in plaats van de post te favorieten.",
"disable_autoplay": "Automatisch afspelen uitschakelen",
"disable_autoplay_hint": "Voorkomen dat video's en audio automatisch worden afgespeeld",
"hide_item_ratings": "Itembeoordelingen verbergen",
@@ -269,12 +268,6 @@
"start_export": "Export genereren (ZIP)"
},
"filter": {
"excluded_tags": "Uitgesloten Tags",
"exclude_tag": "Tag uitsluiten",
"unexclude_tag": "Uitgesloten (klik om te herstellen)",
"no_tags_excluded": "Nog geen tags uitgesloten",
"tag_excluded_msg": "Tag '{tag}' toegevoegd aan uitgesloten tags",
"tag_unexcluded_msg": "Tag '{tag}' verwijderd uit uitgesloten tags",
"tag_placeholder": "Tag om uit te sluiten",
"random_mode": "WILLEKEURIG",
"min_xd_score": "Min xD-score",
@@ -282,8 +275,7 @@
"video": "Video",
"audio": "Audio",
"image": "Afbeelding",
"flash": "Flash",
"grid_mode": "Thumbnailgrootte"
"flash": "Flash"
},
"shortcuts": {
"title": "Sneltoetsen",
@@ -297,8 +289,6 @@
"focus_comment": "focus op commentaarinvoer",
"send_comment": "opmerking verzenden",
"flash_yank": "flash yank in/uitschakelen",
"square_clicker": "Square Clicker (audio- / video-posts)",
"open_settings": "instellingen openen/sluiten",
"tag_exclude": "open tag uitsluiten",
"tag_input": "open tag invoer",
"toggle_bg": "turns on/off the background",
@@ -349,8 +339,7 @@
"label": "Fout",
"post_not_visible": "Sorry, deze post is momenteel niet zichtbaar.",
"filter_hint": "Je huidige filter is ingesteld op {mode}.",
"filter_hint_link": "Om deze inhoud te bekijken, pas je filter aan.",
"see_anyways": "Toch bekijken"
"filter_hint_link": "Om deze inhoud te bekijken, pas je filter aan."
},
"drop": {
"drop_anywhere": "Overal slepen om te uploaden"
@@ -402,11 +391,7 @@
"name": "Naam",
"description": "Beschrijving",
"private": "Privé",
"view": "Bekijken",
"save": "Opslaan",
"delete": "Verwijderen",
"edit": "Bewerken",
"dismiss": "Sluiten"
"view": "Bekijken"
},
"mod": {
"confirm_action": "Actie Bevestigen",
@@ -473,9 +458,6 @@
"acknowledge": "Ik Begrijp het"
},
"sidebar": {
"recent_comments": "Recente reacties",
"recommendations": "Aanbevelingen",
"recommended_videos": "Aanbevolen video's",
"loading_activity": "Activiteit laden...",
"no_activity": "Geen recente activiteit.",
"failed_to_load": "Laden mislukt.",
@@ -484,10 +466,7 @@
"view": "Bekijken",
"read_more": "lees meer",
"see_less": "zie minder",
"show_full_comment": "volledig commentaar tonen",
"loading_recommendations": "Aanbevelingen laden...",
"no_recommendations": "Geen aanbevelingen gevonden.",
"refresh_recommendations": "Aanbevelingen vernieuwen"
"show_full_comment": "volledig commentaar tonen"
},
"subscriptions": {
"title": "Mijn Abonnementen",
@@ -525,7 +504,6 @@
"stat_favs": "Totaal aantal favorieten",
"stat_disk_usage": "Totale Bestandsgrootte",
"stat_users": "Totaal Gebruikers",
"stat_anon_users": "Anonieme gebruikers",
"most_favorited": "Meest Gefavoriet",
"favs": "favorieten",
"top_xd": "Top xD-scores"
@@ -536,7 +514,7 @@
"limit_reached": "Uploadlimiet bereikt (0/{limit})",
"limit_remaining": "{remaining}/{limit} uploads over",
"auth_required_title": "Authenticatie Vereist",
"auth_required_text": "Je moet ingelogd zijn om inhoud naar f0ckm te uploaden.",
"auth_required_text": "Je moet ingelogd zijn om inhoud naar w0bm te uploaden.",
"login_btn": "Inloggen"
},
"messages": {
@@ -561,9 +539,6 @@
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
"no_uploads": "geen uploads gevonden",
"no_favs": "geen favorieten",
"guest_favs_saved": "Je hebt {count} gastfavorieten opgeslagen op dit apparaat.",
"sync_guest_favs": "Importeren naar account",
"guest_favs_imported": "Favorieten succesvol geïmporteerd naar je account!",
"private_favorites": "privé favorieten",
"back_to_profile": "Terug naar Profiel",
"ban_modal_title": "Gebruiker Bannen",
@@ -666,8 +641,6 @@
"reason_required": "Reden is vereist.",
"reason_optional": "Reden (optioneel)",
"reason_required_label": "Reden (vereist)",
"captcha_required": "Vul alstublieft de CAPTCHA in.",
"captcha_loading": "CAPTCHA is nog aan het laden. Even geduld a.u.b.",
"processing": "Verwerken...",
"yes": "Ja",
"no": "Nee",
@@ -738,7 +711,6 @@
"settings": "Instellingen",
"filters": "Filters",
"volume": "Volume",
"clear_filter": "Filter wissen",
"reset_all": "Alles resetten",
"rating": "Beoordeling",
"all": "Alles",
@@ -826,26 +798,5 @@
"slot_refreshes_on": "slot vernieuwd op {date}",
"slot_refreshed": "slot vernieuwd",
"admin_desc": "Je bent admin, ga je gang."
},
"album": {
"title": "Album",
"multiple_selected": "Meerdere afbeeldingen geselecteerd",
"mode_album": "Album (1 bericht)",
"mode_batch": "Aparte berichten",
"cover": "Omslag",
"pictures": "afbeeldingen",
"counter": "%s van %s",
"prev": "Vorige afbeelding",
"next": "Volgende afbeelding",
"hotkey_tip": "Gebruik [ en ] of zweef over album om te navigeren",
"move_left": "Naar links",
"move_right": "Naar rechts",
"remove_picture": "Afbeelding verwijderen",
"add_more": "Meer afbeeldingen toevoegen",
"drop_hint": "Selecteer of sleep meerdere afbeeldingen om een album te maken",
"select_pictures": "Selecteer afbeeldingen voor album",
"min_pictures": "Voeg minimaal 2 afbeeldingen toe voor een album",
"subf0ck_tags": "Tags voor deze subf0ck",
"subf0ck_tags_placeholder": "Tags voor deze subf0ck (optioneel)"
}
}
+6 -52
View File
@@ -59,7 +59,6 @@
"comment_optional": "(optional)",
"comment_placeholder": "Fügen Sie Ihrer Aufladierung doch einen Kommentar hinzu...",
"select_file": "Datei auswählen",
"redirect_to_item": "Nach Pfostieren zum Pfosten weiterleiten",
"uploading": "Wird aufladiert...",
"pending_approval_patient": "Die Ladung harrt der Absegnung, bitte haben Sie Geduld",
"remove_file": "Datei entfernen",
@@ -159,7 +158,7 @@
"alternative_steuerung": "Icon-Navigationsstil",
"alternative_steuerung_hint": "Ersetzt die Text-Navigation (← zurück | Zufall | weiter →) durch kompakte Chevron-Icons",
"f_for_fullscreen": "F is for Fullscreen",
"f_for_fullscreen_hint": "Das Drücken der F-Taste leitet die Vollbildlichkeit des Abspielers ein, statt den Pfosten zu favorisieren (Shift+F zur Favorisierung).",
"f_for_fullscreen_hint": "Das Drücken der F-Taste leitet die Vollbildlichkeit des Abspielers ein, statt den Pfosten zu favorisieren.",
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
"disable_autoplay_hint": "Vermeiden Sie das automatische Abspielen von Videos und Tondateien",
"hide_item_ratings": "Item-Bewertungen ausblenden",
@@ -267,12 +266,6 @@
"start_export": "Paket schnüren"
},
"filter": {
"excluded_tags": "Ausgeschlossene Etiketten",
"exclude_tag": "Etikett ausschließen",
"unexclude_tag": "Ausgeschlossen (klicken zum Wiederherstellen)",
"no_tags_excluded": "Noch keine Etiketten ausgeschlossen",
"tag_excluded_msg": "Etikett '{tag}' zu ausgeschlossenen Etiketten hinzugefügt",
"tag_unexcluded_msg": "Etikett '{tag}' aus ausgeschlossenen Etiketten entfernt",
"tag_placeholder": "Auszuschließendes Etikett",
"random_mode": "ZUFA",
"min_xd_score": "Min. xD-Punktestand",
@@ -280,8 +273,7 @@
"video": "Video",
"audio": "Tondatei",
"image": "Bild",
"flash": "Blitz",
"grid_mode": "Größe der Daumennägel"
"flash": "Blitz"
},
"shortcuts": {
"title": "Tastaturkürzel",
@@ -295,8 +287,6 @@
"focus_comment": "Kommentareingabe fokussieren",
"send_comment": "Kommentar senden",
"flash_yank": "Blitz-Rucken aktivieren/deaktivieren",
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
"open_settings": "Einstellungen auf-/zuklappen",
"tag_exclude": "Etiketten-Ausschluss öffnen",
"tag_input": "Etiketten-Eingabe öffnen",
"toggle_bg": "Hintergrund ein-/ausschalten",
@@ -347,8 +337,7 @@
"label": "Fehler",
"post_not_visible": "Bedauerlicher Weise ist dieser Pfosten derzeit nicht sichtbar.",
"filter_hint": "Ihr aktueller Filter ist auf {mode} eingestellt.",
"filter_hint_link": "Um diesen Inhalt zu betrachten, stellen Sie Ihren Filter entsprechend um.",
"see_anyways": "Dennoch betrachten"
"filter_hint_link": "Um diesen Inhalt zu betrachten, stellen Sie Ihren Filter entsprechend um."
},
"drop": {
"drop_anywhere": "Überall ablegen zum Aufladieren"
@@ -402,8 +391,7 @@
"view": "Ansehen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Editieren",
"dismiss": "Weg damit"
"edit": "Editieren"
},
"mod": {
"confirm_action": "Aktion bestätigen",
@@ -471,9 +459,6 @@
"acknowledge": "Ich verstehe"
},
"sidebar": {
"recent_comments": "Frische Kommis",
"recommendations": "Zufallskram",
"recommended_videos": "Filmchen-Tipps",
"loading_activity": "Aktivität wird geladen...",
"no_activity": "Noch keine Aktivität",
"failed_to_load": "Ladung gescheitert.",
@@ -482,10 +467,7 @@
"view": "Ansehen",
"read_more": "mehr sehen",
"see_less": "weniger sehen",
"show_full_comment": "Kommentar vollständig ausklappen",
"loading_recommendations": "Lade Empfehlungen...",
"no_recommendations": "Nix am Start.",
"refresh_recommendations": "Neu würfeln"
"show_full_comment": "Kommentar vollständig ausklappen"
},
"subscriptions": {
"title": "Meine Abonnements",
@@ -523,7 +505,6 @@
"stat_favs": "Gesamtanzahl Favoriten",
"stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer",
"stat_anon_users": "Anonymer Alkoholiker",
"most_favorited": "Am häufigsten favorisiert",
"favs": "Favoriten",
"top_xd": "Beste xD-Punktestände"
@@ -534,7 +515,7 @@
"limit_reached": "Aufladierungsgrenze erreicht (0/{limit})",
"limit_remaining": "{remaining}/{limit} Aufladierungen verbleibend",
"auth_required_title": "Authentifizierung erforderlich",
"auth_required_text": "Sie müssen angemeldet sein, um Inhalte hochzuladen.",
"auth_required_text": "Sie müssen angemeldet sein, um Inhalte auf w0bm hochzuladen.",
"login_btn": "Anmeldung"
},
"messages": {
@@ -559,9 +540,6 @@
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
"no_uploads": "keine Aufladierungen gefunden",
"no_favs": "keine Favoriten",
"guest_favs_saved": "Du hast {count} Kaltgast-Favs auf diesem Gerät rumgammeln.",
"sync_guest_favs": "In Account ballern",
"guest_favs_imported": "Favs erfolgreich ins Konto geballert!",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
@@ -664,8 +642,6 @@
"reason_required": "Grund ist erforderlich.",
"reason_optional": "Grund (optional)",
"reason_required_label": "Grund (erforderlich)",
"captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
"captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
"processing": "Verarbeitung wird durchgeführt...",
"yes": "Ja",
"no": "Nein",
@@ -738,7 +714,6 @@
"settings": "Einstellungen",
"filters": "Filter",
"volume": "Lautstärke",
"clear_filter": "Filter entfernen",
"reset_all": "Alles zurücksetzen",
"rating": "Bewertung",
"all": "Alle",
@@ -826,26 +801,5 @@
"slot_refreshes_on": "Platz erneuert sich am {date}",
"slot_refreshed": "Platz erneuert",
"admin_desc": "Du bist Admin, mach weiter."
},
"album": {
"title": "Album",
"multiple_selected": "Mehrere Bildnisse ausgewählt",
"mode_album": "Album (1 Einpfostung)",
"mode_batch": "Vereinzelte Einpfostungen",
"cover": "Deckblatt",
"pictures": "Bildnisse",
"counter": "%s von %s",
"prev": "Vorheriges Bildnis",
"next": "Nächstes Bildnis",
"hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
"move_left": "Nach links",
"move_right": "Nach rechts",
"remove_picture": "Bildnis entfernen",
"add_more": "Weitere Bildnisse hinzufügen",
"drop_hint": "Wähle oder droppe mehrere Bilder für 1 Album",
"select_pictures": "Bildnisse fürs Album auswählen",
"min_pictures": "Mindestens 2 Bildnisse fürs Album nötig",
"subf0ck_tags": "Tags für dies Unterf0ck",
"subf0ck_tags_placeholder": "Tags für dies Unterf0ck (optional)"
}
}
+3 -20
View File
@@ -53,32 +53,15 @@ export const parseMultipart = (buffer, boundary) => {
const contentTypeMatch = headers.match(/Content-Type:\s*([^\r\n]+)/i);
if (nameMatch) {
let name = nameMatch[1];
if (name.endsWith('[]')) {
name = name.slice(0, -2);
}
const name = nameMatch[1];
if (extractedFilename !== null) {
const fileObj = {
parts[name] = {
filename: extractedFilename,
contentType: contentTypeMatch ? contentTypeMatch[1] : 'application/octet-stream',
data: body
};
if (!parts[name]) {
parts[name] = fileObj;
} else if (Array.isArray(parts[name])) {
parts[name].push(fileObj);
} else {
parts[name] = [parts[name], fileObj];
}
} else {
const textVal = body.toString().trim();
if (!parts[name]) {
parts[name] = textVal;
} else if (Array.isArray(parts[name])) {
parts[name].push(textVal);
} else {
parts[name] = [parts[name], textVal];
}
parts[name] = body.toString().trim();
}
}
}
-210
View File
@@ -1,210 +0,0 @@
import db from "./sql.mjs";
// Maps for fast O(1) in-memory lookups:
// dest (string) -> owner username (lowercase string)
const _privateDests = new Map();
const _privateIds = new Map();
// Unavailable items (visibility === 3, serves HTTP 451 for non-logged in users):
const _unavailableDests = new Map();
const _unavailableIds = new Map();
let _initialized = false;
let _initPromise = null;
/**
* Load all active private and unavailable items into memory cache.
*/
export async function initPrivateItems() {
try {
const rows = await db`
SELECT id, dest, LOWER(username) as username, visibility
FROM items
WHERE visibility IN (2, 3) AND is_deleted = false
`;
_privateDests.clear();
_privateIds.clear();
_unavailableDests.clear();
_unavailableIds.clear();
for (const r of rows) {
const u = r.username || '';
if (r.visibility === 2) {
if (r.dest) _privateDests.set(r.dest, u);
if (r.id) _privateIds.set(Number(r.id), u);
} else if (r.visibility === 3) {
if (r.dest) _unavailableDests.set(r.dest, u);
if (r.id) _unavailableIds.set(Number(r.id), u);
}
}
if (!_initialized) {
console.log(`[BOOT] Loaded ${_privateDests.size} private item(s) and ${_unavailableDests.size} unavailable item(s) into memory cache`);
}
_initialized = true;
} catch (err) {
console.error('[BOOT] Failed to load private/unavailable items into cache:', err.message);
}
}
export function ensurePrivateItemsInit() {
if (!_initialized && !_initPromise) {
_initPromise = initPrivateItems().finally(() => { _initPromise = null; });
}
return _initPromise;
}
// Background sync every 30 seconds
setInterval(() => {
initPrivateItems().catch(() => {});
}, 30_000).unref();
export function addPrivateItem(id, dest, username) {
const u = (username || '').toLowerCase();
if (dest) _privateDests.set(dest, u);
if (id) _privateIds.set(Number(id), u);
// Ensure not in unavailable
removeUnavailableItem(id, dest);
}
export function removePrivateItem(id, dest) {
if (dest) _privateDests.delete(dest);
if (id) _privateIds.delete(Number(id));
}
export function addUnavailableItem(id, dest, username) {
const u = (username || '').toLowerCase();
if (dest) _unavailableDests.set(dest, u);
if (id) _unavailableIds.set(Number(id), u);
// Ensure not in private
removePrivateItem(id, dest);
}
export function removeUnavailableItem(id, dest) {
if (dest) _unavailableDests.delete(dest);
if (id) _unavailableIds.delete(Number(id));
}
/**
* Checks if a given pathname (/b/<dest>, /t/<id>..., /ca/<id>...) is a private or unavailable item.
* Returns { isPrivate: boolean, isUnavailable: boolean, owner: string } or null if normal public.
*/
export function getPrivateItemFromPath(pathname) {
if (!pathname || typeof pathname !== 'string') return null;
if (pathname.startsWith('/b/')) {
let dest;
try {
dest = decodeURIComponent(pathname.slice(3));
} catch {
dest = pathname.slice(3);
}
dest = dest.split('?')[0].split('#')[0];
const privOwner = _privateDests.get(dest);
if (privOwner !== undefined) {
return { isPrivate: true, isUnavailable: false, owner: privOwner };
}
const unavOwner = _unavailableDests.get(dest);
if (unavOwner !== undefined) {
return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
return null;
}
if (pathname.startsWith('/t/')) {
let filename;
try {
filename = decodeURIComponent(pathname.slice(3));
} catch {
filename = pathname.slice(3);
}
filename = filename.split('?')[0].split('#')[0];
const match = filename.match(/^(\d+)/);
if (match) {
const id = parseInt(match[1], 10);
const privOwner = _privateIds.get(id);
if (privOwner !== undefined) {
return { isPrivate: true, isUnavailable: false, owner: privOwner };
}
const unavOwner = _unavailableIds.get(id);
if (unavOwner !== undefined) {
return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
}
return null;
}
if (pathname.startsWith('/ca/')) {
let filename;
try {
filename = decodeURIComponent(pathname.slice(4));
} catch {
filename = pathname.slice(4);
}
filename = filename.split('?')[0].split('#')[0];
const match = filename.match(/^(\d+)/);
if (match) {
const id = parseInt(match[1], 10);
const privOwner = _privateIds.get(id);
if (privOwner !== undefined) {
return { isPrivate: true, isUnavailable: false, owner: privOwner };
}
const unavOwner = _unavailableIds.get(id);
if (unavOwner !== undefined) {
return { isPrivate: false, isUnavailable: true, owner: unavOwner };
}
}
return null;
}
return null;
}
export function isPrivateItemPath(pathname) {
return getPrivateItemFromPath(pathname) !== null;
}
/**
* Render standard 502 Bad Gateway response.
*/
export function render502(req, res) {
if (req.headers && req.headers['x-requested-with'] === 'XMLHttpRequest') {
res.writeHead(502, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(JSON.stringify({ success: false, msg: 'Bad Gateway' }));
} else {
const body = (typeof global._buildGatePage === 'function')
? global._buildGatePage(req)
: (global._nginx502 || `<html>\n<head><title>502 Bad Gateway</title></head>\n<body bgcolor="white">\n<center><h1>502 Bad Gateway</h1></center>\n<hr><center>nginx</center>\n</body>\n</html>`);
res.writeHead(502, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(body);
}
}
/**
* Render HTTP 451 Unavailable For Legal Reasons response.
*/
export function render451(req, res) {
if (req.headers && (req.headers['x-requested-with'] === 'XMLHttpRequest' || req.headers.accept?.includes('application/json'))) {
res.writeHead(451, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(JSON.stringify({ success: false, is_unavailable: true, msg: '451 - Unavailable For Legal Reasons' }));
} else {
const body = `<html>\r
<head><title>451 Unavailable For Legal Reasons</title></head>\r
<body>\r
<center><h1>451 Unavailable For Legal Reasons</h1></center>\r
<hr><center>nginx</center>\r
</body>\r
</html>\r
`;
res.writeHead(451, {
'Content-Type': 'text/html',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}).end(body);
}
}
+20 -100
View File
@@ -354,12 +354,9 @@ export default new class queue {
}
async genThumbnail(filename, mime, itemid, link, pending = false, size = 512) {
let bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
let tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
let cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
try { bDir = await fs.promises.realpath(bDir); } catch (_) {}
try { tDir = await fs.promises.realpath(tDir); } catch (_) {}
try { cDir = await fs.promises.realpath(cDir); } catch (_) {}
const bDir = pending ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const cDir = pending ? path.join(cfg.paths.pending, 'ca') : cfg.paths.ca;
const tmpFile = path.join(os.tmpdir(), itemid + '.png');
const tmpJpg = path.join(os.tmpdir(), itemid + '.jpg');
const thumbSize = (size && size > 128) ? size : 128;
@@ -375,13 +372,7 @@ export default new class queue {
}
} catch (e) {}
let outPath = path.join(tDir, itemid + '.webp');
try {
const outStat = await fs.promises.lstat(outPath);
if (outStat.isSymbolicLink()) {
await fs.promises.unlink(outPath).catch(() => {});
}
} catch (_) {}
const outPath = path.join(tDir, itemid + '.webp');
try {
@@ -411,11 +402,6 @@ export default new class queue {
}
if (!fetched) {
console.error(`[QUEUE] YouTube thumbnail extraction failed for ${itemid}: all quality levels failed or returned placeholder`);
} else {
// Pre-warm dynamic ambient timeline in background
import('./routes/apiv2/ambient.mjs')
.then(m => m.getOrExtractYoutubeAmbient(videoId))
.catch(() => {});
}
}
}
@@ -471,23 +457,17 @@ export default new class queue {
else if (mime.startsWith('audio/')) {
let coverExtracted = false;
this._lastCoverExtracted = false; // Reset state for this call
if (link && typeof link === 'string' && link.match(/soundcloud/)) {
if (link.match(/soundcloud/)) {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') ? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`] : [];
let cover = null;
try {
const ytRes = await this.spawn('yt-dlp', [...proxyArgs, '--get-thumbnail', link], { quiet: true });
cover = ytRes.stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.warn(`[QUEUE] yt-dlp thumbnail fetch failed for SoundCloud track (${link}):`, err.message || err);
}
if (cover && !cover.match(/default_avatar/)) {
let cover = (await this.spawn('yt-dlp', [...proxyArgs, '-f', 'bv*[height<=720]+ba/b[height<=720] / wv*+ba/w', '--get-thumbnail', link])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
if (!cover.match(/default_avatar/)) {
cover = cover.replace(/-(large|original)\./, '-t500x500.');
try {
const curlArgs = ['-s', '-L', cover, '-o', tmpJpg];
if (proxyArgs.length > 0) curlArgs.push(...proxyArgs);
await this.spawn('curl', curlArgs);
const size = (await fs.promises.stat(tmpJpg)).size;
if (size > 0) {
if (size >= 0) {
await this.spawn('magick', [tmpJpg, tmpFile]);
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
coverExtracted = true;
@@ -508,40 +488,22 @@ export default new class queue {
} else {
// Try extracting embedded cover art (video stream in audio file)
try {
const caWebp = path.join(cDir, itemid + '.webp');
await this.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'webp', '-frames:v', '1', caWebp]);
const stat = await fs.promises.stat(caWebp).catch(() => null);
if (stat && stat.size > 0) {
await this.spawn('magick', [caWebp + '[0]', tmpFile]);
await this.spawn('ffmpeg', ['-i', sourcePath, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]);
const size = (await fs.promises.stat(tmpJpg)).size;
if (size > 0) {
await this.spawn('magick', [tmpJpg, tmpFile]);
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
coverExtracted = true;
}
} catch (err) { }
if (!coverExtracted) {
try {
await this.spawn('ffmpeg', ['-y', '-i', sourcePath, '-an', '-vcodec', 'copy', '-frames:v', '1', '-update', '1', tmpJpg]);
const size = (await fs.promises.stat(tmpJpg).catch(() => ({ size: 0 }))).size;
if (size > 0) {
await this.spawn('magick', [tmpJpg, tmpFile]);
await this.spawn('magick', [tmpJpg, path.join(cDir, itemid + '.webp')]);
coverExtracted = true;
}
} catch (err) { }
}
}
// If no new cover art extracted, check if cover art was already saved previously in cDir
// If no cover art found, use audio.webp as the thumbnail
if (!coverExtracted) {
const existingCover = path.join(cDir, itemid + '.webp');
try {
const stat = await fs.promises.stat(existingCover);
if (stat.size > 0) {
await this.spawn('magick', [existingCover, tmpFile]);
coverExtracted = true;
}
} catch (_) { }
}
// If no cover art found, generate audio placeholder matching .sidebar-media-placeholder.audio
if (!coverExtracted) {
await this.genAudioPlaceholder(tmpFile, thumbSpec, thumbSize);
const audioFallback = path.join(cfg.paths.s, 'img', 'audio.webp');
await fs.promises.copyFile(audioFallback, tmpFile).catch(async () => {
// If copy fails, fall back to generated placeholder
await this.spawn('magick', ['-size', thumbSpec, 'xc:#1a1a1a', '-gravity', 'center', '-fill', '#666', '-pointsize', '40', '-annotate', '0', '♪', tmpFile]).catch(() => {});
});
}
// Store extraction result for caller
this._lastCoverExtracted = coverExtracted;
@@ -665,13 +627,6 @@ export default new class queue {
// Cleanup temp files
await fs.promises.unlink(tmpFile).catch(() => {});
await fs.promises.unlink(tmpJpg).catch(() => {});
if (mime && mime.startsWith('audio/')) {
try {
await this.genAudioPlaceholder(outPath, thumbSpec, thumbSize);
console.warn(`[QUEUE] Used audio placeholder thumbnail for item ${itemid}`);
return false;
} catch (_) {}
}
// Fallback: copy 404.gif as the thumbnail
const fallback404 = path.join(cfg.paths.s, 'img', '404.gif');
try {
@@ -684,46 +639,11 @@ export default new class queue {
}
};
async genAudioPlaceholder(targetFile, thumbSpec = '512x512', thumbSize = 512) {
const faFont = path.join(cfg.paths.s, 'fa', 'webfonts', 'fa-solid-900.ttf');
const ptSize = String(Math.round(thumbSize * 0.35));
try {
await this.spawn('magick', [
'-size', thumbSpec, 'radial-gradient:#2c2c2c-#181818',
'(', '+clone', '-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', 'rgba(255,255,255,0.3)', '-annotate', '0', '\uf001', '-blur', '0x12', ')',
'-composite',
'-font', faFont, '-pointsize', ptSize, '-gravity', 'center', '-fill', 'rgba(240,240,240,0.88)', '-annotate', '0', '\uf001',
targetFile
]);
return true;
} catch (_) {
const audioFallback = path.join(cfg.paths.s, 'img', 'audio.webp');
try {
await fs.promises.copyFile(audioFallback, targetFile);
return true;
} catch (copyErr) {
await this.spawn('magick', [
'-size', thumbSpec, 'radial-gradient:#2c2c2c-#181818',
'-gravity', 'center', '-fill', 'rgba(240,240,240,0.88)', '-pointsize', '60', '-annotate', '0', '♪',
targetFile
]).catch(() => {});
return false;
}
}
};
async genBlurredThumbnail(itemid, pending = false) {
let tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
try { tDir = await fs.promises.realpath(tDir); } catch (_) {}
const tDir = pending ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const src = path.join(tDir, `${itemid}.webp`);
const dst = path.join(tDir, `${itemid}_blur.webp`);
try {
const dstStat = await fs.promises.lstat(dst);
if (dstStat.isSymbolicLink()) {
await fs.promises.unlink(dst).catch(() => {});
}
} catch (_) {}
try {
await this.spawn('magick', [src, '-blur', '0x48', dst]);
return true;
-105
View File
@@ -1,105 +0,0 @@
import db from "./sql.mjs";
import cfg from "./config.mjs";
import { getHwFingerprintEnabled } from "./settings.mjs";
/**
* retention.mjs — automatic deletion of personal data after a configurable period.
*
* All periods are in days, configured under websrv.* (0 = keep forever):
* retention_ip_days stored IPs: user_ips rows are deleted; IP columns on sessions, anon identities,
* uploads, comments, reports and ToS acceptances are set to NULL
* retention_activity_log_days anon_activity_log rows (action, IP, fingerprints) are deleted
* retention_login_attempts_days login_attempts rows (hashed IP + attempted username) are deleted
* retention_sessions_days sessions unused for this long are deleted (logs that device out)
* retention_fingerprint_days hardware fingerprint is cleared from anonymous identities inactive this long
*
* With websrv.anon_hw_fingerprint: false, every stored device fingerprint (identities and activity log) is
* cleared on each run, regardless of age.
*
* Active bans (banned_ips / banned_fingerprints / banned_hardware_fingerprints) are not touched: they are kept
* until they expire or are lifted.
*/
const DEFAULTS = {
ip: 30,
activity_log: 90,
login_attempts: 30,
sessions: 365,
fingerprint: 365
};
const days = (key) => {
const v = cfg.websrv?.[`retention_${key}_days`];
if (v === undefined || v === null || v === '') return DEFAULTS[key];
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 ? n : 0;
};
export const getRetention = () => ({
ip: days('ip'),
activity_log: days('activity_log'),
login_attempts: days('login_attempts'),
sessions: days('sessions'),
fingerprint: days('fingerprint')
});
const RUN_INTERVAL_MS = 60 * 60 * 1000; // hourly
export const runRetention = async () => {
const r = getRetention();
const nowSecs = ~~(Date.now() / 1e3);
const before = (d) => new Date(Date.now() - d * 86400e3);
const counts = {};
const step = async (name, fn) => {
try {
const res = await fn();
if (res?.count) counts[name] = res.count;
} catch (e) {
console.error(`[RETENTION] ${name} failed:`, e.message);
}
};
if (r.ip) {
const cutoff = before(r.ip);
const cutoffSecs = nowSecs - r.ip * 86400;
await step('user_ips', () => db`delete from user_ips where last_seen < ${cutoff}`);
await step('sessions.ip', () => db`update user_sessions set ip = null where ip is not null and last_used < ${cutoffSecs}`);
await step('anon.created_ip', () => db`update anon_identities set created_ip = null where created_ip is not null and created_at < ${cutoff}`);
await step('anon.last_ip', () => db`update anon_identities set last_ip = null where last_ip is not null and last_seen < ${cutoff}`);
await step('items.uploader_ip', () => db`update items set uploader_ip = null where uploader_ip is not null and stamp < ${cutoffSecs}`);
await step('comments.ip', () => db`update comments set ip = null where ip is not null and created_at < ${cutoff}`);
await step('reports.reporter_ip', () => db`update reports set reporter_ip = null where reporter_ip is not null and created_at < ${cutoff}`);
await step('tos.accepted_ip', () => db`update user_tos_acceptance set accepted_ip = null where accepted_ip is not null and accepted_at < ${cutoff}`);
}
if (r.activity_log) {
await step('anon_activity_log', () => db`delete from anon_activity_log where created_at < ${before(r.activity_log)}`);
}
if (r.login_attempts) {
await step('login_attempts', () => db`delete from login_attempts where attempted_at < ${before(r.login_attempts)}`);
}
if (r.sessions) {
await step('user_sessions', () => db`delete from user_sessions where last_used < ${nowSecs - r.sessions * 86400}`);
}
if (!getHwFingerprintEnabled()) {
// Fingerprinting switched off: don't keep any fingerprints collected while it was on
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null`);
await step('activity.hw_fingerprint', () => db`update anon_activity_log set hw_fingerprint = null where hw_fingerprint is not null`);
} else if (r.fingerprint) {
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null and last_seen < ${before(r.fingerprint)}`);
}
const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(', ');
if (summary) console.log(`[RETENTION] Cleaned: ${summary}`);
};
export const startRetention = () => {
const r = getRetention();
const fmt = (d) => d ? `${d}d` : 'forever';
console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${getHwFingerprintEnabled() ? fmt(r.fingerprint) : 'disabled (purged)'}`);
setTimeout(runRetention, 30_000);
setInterval(runRetention, RUN_INTERVAL_MS);
};
+328 -2248
View File
File diff suppressed because it is too large Load Diff
+83 -733
View File
@@ -12,8 +12,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs";
import crypto from "crypto";
import path from "path";
import YAML from "yaml";
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, getBrandImageUrl } from "../settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -78,15 +77,7 @@ export default (router, tpl) => {
} else {
const reason = user[0].ban_reason || 'none';
const expires = user[0].ban_expires ? new Date(user[0].ban_expires).toISOString().replace('T', ' ').substring(0, 16) : 'never';
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: false,
banned: true,
msg: `You are banned! reason: ${reason} expire: ${expires}`,
redirect: '/banned'
}));
}
return res.writeHead(302, { Location: '/banned' }).end();
return fail(`You are banned! reason: ${reason} expire: ${expires}`);
}
}
@@ -113,7 +104,7 @@ export default (router, tpl) => {
last_used: stamp,
last_action: "/login",
kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0,
ip: security.storableIP(ip)
ip: ip
};
await db`
@@ -168,7 +159,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
}
const user = (await db`select id, login, password from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0];
const user = (await db`select id, login from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0];
const targetIdentity = user ? user.login : email;
// 2. Identity-based check
@@ -182,8 +173,7 @@ export default (router, tpl) => {
// but the user wants "maximum tries per day is 1", so we record it regardless of email existence)
await security.recordAttempt(ip, targetIdentity, 'password_reset_request', true);
// Passkey-only accounts (password '!') can't get a password by mail: same neutral answer, no mail
if (!user || user.password === '!') {
if (!user) {
const msg = "If an account with that email exists, we have sent a reset link.";
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
return res.reply({ body: tpl.render("forgot-password", { success: msg }) });
@@ -260,7 +250,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
}
const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() and password <> '!' limit 1`)[0];
const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() limit 1`)[0];
if (!user) {
const msg = "Invalid or expired reset token.";
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
@@ -287,29 +277,8 @@ export default (router, tpl) => {
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
// Dashboard counters (cheap aggregate queries; failures just show 0)
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0, invite_requests: 0 };
try {
const [[r], [u], [t], [ir]] = await Promise.all([
db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`,
db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users,
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon
FROM "user"`,
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`,
db`SELECT count(*)::int AS n FROM invite_requests WHERE status = 'pending'`
]);
dash.trash = t?.n || 0;
dash.invite_requests = ir?.n || 0;
dash.open_reports = r?.n || 0;
dash.users = u?.users || 0;
dash.anon_users = u?.anon || 0;
} catch (e) {
console.error('[ADMIN] dashboard counters failed:', e.message);
}
res.reply({
body: tpl.render("admin", {
dash,
totals: await lib.countf0cks(),
session: req.session,
manual_approval: getManualApproval(),
@@ -322,7 +291,6 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(),
shitpost_mode: getShitpostMode(),
enable_cleanup_config: cfg.websrv.enable_cleanup !== false,
current_brand_image: getBrandImageUrl(),
tmp: null
}, req)
});
@@ -355,208 +323,6 @@ export default (router, tpl) => {
});
});
router.get(/^\/admin\/bans(\/)?$/, lib.modAuth, async (req, res) => {
const bannedFingerprints = await db`
select bf.*,
u.login as anon_login, u.user as anon_user,
admin.user as banned_by_user
from banned_fingerprints bf
left join anon_identities ai on ai.fingerprint = bf.fingerprint
left join "user" u on u.id = ai.user_id
left join "user" admin on admin.id = bf.banned_by
order by bf.created_at desc
`;
const bannedHardware = await db`
select bh.*,
admin.user as banned_by_user
from banned_hardware_fingerprints bh
left join "user" admin on admin.id = bh.banned_by
order by bh.created_at desc
`;
const bannedIps = await db`
select bi.*,
admin.user as banned_by_user
from banned_ips bi
left join "user" admin on admin.id = bi.banned_by
order by bi.created_at desc
`;
const anonIdentities = await db`
select ai.*,
u.id as user_id, u.login, u.user, u.banned as is_banned, u.ban_reason,
bf.id as is_fp_banned,
bh.id as is_hw_banned
from anon_identities ai
left join "user" u on u.id = ai.user_id
left join banned_fingerprints bf on bf.fingerprint = ai.fingerprint
left join banned_hardware_fingerprints bh on bh.hw_fingerprint = ai.hw_fingerprint
order by ai.last_seen desc
limit 100
`;
const recentActivity = await db`
select al.*,
u.login, u.user
from anon_activity_log al
left join "user" u on u.id = al.user_id
order by al.created_at desc
limit 100
`;
res.reply({
body: tpl.render("admin/bans", {
session: req.session,
csrf_token: req.session ? req.session.csrf_token : '',
bannedFingerprints,
bannedHardware,
bannedIps,
anonIdentities,
recentActivity,
tmp: null
}, req)
});
});
router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { fingerprint, reason, duration, ban_ips, user_id } = req.post || {};
if (!fingerprint) throw new Error('Missing fingerprint');
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
const result = await security.banAnonymousUser({
userId: user_id ? +user_id : null,
fingerprint,
bannedBy: req.session.id,
reason: reason || 'Banned by moderator',
expires,
banIps: ban_ips !== false
});
await audit.log(req.session.id, 'ban_fingerprint', 'fingerprint', null, { fingerprint, reason, duration });
return res.json({ success: true, result });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/unban\/?$/, lib.modAuth, async (req, res) => {
try {
const { fingerprint } = req.post || {};
if (!fingerprint) throw new Error('Missing fingerprint');
await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${fingerprint}`;
// Also unban any shadow user associated with this fingerprint
const ident = await db`SELECT user_id FROM anon_identities WHERE fingerprint = ${fingerprint}`;
if (ident.length > 0) {
await db`UPDATE "user" SET banned = false, ban_reason = null, ban_expires = null WHERE id = ${ident[0].user_id}`;
}
await audit.log(req.session.id, 'unban_fingerprint', 'fingerprint', null, { fingerprint });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/ip\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { ip, reason, duration } = req.post || {};
if (!ip) throw new Error('Missing IP address');
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
// Already a stored hash (banned from the IP list) or a raw address typed by the moderator
const isHash = /^[a-f0-9]{64}$/i.test(ip);
const ipHash = isHash ? ip.toLowerCase() : security.hashIP(ip);
// With hashing on, the raw address is never persisted, not even in the ban list
const ipStored = (isHash || cfg.websrv.hash_user_ips) ? ipHash : ip;
await db`
INSERT INTO banned_ips (ip, ip_hash, banned_by, reason, expires_at)
VALUES (${ipStored}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires})
ON CONFLICT (ip) DO UPDATE
SET reason = EXCLUDED.reason,
expires_at = EXCLUDED.expires_at,
banned_by = EXCLUDED.banned_by,
ip_hash = EXCLUDED.ip_hash
`;
await audit.log(req.session.id, 'ban_ip', 'ip', null, { ip, reason, duration });
// Broadcast ban event via SSE
await db.notify('bans', JSON.stringify({
ip,
ipHash,
reason: (reason || 'Banned by moderator').substring(0, 300),
expires
})).catch(() => {});
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/ip\/unban\/?$/, lib.modAuth, async (req, res) => {
try {
const { ip } = req.post || {};
if (!ip) throw new Error('Missing IP');
await db`DELETE FROM banned_ips WHERE ip = ${ip} OR ip_hash = ${ip}`;
await audit.log(req.session.id, 'unban_ip', 'ip', null, { ip });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/hardware\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { hw_fingerprint, reason, duration, ban_ips, user_id } = req.post || {};
if (!hw_fingerprint) throw new Error('Missing hardware fingerprint');
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
const result = await security.banAnonymousUser({
userId: user_id ? +user_id : null,
hwFingerprint: hw_fingerprint,
bannedBy: req.session.id,
reason: reason || 'Banned by moderator',
expires,
banIps: ban_ips !== false,
banHardware: true
});
await audit.log(req.session.id, 'ban_hardware', 'hardware', null, { hw_fingerprint, reason, duration });
return res.json({ success: true, result });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
router.post(/^\/api\/v2\/admin\/bans\/hardware\/unban\/?$/, lib.modAuth, async (req, res) => {
try {
const { hw_fingerprint } = req.post || {};
if (!hw_fingerprint) throw new Error('Missing hardware fingerprint');
await db`DELETE FROM banned_hardware_fingerprints WHERE hw_fingerprint = ${hw_fingerprint}`;
await audit.log(req.session.id, 'unban_hardware', 'hardware', null, { hw_fingerprint });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message });
}
});
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`;
@@ -672,154 +438,11 @@ export default (router, tpl) => {
if (res.json) return res.json({ success: false });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
}
// Revoke any linked invite request and notify the user
const [linkedRequest] = await db`
SELECT ir.id, ir.user_id FROM invite_requests ir
WHERE ir.token_id = ${req.post.id} AND ir.status = 'approved'
`;
if (linkedRequest) {
await db`
UPDATE invite_requests
SET status = 'revoked', reviewed_at = NOW(), reviewed_by = ${req.session.id}
WHERE id = ${linkedRequest.id}
`;
if (linkedRequest.user_id) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${linkedRequest.user_id}, 'invite_denied', 0, ${db.json({ revoked: true })})
`;
}
}
await db`delete from invite_tokens where id = ${req.post.id}`;
if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
});
// ═══════════════════ Invite Requests (admin) ═══════════════════
// List all invite requests
router.get(/^\/api\/v2\/admin\/invite-requests\/?$/, lib.auth, async (req, res) => {
try {
const requests = await db`
SELECT ir.*,
u_reviewer.user as reviewed_by_name,
u_requester.login as requester_login,
u_requester.user as requester_name
FROM invite_requests ir
LEFT JOIN "user" u_reviewer ON u_reviewer.id = ir.reviewed_by
LEFT JOIN "user" u_requester ON u_requester.id = ir.user_id
ORDER BY
CASE WHEN ir.status = 'pending' THEN 0 ELSE 1 END,
ir.created_at DESC
LIMIT 200
`;
if (res.json) return res.json({ success: true, requests });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, requests }));
} catch (err) {
const msg = lib.logError(err, 'Failed to fetch invite requests');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
// Approve an invite request — generates a token and notifies the anon user
router.post(/^\/api\/v2\/admin\/invite-requests\/approve\/?$/, lib.auth, async (req, res) => {
try {
const { id } = req.post;
if (!id) {
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
}
// Check request exists and is pending
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
if (!request) {
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
}
// Generate invite token
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
const [tokenRow] = await db`
INSERT INTO invite_tokens (token, created_at, created_by)
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id})
RETURNING id
`;
// Update request status
await db`
UPDATE invite_requests
SET status = 'approved', token_id = ${tokenRow.id}, reviewed_by = ${req.session.id}, reviewed_at = NOW()
WHERE id = ${+id}
`;
// Find the anon user's real user_id via their fingerprint, and send them a notification
const anonUser = await db`
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
`;
if (anonUser.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${anonUser[0].user_id}, 'invite_approved', 0, ${db.json({ token, request_id: +id })})
`;
}
console.log(`[INVITE-REQ] Approved request #${id} (fp: ${request.fingerprint.slice(0, 12)}…) → token ${token.slice(0, 8)}…`);
if (res.json) return res.json({ success: true, token });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token }));
} catch (err) {
const msg = lib.logError(err, 'Failed to approve invite request');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
// Deny an invite request
router.post(/^\/api\/v2\/admin\/invite-requests\/deny\/?$/, lib.auth, async (req, res) => {
try {
const { id } = req.post;
if (!id) {
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
}
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
if (!request) {
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
}
await db`
UPDATE invite_requests
SET status = 'denied', reviewed_by = ${req.session.id}, reviewed_at = NOW()
WHERE id = ${+id}
`;
// Notify the anon user their request was denied
const anonUser = await db`
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
`;
if (anonUser.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data)
VALUES (${anonUser[0].user_id}, 'invite_denied', 0, ${db.json({ request_id: +id })})
`;
}
console.log(`[INVITE-REQ] Denied request #${id} (fp: ${request.fingerprint.slice(0, 12)}…)`);
if (res.json) return res.json({ success: true });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
} catch (err) {
const msg = lib.logError(err, 'Failed to deny invite request');
if (res.json) return res.json({ success: false, msg });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
});
router.post(/^\/api\/v2\/admin\/ban\/?$/, lib.modAuth, async (req, res) => {
try {
const { user_id, reason, duration } = req.post;
@@ -858,29 +481,6 @@ export default (router, tpl) => {
where id = ${+user_id}
`;
// If this is an anonymous identity, cascade the ban to fingerprint and IPs
const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`;
if (anonIdent.length > 0 || (targetUser[0].login && targetUser[0].login.startsWith('anon_'))) {
await security.banAnonymousUser({
userId: +user_id,
fingerprint: anonIdent[0]?.fingerprint || null,
bannedBy: req.session.id,
reason,
expires,
banIps: true
});
} else {
// Broadcast ban to registered user's active SSE sessions
const userIps = await db`SELECT distinct ip FROM user_ips WHERE user_id = ${+user_id}`;
const ips = userIps.map(r => r.ip).filter(Boolean);
await db.notify('bans', JSON.stringify({
userId: +user_id,
reason: (reason || 'Violation of community rules').substring(0, 300),
expires,
ips
})).catch(() => {});
}
// Log it in audit
await audit.log(req.session.id, 'ban_user', 'user', +user_id, { reason, duration, target_user: targetUser[0].user });
@@ -916,12 +516,6 @@ export default (router, tpl) => {
where id = ${+user_id}
`;
// Clean up any banned fingerprint for this identity
const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`;
if (anonIdent.length > 0) {
await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${anonIdent[0].fingerprint}`;
}
// Log it in audit
await audit.log(req.session.id, 'unban_user', 'user', +user_id);
@@ -1109,15 +703,6 @@ export default (router, tpl) => {
// Write formatted JSON to config.json on disk
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
// Also update config.yaml if it exists
const configYamlPath = path.resolve(process.cwd(), "config.yaml");
try {
const yamlDoc = new YAML.Document(updatedConfig);
await fs.writeFile(configYamlPath, String(yamlDoc), "utf-8");
} catch (yamlErr) {
console.warn('[ADMIN] Could not sync config.yaml:', yamlErr.message);
}
// Mutate in-memory cfg object so changes apply immediately
Object.assign(cfg, updatedConfig);
@@ -1315,179 +900,87 @@ export default (router, tpl) => {
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
const limit = 50;
const offset = (page - 1) * limit;
const rawStatus = (req.url.qs?.status || req.url.qs?.filter || '').toLowerCase().trim();
const rawRole = (req.url.qs?.role || '').toLowerCase().trim();
const onlyLegacy = req.url.qs?.legacy === '1' || req.url.qs?.legacy === 'true' ||
req.url.qs?.legacy_only === '1' || req.url.qs?.legacy_only === 'true' ||
req.url.qs?.only_legacy === '1' || req.url.qs?.only_legacy === 'true' ||
rawStatus === 'legacy';
const status = onlyLegacy ? '' : rawStatus;
const role = onlyLegacy ? '' : rawRole;
let users;
let total;
if (onlyLegacy) {
users = await db`
WITH ghost_users AS (
SELECT
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, ARRAY[]::text[] as groups, true as activated,
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
FROM items i
WHERE i.username IS NOT NULL AND i.username != ''
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
GROUP BY i.username
),
paginated_users AS (
SELECT * FROM ghost_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
SELECT
pu.*,
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
COALESCE(ic.upload_count, 0) as upload_count,
0::bigint as comment_count,
0::bigint as failed_attempts
FROM paginated_users pu
LEFT JOIN LATERAL (
SELECT COUNT(*) as upload_count
FROM items
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
) ic ON true
`;
const totalCountGhost = await db`
SELECT COUNT(DISTINCT i.username) as c
const users = await db`
WITH filtered_users AS (
SELECT
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
),
ghost_users AS (
SELECT
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
FROM items i
WHERE i.username IS NOT NULL AND i.username != ''
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
`;
total = parseInt(totalCountGhost[0].c);
} else {
let qCond = null;
if (q) {
if (exactMatch) {
qCond = db`(lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q}))`;
} else {
const pattern = '%' + lib.escapeLike(q) + '%';
qCond = db`(u.login ILIKE ${pattern} OR u.user ILIKE ${pattern} OR u.email ILIKE ${pattern})`;
}
}
GROUP BY i.username
),
all_users AS (
SELECT * FROM filtered_users
UNION ALL
SELECT * FROM ghost_users
),
paginated_users AS (
SELECT * FROM all_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
SELECT
pu.*,
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
COALESCE(ic.upload_count, 0) as upload_count,
COALESCE(cc.comment_count, 0) as comment_count,
COALESCE(la.failed_attempts, 0) as failed_attempts
FROM paginated_users pu
LEFT JOIN LATERAL (
SELECT COUNT(*) as upload_count
FROM items
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
) ic ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) as comment_count
FROM comments
WHERE user_id = pu.id AND is_deleted = false
) cc ON pu.id IS NOT NULL
LEFT JOIN LATERAL (
SELECT COUNT(*) as failed_attempts
FROM login_attempts
WHERE username = pu.login
AND success = false
AND type = 'login'
AND attempted_at > now() - interval '10 hours'
) la ON true
`;
let statusCond = null;
if (status === 'banned') {
statusCond = db`u.banned = true`;
} else if (status === 'active') {
statusCond = db`(u.activated = true AND u.banned = false)`;
} else if (status === 'pending') {
statusCond = db`(u.activated = false AND u.banned = false)`;
}
let roleCond = null;
if (role === 'staff' || status === 'staff') {
roleCond = db`(u.admin = true OR u.is_moderator = true)`;
} else if (role === 'admin') {
roleCond = db`u.admin = true`;
} else if (role === 'mod') {
roleCond = db`(u.is_moderator = true AND u.admin = false)`;
} else if (role === 'user') {
roleCond = db`(u.admin = false AND u.is_moderator = false)`;
}
users = await db`
WITH filtered_users AS (
SELECT
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.groups, u.activated,
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
WHERE true
${qCond ? db`AND ${qCond}` : db``}
${statusCond ? db`AND ${statusCond}` : db``}
${roleCond ? db`AND ${roleCond}` : db``}
),
paginated_users AS (
SELECT * FROM filtered_users
ORDER BY created_at DESC
LIMIT ${limit} OFFSET ${offset}
)
SELECT
pu.*,
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
COALESCE(ic.upload_count, 0) as upload_count,
COALESCE(cc.comment_count, 0) as comment_count,
COALESCE(la.failed_attempts, 0) as failed_attempts
FROM paginated_users pu
LEFT JOIN LATERAL (
SELECT COUNT(*) as upload_count
FROM items
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
) ic ON true
LEFT JOIN LATERAL (
SELECT COUNT(*) as comment_count
FROM comments
WHERE user_id = pu.id AND is_deleted = false
) cc ON pu.id IS NOT NULL
LEFT JOIN LATERAL (
SELECT COUNT(*) as failed_attempts
FROM login_attempts
WHERE username = pu.login
AND success = false
AND type = 'login'
AND attempted_at > now() - interval '10 hours'
) la ON true
`;
const totalCountActual = await db`
SELECT COUNT(*) as c FROM "user" u
WHERE true
${qCond ? db`AND ${qCond}` : db``}
${statusCond ? db`AND ${statusCond}` : db``}
${roleCond ? db`AND ${roleCond}` : db``}
`;
total = parseInt(totalCountActual[0].c);
}
let totalLabel = 'registered members';
let emptyMsg = 'No users matched your search.';
if (onlyLegacy) {
totalLabel = 'legacy accounts';
emptyMsg = 'No legacy users matched your search.';
} else if (status === 'banned') {
totalLabel = 'banned members';
emptyMsg = 'No banned users found.';
} else if (status === 'pending') {
totalLabel = 'pending members';
emptyMsg = 'No pending users found.';
} else if (status === 'active') {
totalLabel = 'active members';
emptyMsg = 'No active users found.';
} else if (role === 'staff' || status === 'staff') {
totalLabel = 'staff members';
emptyMsg = 'No staff members found.';
} else if (role === 'admin') {
totalLabel = 'admin members';
emptyMsg = 'No admin users found.';
} else if (role === 'mod') {
totalLabel = 'moderator members';
emptyMsg = 'No moderator users found.';
} else if (role === 'user') {
totalLabel = 'regular users';
emptyMsg = 'No regular users found.';
}
const totalCountActual = await db`
SELECT COUNT(*) as c FROM "user" u
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
`;
const totalCountGhost = await db`
SELECT COUNT(DISTINCT i.username) as c
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
`;
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
const data = {
session: req.session,
@@ -1496,11 +989,6 @@ export default (router, tpl) => {
page,
total,
hasMore: users.length === limit,
onlyLegacy,
status,
role,
totalLabel,
emptyMsg,
totals: await lib.countf0cks(),
log_user_ips: getLogUserIps(),
tmp: null
@@ -1508,8 +996,6 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
res.setHeader('X-Total-Count', total.toString());
res.setHeader('X-Total-Label', totalLabel);
res.setHeader('X-Empty-Msg', emptyMsg);
res.setHeader('X-Has-More', (users.length === limit).toString());
return res.reply({
body: tpl.render("admin/users_list", data, req)
@@ -1615,45 +1101,6 @@ export default (router, tpl) => {
}
});
router.post(/^\/api\/v2\/admin\/users\/set-groups\/?$/, lib.auth, async (req, res) => {
try {
const { user_id, groups } = req.post;
if (!user_id) throw new Error('Missing user_id');
const target = await db`SELECT id, login FROM "user" WHERE id = ${+user_id} LIMIT 1`;
if (!target.length) throw new Error('User not found.');
let groupsArr = [];
if (Array.isArray(groups)) {
groupsArr = groups.map(s => String(s).trim().toLowerCase()).filter(Boolean);
} else if (typeof groups === 'string') {
groupsArr = groups.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
}
await db`
UPDATE "user"
SET groups = ${groupsArr}
WHERE id = ${+user_id}
`;
// Invalidate target user's session cache and session table so new groups load immediately
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
await audit.log(req.session.id, 'admin_set_groups', 'user', +user_id, {
target_login: target[0].login,
groups: groupsArr
});
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: true,
groups: groupsArr,
msg: `Groups for "${target[0].login}" updated to: ${groupsArr.join(', ') || 'none'}`
}));
} catch (err) {
console.error('[ADMIN] Set groups failed:', err);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
try {
@@ -1690,26 +1137,6 @@ export default (router, tpl) => {
}
});
// The system ghost that deleted users' content is reassigned to. It can never log in: password '!'
// matches no hash, it is not activated and it is banned (the unban route refuses to touch it).
const ensureGhostUser = async () => {
let ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (ghost.length) return ghost;
await db`
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated, banned, ban_reason, created_at)
VALUES ('deleted_user', 'deleted_user', '!', false, false, false, true, 'System account', now())
ON CONFLICT (login) DO NOTHING
`;
ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (!ghost.length) throw new Error('Could not create the "deleted_user" ghost account.');
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, display_name)
VALUES (${ghost[0].id}, 0, 'amoled', 0, null, 'default.png', 'deleted user')
ON CONFLICT (user_id) DO NOTHING
`;
return ghost;
};
router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => {
try {
const { user_id } = req.post;
@@ -1720,8 +1147,9 @@ export default (router, tpl) => {
if (!target.length) throw new Error('User not found');
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be deleted.');
// Get deleted_user info (created on first use; see migrations/add_deleted_user_ghost.sql)
const ghost = await ensureGhostUser();
// Get deleted_user info
const ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
if (!ghost.length) throw new Error('Ghost account "deleted_user" not found. Please run migration.');
const targetId = target[0].id;
const targetLogin = target[0].login;
@@ -2327,83 +1755,5 @@ export default (router, tpl) => {
}
});
// ── Admin Bar: User Impersonation ────────────────────────────────────────────
// GET /api/v2/admin/users/search?q= — autocomplete for the admin bar "View as" input
router.get(/^\/api\/v2\/admin\/users\/search\/?$/, lib.adminAuth, async (req, res) => {
try {
const q = (req.url.qs?.q || '').trim();
if (!q || q.length < 1) {
if (res.json) return res.json([]);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end('[]');
}
const escaped = lib.escapeLike(q);
const users = await db`
SELECT id, login as user
FROM "user"
WHERE login ILIKE ${'%' + escaped + '%'}
AND activated = true
AND banned = false
ORDER BY login ASC
LIMIT 10
`;
const result = users.map(u => ({ id: u.id, user: u.user }));
if (res.json) return res.json(result);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
} catch (e) {
if (res.json) return res.json({ success: false, msg: e.message });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: e.message }));
}
});
// POST /api/v2/admin/impersonate — start impersonating a user
router.post(/^\/api\/v2\/admin\/impersonate\/?$/, lib.adminAuth, async (req, res) => {
try {
const { username } = req.post;
if (!username) throw new Error('Username required');
const target = await db`
SELECT id, login as user
FROM "user"
WHERE login = ${username.toLowerCase().trim()}
AND activated = true
LIMIT 1
`;
if (target.length === 0) throw new Error('User not found');
if (target[0].id === req.session.id) throw new Error('Cannot impersonate yourself');
// Build signed payload: base64(JSON) + "." + HMAC
const crypto = (await import('crypto')).default || await import('crypto');
const secret = cfg.main.secret || cfg.main.url.full || 'f0ckm-impersonate-secret';
const payload = Buffer.from(JSON.stringify({
uid: target[0].id,
orig: lib.sha256(req.cookies.session),
ts: Date.now()
})).toString('base64url');
const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const cookieVal = `${payload}.${sig}`;
const cookieOpts = lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT');
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `impersonate=${cookieVal}; ${cookieOpts}`
}).end(JSON.stringify({ success: true, username: target[0].user }));
} catch (e) {
if (res.json) return res.json({ success: false, msg: e.message });
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: e.message }));
}
});
// POST /api/v2/admin/stop-impersonate — exit impersonation
router.post(/^\/api\/v2\/admin\/stop-impersonate\/?$/, async (req, res) => {
// No auth guard needed — just clear the cookie
const cookieOpts = lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT');
res.writeHead(200, {
'Content-Type': 'application/json',
'Set-Cookie': `impersonate=; ${cookieOpts}`
}).end(JSON.stringify({ success: true }));
});
return router;
}
+50 -417
View File
@@ -1,297 +1,9 @@
import f0cklib from "../routeinc/f0cklib.mjs";
import lib from "../lib.mjs";
import url from "url";
import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs";
import { isAnonymizeSession, canAnonDo, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
export default (router, tpl) => {
// ── Merged random + item load: single request instead of two ────────────
router.get(/^\/ajax\/item\/random/, async (req, res) => {
const tAjaxStart = Date.now();
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const isPrefetch = !!(
query.prefetch === '1' ||
req.url?.qs?.prefetch === '1' ||
req.headers?.['x-purpose'] === 'prefetch' ||
req.headers?.['purpose'] === 'prefetch' ||
req.headers?.['sec-purpose'] === 'prefetch'
);
const isGuest = !req.session || !req.session.user;
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
const ratingsRaw = req.cookies.ratings;
const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null));
const tag = query.tag || null;
const hall = query.hall || null;
const user = query.user || null;
const userHall = query.userHall || null;
const userHallOwner = query.userHallOwner || null;
const isFav = query.fav === 'true';
const isStrict = query.strict === '1';
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const mime = (typeof query.mime !== 'undefined') ? (query.mime || null) : (cookieMime || null);
// Resolve random item ID
const randomData = await f0cklib.getRandom({
user, tag, hall, userHall, userHallOwner, mime,
fav: isFav,
mode: reqMode,
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
});
const tRandom = Date.now();
if (!randomData || !randomData.itemid) {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: '', error: true, success: false, message: 'No items found' })
});
}
const itemid = String(randomData.itemid);
// Build context URL for the resolved item
let contextUrl = `/${itemid}`;
if (tag) contextUrl = `/tag/${encodeURIComponent(tag)}/${itemid}`;
if (hall) contextUrl = `/h/${encodeURIComponent(hall)}/${itemid}`;
if (userHall && userHallOwner) {
contextUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
} else if (user) {
contextUrl = isFav
? `/user/${encodeURIComponent(user)}/favs/${itemid}`
: `/user/${encodeURIComponent(user)}/${itemid}`;
} else if (isFav) {
contextUrl = `/favs/${itemid}`;
}
if (mime) {
contextUrl = contextUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
}
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX-RANDOM] Resolved random item ${itemid} in ${Date.now() - tAjaxStart}ms`);
// Now run the full item load pipeline (same as /ajax/item/:id)
const bypassFilter = !!(query.bypass === '1');
const data = await f0cklib.getf0ck({
itemid: itemid,
mode: reqMode,
ratings: ratingsArr,
bypass_filter: bypassFilter,
session: req.session,
url: contextUrl,
user: user,
tag: tag,
hall: hall,
userHall: userHall,
userHallOwner: userHallOwner,
mime: mime,
fav: isFav,
ids: null,
random: true,
strict: isStrict || req.session?.strict_mode,
explicitStrict: isStrict,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
subf0ck: query.subf0ck || null
});
const tAjaxFetch = Date.now();
if (!data.success) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 4: 'NSFL' };
const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null;
const errorHtml = tpl.render('error-partial', {
message: tErr('error.post_not_visible'),
tmp: null,
session: req.session ? { ...req.session } : false,
item_id: data.item?.id || itemid,
item_slug: data.item?.slug || null,
error_mode_label: errorModeLabel,
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
error_filter_hint_link: tErr('error.filter_hint_link'),
error_see_anyways: tErr('error.see_anyways')
}, req);
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html: errorHtml, pagination: '', error: true })
});
}
// xD Score + comments — parallelize subscription + comments fetch
if (req.session || !cfg.main.hide_comments_from_public) {
if (req.session?.id && !isPrefetch) {
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
}
const [sub, commentsForScore] = await Promise.all([
req.session ? f0cklib.getSubscriptionStatus(req.session.id, itemid) : false,
f0cklib.getComments(itemid, 'old', false)
]);
data.isSubscribed = sub;
const xdScore = f0cklib.computeXdScore(commentsForScore);
const xdMeta = f0cklib.xdScoreMeta(xdScore);
data.item.xd_score = xdScore;
data.item.xd_tier = xdMeta.tier;
data.item.xd_label = xdMeta.label;
data.commentsJSON = null;
data.comments = [];
} else {
data.isSubscribed = false;
data.commentsJSON = null;
data.comments = [];
data.item.xd_score = 0;
data.item.xd_tier = 0;
data.item.xd_label = '';
}
const tAjaxAux = Date.now();
// Session + template vars
data.session = req.session ? { ...req.session } : false;
data.url = { pathname: contextUrl };
data.fullscreen = req.cookies.fullscreen || 0;
data.hidePagination = true;
// Precompute hall display data
if (data.item && data.item.halls && data.item.halls.length) {
const currentHallSlug = data.tmp && data.tmp.hall
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
: null;
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
} else if (data.item) {
data.item.primaryHall = null;
data.item.otherHalls = [];
}
// Precomputed template booleans
if (data.item) {
const session = data.session;
const item = data.item;
// Keep the real uploader for permission checks — anonymization below overwrites item.username
const _realUsername = item.username;
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
item.author_banner_position = null;
item.author_banner_size = null;
item.author_avatar = null;
item.author_avatar_file = null;
item.author_color = null;
item.author_description = null;
item.author_display_name = null;
item.author_id = null;
if (data.uploader) {
data.uploader.name = 'anonymous';
data.uploader.id = null;
data.uploader.color = null;
}
if (Array.isArray(item.favorites)) {
item.favorites = item.favorites.map(f => {
const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id);
if (isSelf) return f;
return { user_id: null, user: 'anonymous', login: 'anonymous', display_name: 'Anonymous', avatar: null, avatar_file: null, username_color: null, hide_fav_badge: f.hide_fav_badge, is_anon: true };
});
}
}
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
data.item_username_lower = (item.username || '').toLowerCase();
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
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);
}
// Render
const itemHtml = tpl.render('ajax-item', data, req);
const paginationHtml = tpl.render('snippets/pagination', data, req);
const tAjaxRender = Date.now();
// Detailed timing breakdown
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
let itemPage = null;
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
if (effectiveOnara) {
try {
itemPage = await f0cklib.getItemPage({
targetItemId: data.item?.id || itemid,
targetItemPinned: data.item?.is_pinned,
user, tag, hall, userHall, userHallOwner, mime,
fav: isFav,
mode: reqMode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
is_admin: req.session?.admin,
minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0),
tagger: query.tagger || null
});
} catch (_) {}
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const itemNumericId = data.item?.id ? String(data.item.id) : itemid;
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
const itemUser = isAnon
? 'anonymous'
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
const itemUsername = isAnon
? 'anonymous'
: (data.item?.username || 'anonymous');
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
html: itemHtml,
pagination: paginationHtml,
title: data.title,
id: itemid,
numeric_id: itemNumericId,
slug: data.item?.slug || null,
page: itemPage,
thumb: itemThumb,
mode: itemMode,
tag_id: itemTagId,
mime: itemMime,
user: itemUser,
username: itemUsername,
dest: itemDest,
is_random: true
})
});
});
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
const tAjaxStart = Date.now();
let query = {};
@@ -303,14 +15,6 @@ export default (router, tpl) => {
query = req.url.qs || {};
}
const isPrefetch = !!(
query.prefetch === '1' ||
req.url?.qs?.prefetch === '1' ||
req.headers?.['x-purpose'] === 'prefetch' ||
req.headers?.['purpose'] === 'prefetch' ||
req.headers?.['sec-purpose'] === 'prefetch'
);
let contextUrl = `/${req.params.itemid}`;
if (query.tag) contextUrl = `/tag/${encodeURIComponent(query.tag)}/${req.params.itemid}`;
if (query.hall) contextUrl = `/h/${encodeURIComponent(query.hall)}/${req.params.itemid}`;
@@ -320,8 +24,6 @@ export default (router, tpl) => {
contextUrl = query.fav === 'true'
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
} else if (query.fav === 'true') {
contextUrl = `/favs/${req.params.itemid}`;
}
if (query.mime) {
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
@@ -329,19 +31,16 @@ export default (router, tpl) => {
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
const isGuest = !req.session || !req.session.user;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
const reqMode = query.mode !== undefined ? +query.mode : req.mode;
const ratingsRaw = req.cookies.ratings;
const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null));
const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
const bypassFilter = !!(query.force === '1' || query.force === 'true' || query.allow === '1' || query.allow === 'true' || query.bypass === '1' || query.bypass === 'true' || query.see_anyways === '1');
const data = await f0cklib.getf0ck({
itemid: itemid,
mode: reqMode,
ratings: ratingsArr,
bypass_filter: bypassFilter,
session: req.session,
url: contextUrl,
user: query.user,
@@ -351,31 +50,26 @@ export default (router, tpl) => {
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
fav: query.fav === 'true',
ids: query.ids || null,
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
subf0ck: query.subf0ck || null
user_id: req.session?.id
});
const tAjaxFetch = Date.now();
if (!data.success) {
const reqMode = (query.mode !== undefined ? +query.mode : req.mode) ?? 0;
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 4: 'NSFL' };
const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null;
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
const errorModeLabel = (req.session && reqMode !== 3) ? (modeLabels[reqMode] || null) : null;
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const errorHtml = tpl.render('error-partial', {
message: tErr('error.post_not_visible'),
tmp: null,
session: req.session ? { ...req.session } : false,
item_id: data.item?.id || itemid,
item_slug: data.item?.slug || (typeof itemid === 'string' ? itemid : null),
error_mode_label: errorModeLabel,
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
error_filter_hint_link: tErr('error.filter_hint_link'),
error_see_anyways: tErr('error.see_anyways')
error_filter_hint_link: tErr('error.filter_hint_link')
}, req);
return res.reply({
headers: { 'Content-Type': 'application/json' },
@@ -387,8 +81,8 @@ export default (router, tpl) => {
// Comments are always loaded async by the client via /api/comments/:id to avoid
// blocking the browser's main thread on posts with huge comment payloads.
if (req.session || !cfg.main.hide_comments_from_public) {
// Mark notifications as read (only when actually viewed, not on prefetch)
if (req.session?.id && !isPrefetch) {
// Mark notifications as read
if (req.session?.id) {
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
}
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
@@ -443,52 +137,10 @@ export default (router, tpl) => {
if (data.item) {
const session = data.session;
const item = data.item;
// Keep the real uploader for permission checks — anonymization below overwrites item.username
const _realUsername = item.username;
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
if (isAnonymizeSession(req.session)) {
if (item.src) item.src = null;
item.username = 'anonymous';
item.author_banner_file = null;
item.author_banner_position = null;
item.author_banner_size = null;
item.author_avatar = null;
item.author_avatar_file = null;
item.author_color = null;
item.author_description = null;
item.author_display_name = null;
item.author_id = null;
if (data.uploader) {
data.uploader.name = 'anonymous';
data.uploader.id = null;
data.uploader.color = null;
}
if (Array.isArray(item.favorites)) {
item.favorites = item.favorites.map(f => {
const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id);
if (isSelf) return f;
return {
user_id: null,
user: 'anonymous',
login: 'anonymous',
display_name: 'Anonymous',
avatar: null,
avatar_file: null,
username_color: null,
hide_fav_badge: f.hide_fav_badge,
is_anon: true
};
});
}
}
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
// Rating may also be changed by an anonymous uploader on their own item
const _ownerName = getSessionOwnerName(session);
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
@@ -513,48 +165,6 @@ export default (router, tpl) => {
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
- Render: ${tAjaxRender - tAjaxAux}ms`);
let itemPage = null;
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
if (effectiveOnara || query.get_page === '1') {
try {
itemPage = await f0cklib.getItemPage({
targetItemId: data.item?.id || itemid,
targetItemPinned: data.item?.is_pinned,
user: query.user,
tag: query.tag,
hall: query.hall,
userHall: query.userHall,
userHallOwner: query.userHallOwner,
mime: query.mime || (req.cookies.mime || null),
fav: query.fav === 'true',
mode: reqMode,
ratings: ratingsArr,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
session: req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
is_admin: req.session?.admin,
minXdScore: req.session?.min_xd_score || 0,
tagger: query.tagger || null
});
} catch (_) {}
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const itemNumericId = data.item?.id ? String(data.item.id) : (/^\d+$/.test(itemid) ? itemid : null);
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
const itemUser = isAnon
? 'anonymous'
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
const itemUsername = isAnon
? 'anonymous'
: (data.item?.username || 'anonymous');
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -562,16 +172,7 @@ export default (router, tpl) => {
pagination: paginationHtml,
title: data.title,
id: itemid,
numeric_id: itemNumericId,
slug: data.item?.slug || null,
page: itemPage,
thumb: itemThumb,
mode: itemMode,
tag_id: itemTagId,
mime: itemMime,
user: itemUser,
username: itemUsername,
dest: itemDest
slug: data.item?.slug || null
})
});
});
@@ -604,11 +205,17 @@ export default (router, tpl) => {
query = req.url.qs || {};
}
const isGuest = !req.session || !req.session.user;
const page = parseInt(query.page) || 1;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings;
const ratingsArr = isGuest ? ['sfw'] : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
const ratingsRaw = query.ratings || 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,
@@ -618,15 +225,18 @@ export default (router, tpl) => {
userHall: query.userHall || null,
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
mode: isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode),
mode: query.mode !== undefined ? +query.mode : req.mode,
sort: sortParam,
timeframe: timeframeParam,
oc: ocParam,
pinned: pinnedParam,
hasComments: hasCommentsParam,
ratings: ratingsArr,
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
fav: query.fav === 'true',
ids: query.ids || null,
total: query.total ? parseInt(query.total, 10) : undefined,
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
@@ -662,7 +272,8 @@ export default (router, tpl) => {
// Render just the thumbnail items
const itemsHtml = tpl.render('snippets/items-grid', {
items: data.items,
link: data.link
link: data.link,
layout: layoutParam
}, req);
// Render pagination
@@ -685,6 +296,27 @@ 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({
@@ -698,6 +330,7 @@ export default (router, tpl) => {
success: true,
html: itemsHtml,
titleHtml: titleHtml,
filterBarHtml: filterBarHtml,
pagination: paginationHtml,
hasMore: hasMore,
nextPage: data.pagination.next,
-155
View File
@@ -1,155 +0,0 @@
import { promises as fs } from "fs";
import path from "path";
import os from "os";
import { execFile } from "child_process";
import { promisify } from "util";
import cfg from "../../config.mjs";
const execFileAsync = promisify(execFile);
// Memory cache for in-flight requests to avoid concurrent yt-dlp calls for the same video
const pendingRequests = new Map();
export async function getOrExtractYoutubeAmbient(videoId) {
// Validate videoId: YouTube IDs are 11 chars containing [a-zA-Z0-9_-]
if (!videoId || !/^[a-zA-Z0-9_-]{6,15}$/.test(videoId)) {
throw new Error("Invalid YouTube video ID");
}
let tDir = cfg.paths?.t || 'public/t';
try { tDir = await fs.realpath(tDir); } catch (_) {}
const cacheFile = path.join(tDir, `ambient_${videoId}.json`);
// 1. Check disk cache
try {
const cached = await fs.readFile(cacheFile, 'utf8');
return JSON.parse(cached);
} catch (_) {
// Not cached yet
}
// 2. De-duplicate concurrent requests
if (pendingRequests.has(videoId)) {
return pendingRequests.get(videoId);
}
const promise = (async () => {
try {
const ytUrl = `https://www.youtube.com/watch?v=${videoId}`;
const proxyArgs = (cfg.main?.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '')
? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`]
: [];
// Run yt-dlp to inspect formats
const { stdout } = await execFileAsync('yt-dlp', [
'-j',
'--skip-download',
'--no-playlist',
...proxyArgs,
ytUrl
], { timeout: 15000 });
const info = JSON.parse(stdout);
const duration = Number(info.duration) || 0;
// Find storyboard format (prefer sb3, fallback to sb2/sb1/sb0)
const sbFormats = (info.formats || []).filter(f => f.format_id && f.format_id.startsWith('sb'));
const sb = sbFormats.find(f => f.format_id === 'sb3') ||
sbFormats.find(f => f.format_id === 'sb2') ||
sbFormats[sbFormats.length - 1];
let colors = [];
if (sb && (sb.url || (sb.fragments && sb.fragments[0]?.url))) {
const imgUrl = (sb.fragments && sb.fragments[0]?.url)
? sb.fragments[0].url
: sb.url.replace('$M', '0');
const tmpImg = path.join(os.tmpdir(), `sb_${videoId}_${Date.now()}.jpg`);
const tmpRgb = path.join(os.tmpdir(), `sb_${videoId}_${Date.now()}.rgb`);
try {
const curlProxyArgs = (cfg.main?.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '')
? ['--proxy', cfg.main.socks.includes('://') ? cfg.main.socks : `socks5h://${cfg.main.socks}`]
: [];
await execFileAsync('curl', ['-s', '-L', imgUrl, '-o', tmpImg, ...curlProxyArgs], { timeout: 10000 });
await execFileAsync('magick', [tmpImg, '-scale', '10x10!', `rgb:${tmpRgb}`], { timeout: 5000 });
const buf = await fs.readFile(tmpRgb);
for (let i = 0; i < buf.length; i += 3) {
colors.push([buf[i], buf[i + 1], buf[i + 2]]);
}
} finally {
await fs.unlink(tmpImg).catch(() => {});
await fs.unlink(tmpRgb).catch(() => {});
}
}
// If storyboard wasn't available or yielded no colors, fallback to thumbnail sampling
if (!colors.length) {
// Fallback: download thumbnail and sample a 3x3 palette
const thumbUrl = `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`;
const tmpImg = path.join(os.tmpdir(), `sb_thumb_${videoId}_${Date.now()}.jpg`);
const tmpRgb = path.join(os.tmpdir(), `sb_thumb_${videoId}_${Date.now()}.rgb`);
try {
await execFileAsync('curl', ['-s', '-L', thumbUrl, '-o', tmpImg], { timeout: 10000 });
await execFileAsync('magick', [tmpImg, '-scale', '3x3!', `rgb:${tmpRgb}`], { timeout: 5000 });
const buf = await fs.readFile(tmpRgb);
for (let i = 0; i < buf.length; i += 3) {
colors.push([buf[i], buf[i + 1], buf[i + 2]]);
}
} catch (_) {
// Minimal fallback
colors = [[30, 30, 35]];
} finally {
await fs.unlink(tmpImg).catch(() => {});
await fs.unlink(tmpRgb).catch(() => {});
}
}
const result = {
videoId,
duration,
colors
};
// Cache to disk
try {
await fs.writeFile(cacheFile, JSON.stringify(result), 'utf8');
} catch (err) {
console.warn(`[AMBIENT] Failed to write cache for ${videoId}:`, err.message);
}
return result;
} finally {
pendingRequests.delete(videoId);
}
})();
pendingRequests.set(videoId, promise);
return promise;
}
export default (router) => {
router.get(/^\/api\/v2\/ambient\/yt\/([a-zA-Z0-9_-]+)/, async (req, res) => {
const videoId = req.url.pathname.split('/')[5];
if (!videoId) {
return res.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ error: 'Missing video ID' }));
}
try {
const data = await getOrExtractYoutubeAmbient(videoId);
return res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'public, max-age=86400, stale-while-revalidate=604800'
}).end(JSON.stringify(data));
} catch (err) {
console.error(`[AMBIENT ERROR] Failed for ${videoId}:`, err.message);
return res.writeHead(500, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ error: err.message }));
}
});
return router;
};
-685
View File
@@ -1,685 +0,0 @@
import crypto from 'node:crypto';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import security from '../../security.mjs';
import {
getOrCreateAnonUserByCredential,
createAnonSession,
resolveAuditIP
} from '../../anon_auth.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, fromBase64url, getRpIdFromHost, aaguidProvider
} from '../../webauthn.mjs';
import { getEnableAnonymousAccess, getHwFingerprintEnabled } from '../../settings.mjs';
// Maximum number of passkeys a single anonymous identity may hold
const MAX_ANON_PASSKEYS = 4;
const countPasskeys = async userId => {
const rows = await db`SELECT COUNT(*)::int AS n FROM passkey_credentials WHERE user_id = ${userId}`;
return rows[0]?.n || 0;
};
export default router => {
router.group(/^\/api\/v2\/anon/, group => {
// ─── Helpers ─────────────────────────────────────────────────────────────
const formatCascadeReason = (sourceReason, prefix = 'Cascade ban from device') => {
if (!sourceReason) return prefix;
let clean = sourceReason;
while (/^Cascade ban from (device|hardware ID|key) \((.*)\)$/.test(clean)) {
clean = clean.replace(/^Cascade ban from (device|hardware ID|key) \((.*)\)$/, '$2');
}
return `${prefix} (${clean || 'Violation of community rules'})`;
};
// Client-supplied device fingerprint, or null when fingerprinting is disabled (then nothing is stored or matched)
const acceptHw = (hw) => (getHwFingerprintEnabled() && hw) ? String(hw).slice(0, 128) : null;
const acceptTombstone = (t) => (t && !getHwFingerprintEnabled()) ? { ...t, hw_fingerprint: null } : t;
const setBanCookie = (res, reason, expires) => {
const payload = encodeURIComponent(JSON.stringify({
banned: true,
reason: reason || 'Banned',
expires: expires ? new Date(expires).toISOString() : null
}));
res.setHeader('Set-Cookie', `f0ck_banned=${payload}; Path=/; Max-Age=31536000; SameSite=Lax`);
};
const checkAndCascadeBans = async (res, fingerprint, hwFingerprint, credentialId, tombstone) => {
// Tombstone cascade
if (tombstone && tombstone.banned) {
const tombstoneFp = tombstone.fingerprint;
const tombstoneHw = tombstone.hw_fingerprint;
const tombstoneBan = tombstoneFp ? await security.isFingerprintBanned(tombstoneFp) : null;
const tombstoneHwBan = (!tombstoneBan && tombstoneHw) ? await security.isHardwareBanned(tombstoneHw) : null;
const activeTombstoneBan = tombstoneBan || tombstoneHwBan;
if (activeTombstoneBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint: hwFingerprint || tombstoneHw,
bannedBy: activeTombstoneBan.banned_by,
reason: formatCascadeReason(activeTombstoneBan.reason, 'Cascade ban from device'),
expires: activeTombstoneBan.expires,
banIps: true,
banHardware: true
});
}
return activeTombstoneBan;
}
}
// Hardware fingerprint ban
if (hwFingerprint) {
const hwBan = await security.isHardwareBanned(hwFingerprint);
if (hwBan) {
const alreadyFpBanned = fingerprint ? await security.isFingerprintBanned(fingerprint) : false;
if (!alreadyFpBanned && fingerprint) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint,
bannedBy: hwBan.banned_by,
reason: formatCascadeReason(hwBan.reason, 'Cascade ban from hardware ID'),
expires: hwBan.expires,
banIps: true,
banHardware: true
});
}
return hwBan;
}
}
// Fingerprint ban
if (fingerprint) {
const fpBan = await security.isFingerprintBanned(fingerprint);
if (fpBan) {
if (hwFingerprint) {
const alreadyHwBanned = await security.isHardwareBanned(hwFingerprint);
if (!alreadyHwBanned) {
await security.banAnonymousUser({
fingerprint,
hwFingerprint,
bannedBy: fpBan.banned_by,
reason: formatCascadeReason(fpBan.reason, 'Cascade ban from key'),
expires: fpBan.expires,
banIps: true,
banHardware: true
});
}
}
return fpBan;
}
}
return null;
};
// ─── Deprecated SSH endpoint — hard cut ───────────────────────────────────
group.post(/\/session$/, async (req, res) => {
return res.json({
success: false,
msg: 'SSH-key anonymous authentication has been replaced by passkeys. Please refresh the page.'
}, 410);
});
// ─── Passkey Registration ─────────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/register/begin
* Returns WebAuthn registration options (challenge + rp + user config).
* The client does NOT need to be logged in.
*/
group.post(/\/passkey\/register\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-register' });
// Generate a temporary opaque user handle (32 random bytes, base64url)
// This will be replaced by the real user_id after finish, but WebAuthn requires
// a user.id at registration time. We store it in the challenge.
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
// Store the user handle in the challenge so finish can retrieve it
// (The challenge entry is keyed by challenge string)
// We re-issue the challenge with the user handle attached
const challengeWithHandle = generateChallenge({ type: 'anon-register', userHandle });
// Temporary display name for the registration prompt
const tmpName = `anon_new@${cfg.main?.url?.domain || 'f0ck.dev'}`;
const options = buildRegistrationOptions({
challenge: challengeWithHandle,
userId: userHandle,
userName: tmpName,
displayName: 'Anonymous',
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] register/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/register/finish
* Verify attestation, create shadow user + credential, establish session.
*/
group.post(/\/passkey\/register\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: rawHw, tombstone: rawTombstone } = body;
const hwFingerprint = acceptHw(rawHw);
const tombstone = acceptTombstone(rawTombstone);
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-register') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Verify the attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[ANON_PASSKEY] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
// Get fingerprint before ban checks (derived from credentialId)
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
// Ban checks
const ban = await checkAndCascadeBans(res, fingerprint, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false, banned: true,
fingerprint, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
// Get or create shadow user
const { userId, isNew, fingerprint: fp } = await getOrCreateAnonUserByCredential(
credentialId, req, hwFingerprint || null
);
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires, activated FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].activated === false) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
// Store / update passkey credential in passkey_credentials
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW()
`;
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null, credentialId);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
is_new: isNew,
user_id: userId,
fingerprint: fp,
short_fingerprint: fp.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_PASSKEY] register/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Passkey Authentication ───────────────────────────────────────────────
/**
* POST /api/v2/anon/passkey/auth/begin
* Returns authentication options. allowCredentials is empty (discoverable credential flow).
*/
group.post(/\/passkey\/auth\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const challenge = generateChallenge({ type: 'anon-auth' });
const options = buildAuthenticationOptions({
challenge,
allowCredentials: [], // discoverable — let the browser/Bitwarden pick
rpId: getRpIdFromHost(req.headers.host)
});
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] auth/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/auth/finish
* Verify assertion, establish anonymous session.
*/
group.post(/\/passkey\/auth\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
setBanCookie(res, ipBan.reason || 'IP address is banned', ipBan.expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: ipBan.reason, redirect: '/banned' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId, hw_fingerprint: rawHw, tombstone: rawTombstone } = body;
const hwFingerprint = acceptHw(rawHw);
const tombstone = acceptTombstone(rawTombstone);
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-auth') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Look up stored credential
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count, ai.fingerprint
FROM passkey_credentials pc
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = ${credentialId}
WHERE pc.credential_id = ${credentialId}
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'Passkey not registered. Please register first.' }, 401);
}
const { user_id: userId, public_key_spki: spki, sign_count: storedSignCount, fingerprint } = credRows[0];
// Verify the assertion
let authResult;
try {
authResult = await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki,
storedSignCount,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[ANON_PASSKEY] Auth verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Derive fingerprint if not stored yet (legacy or first-time)
const fpForBan = fingerprint || (() => {
return 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
})();
// Ban checks
const ban = await checkAndCascadeBans(res, fpForBan, hwFingerprint || null, credentialId, tombstone || null);
if (ban) {
setBanCookie(res, ban.reason || 'Banned', ban.expires);
return res.json({
success: false, banned: true,
fingerprint: fpForBan, hw_fingerprint: hwFingerprint,
msg: 'YOU ARE BANNED!', reason: ban.reason,
expires: ban.expires ? new Date(ban.expires).toLocaleString() : 'Permanent',
redirect: '/banned'
}, 403);
}
// Check user table ban
const userRows = await db`SELECT banned, ban_reason, ban_expires, activated FROM "user" WHERE id = ${userId} LIMIT 1`;
if (userRows.length > 0 && userRows[0].activated === false) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
if (userRows.length > 0 && userRows[0].banned) {
const u = userRows[0];
setBanCookie(res, u.ban_reason || 'Banned', u.ban_expires);
return res.json({ success: false, banned: true, msg: 'YOU ARE BANNED!', reason: u.ban_reason, redirect: '/banned' }, 403);
}
// Update sign count and last_used
await db`
UPDATE passkey_credentials
SET sign_count = ${authResult.newSignCount}, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Update anon_identities (hw_fingerprint, last_seen)
await db`
UPDATE anon_identities
SET last_seen = NOW()
${hwFingerprint ? db`, hw_fingerprint = ${hwFingerprint}` : db``}
WHERE user_id = ${userId} AND credential_id = ${credentialId}
`.catch(() => {});
const { session, csrf_token } = await createAnonSession(userId, req, hwFingerprint || null, credentialId);
res.setHeader('Set-Cookie', `session=${session}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({
success: true,
user_id: userId,
fingerprint: fpForBan,
short_fingerprint: fpForBan.slice(7, 15),
credential_id: credentialId,
hw_fingerprint: hwFingerprint || null,
csrf_token
});
} catch (err) {
console.error('[ANON_PASSKEY] auth/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Add Passkey to current anonymous identity ────────────────────────────
// Resolves the logged-in anonymous user, or null if the session isn't an anon identity
const getAnonSessionUserId = async req => {
if (!req.session?.id) return null;
const rows = await db`SELECT 1 FROM anon_identities WHERE user_id = ${req.session.id} LIMIT 1`;
return rows.length > 0 ? req.session.id : null;
};
/**
* POST /api/v2/anon/passkey/add/begin
* Registration options for an additional passkey on the current anonymous identity.
*/
group.post(/\/passkey\/add\/begin$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const userId = await getAnonSessionUserId(req);
if (!userId) {
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
}
const existing = await db`SELECT credential_id FROM passkey_credentials WHERE user_id = ${userId}`;
if (existing.length >= MAX_ANON_PASSKEYS) {
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
}
const userHandle = base64url(Buffer.from(crypto.getRandomValues(new Uint8Array(16))));
const challenge = generateChallenge({ type: 'anon-add', userId });
const options = buildRegistrationOptions({
challenge,
userId: userHandle,
userName: `anon@${cfg.main?.url?.domain || 'f0ck.dev'}`,
displayName: 'Anonymous',
rpId: getRpIdFromHost(req.headers.host)
});
// Stop the authenticator from registering a second copy of a passkey it already holds
options.excludeCredentials = existing.map(r => ({ type: 'public-key', id: r.credential_id }));
return res.json({ success: true, options });
} catch (err) {
console.error('[ANON_PASSKEY] add/begin error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
/**
* POST /api/v2/anon/passkey/add/finish
* Verify attestation and attach the new passkey to the current anonymous identity.
*/
group.post(/\/passkey\/add\/finish$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ success: false, msg: 'Anonymous access is disabled' }, 403);
}
const userId = await getAnonSessionUserId(req);
if (!userId) {
return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
}
if (!req.session.csrf_token || req.headers['x-csrf-token'] !== req.session.csrf_token) {
return res.json({ success: false, msg: 'Invalid CSRF token' }, 403);
}
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, hw_fingerprint: rawHw } = body;
const hwFingerprint = acceptHw(rawHw);
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'anon-add' || challengeMeta.userId !== userId) {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Re-check here too: two add flows could have been started in parallel
if (await countPasskeys(userId) >= MAX_ANON_PASSKEYS) {
return res.json({ success: false, msg: `You can have at most ${MAX_ANON_PASSKEYS} passkeys.` }, 400);
}
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[ANON_PASSKEY] Add verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
const taken = await db`SELECT user_id FROM passkey_credentials WHERE credential_id = ${credentialId} LIMIT 1`;
if (taken.length > 0) {
return res.json({ success: false, msg: 'This passkey is already registered.' }, 409);
}
const fingerprint = 'SHA256:' + crypto.createHash('sha256').update(Buffer.from(credentialId)).digest().toString('base64').replace(/=+$/, '');
const auditIp = resolveAuditIP(req);
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${userId}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${'Passkey'})
`;
await db`
INSERT INTO anon_identities (user_id, credential_id, fingerprint, created_ip, last_ip, hw_fingerprint)
VALUES (${userId}, ${credentialId}, ${fingerprint}, ${auditIp}, ${auditIp}, ${hwFingerprint || null})
ON CONFLICT (credential_id) DO NOTHING
`;
const passkeyCount = await countPasskeys(userId);
return res.json({ success: true, passkey_count: passkeyCount, passkey_max: MAX_ANON_PASSKEYS });
} catch (err) {
console.error('[ANON_PASSKEY] add/finish error:', err);
return res.json({ success: false, msg: err.message || 'Internal server error' }, 500);
}
});
// ─── Identity ─────────────────────────────────────────────────────────────
/**
* GET /api/v2/anon/passkeys
* The current anonymous identity's passkeys (settings page). `primary` marks the one the
* identity's fingerprint is derived from.
*/
group.get(/\/passkeys$/, async (req, res) => {
try {
const userId = await getAnonSessionUserId(req);
if (!userId) return res.json({ success: false, msg: 'Not logged in as anonymous' }, 401);
const rows = await db`
SELECT pc.id, pc.credential_id, pc.name, pc.aaguid, pc.created_at, pc.last_used,
(ai.credential_id IS NOT NULL) AS primary
FROM passkey_credentials pc
LEFT JOIN anon_identities ai ON ai.user_id = pc.user_id AND ai.credential_id = pc.credential_id
WHERE pc.user_id = ${userId}
ORDER BY pc.created_at ASC
`;
const [sess] = req.session.sess_id
? await db`SELECT passkey_credential_id FROM user_sessions WHERE id = ${+req.session.sess_id}`
: [];
const inUse = sess?.passkey_credential_id || null;
return res.json({
success: true,
max: MAX_ANON_PASSKEYS,
passkeys: rows.map(r => ({ ...r, provider: aaguidProvider(r.aaguid), current: !!inUse && r.credential_id === inUse }))
});
} catch (err) {
console.error('[ANON_PASSKEY] list error:', err);
return res.json({ success: false, msg: 'Failed to load passkeys' }, 500);
}
});
/**
* GET /api/v2/anon/identity
* Get the current anonymous identity or registered user state.
*/
group.get(/\/identity$/, async (req, res) => {
try {
if (!getEnableAnonymousAccess()) {
return res.json({ logged_in: false, is_anon: false, disabled: true });
}
if (!req.session) {
return res.json({ logged_in: false, is_anon: false });
}
const rows = await db`
SELECT ai.credential_id, ai.fingerprint, ai.hw_fingerprint, ai.created_at, ai.last_seen,
pc.name AS passkey_name, pc.aaguid
FROM anon_identities ai
LEFT JOIN passkey_credentials pc ON pc.credential_id = ai.credential_id
WHERE ai.user_id = ${req.session.id}
ORDER BY ai.created_at ASC
LIMIT 1
`;
if (rows.length > 0) {
const fp = rows[0].fingerprint;
return res.json({
logged_in: true,
is_anon: true,
user_id: req.session.id,
fingerprint: fp,
short_fingerprint: fp ? fp.slice(7, 15) : null,
hw_fingerprint: rows[0].hw_fingerprint,
credential_id: rows[0].credential_id,
passkey_name: rows[0].passkey_name,
passkey_count: await countPasskeys(req.session.id),
passkey_max: MAX_ANON_PASSKEYS,
csrf_token: req.session.csrf_token
});
}
return res.json({
logged_in: true,
is_anon: false,
user: req.session.user,
user_id: req.session.id,
csrf_token: req.session.csrf_token
});
} catch (err) {
console.error('[ANON_PASSKEY] Identity lookup error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
// ─── Logout ───────────────────────────────────────────────────────────────
/**
* POST /api/v2/anon/logout
* Clear anonymous session cookie and remove active session from database.
*/
group.post(/\/logout$/, async (req, res) => {
try {
if (req.session && req.session.sess_id) {
await db`
DELETE FROM user_sessions
WHERE id = ${+req.session.sess_id}
`;
}
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
return res.json({ success: true });
} catch (err) {
console.error('[ANON_PASSKEY] Logout error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
});
};
File diff suppressed because it is too large Load Diff
+32 -494
View File
@@ -1,44 +1,16 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import security from '../../security.mjs';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
import { canAnonDo, isAnonSession } from '../../settings.mjs';
import {
generateChallenge, consumeChallenge,
verifyRegistration, verifyAuthentication,
buildRegistrationOptions, buildAuthenticationOptions,
base64url, getRpIdFromHost, aaguidProvider
} from '../../webauthn.mjs';
// Passkey-only accounts store this instead of a hash; lib.verify never matches it
const PASSWORD_DISABLED = '!';
const LAST_PASSKEY_MSG = 'This is your last passkey and password login is disabled. Set a password first, or add another passkey.';
const IN_USE_PASSKEY_MSG = 'This passkey is the one you are signed in with right now and cannot be removed from this session.';
// The passkey the current session was opened with (null: password login or an older session)
const sessionPasskey = async (req) => {
if (!req.session?.sess_id) return null;
const [row] = await db`select passkey_credential_id from user_sessions where id = ${+req.session.sess_id}`;
return row?.passkey_credential_id || null;
};
// True when removing a passkey would leave a passkey-only account with no way to log in
const isLastPasskeyOfPasskeyOnly = async (userId) => {
const [row] = await db`
select (u.password = ${PASSWORD_DISABLED}) as disabled,
(select count(*)::int from passkey_credentials pc where pc.user_id = u.id) as n
from "user" u where u.id = ${+userId}
`;
return !!row && row.disabled && row.n <= 1;
};
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
export default router => {
router.group(/^\/api\/v2\/settings/, group => {
group.put(/\/setAvatar/, lib.registeredUser, async (req, res) => {
group.put(/\/setAvatar/, lib.loggedin, async (req, res) => {
if (!req.post.avatar) {
return res.json({
msg: 'no avatar provided',
@@ -74,7 +46,7 @@ export default router => {
});
// Switch to custom avatar (sets avatar ID to 0 so avatar_file is used)
group.put(/\/useCustomAvatar/, lib.registeredUser, async (req, res) => {
group.put(/\/useCustomAvatar/, lib.loggedin, async (req, res) => {
// Check if user has a custom avatar file
const userOpts = (await db`
select avatar_file from user_options where user_id = ${+req.session.id}
@@ -102,9 +74,6 @@ export default router => {
});
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
@@ -114,32 +83,19 @@ export default router => {
});
group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagname = req.post?.tagname || req.body?.tagname;
const tagId = req.post?.tag_id || req.body?.tag_id;
if (!tagname && !tagId) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tagname = req.post.tagname;
if (!tagname) return res.json({ success: false, msg: 'No tag provided' }, 400);
const tag = tagId
? (await db`select id, tag, normalized from tags where id = ${+tagId}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagname}) or tag = ${tagname}`)[0];
const tag = (await db`select id, tag, normalized from tags where normalized = slugify(${tagname})`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
await db`
update user_options
set excluded_tags = array_append(coalesce(excluded_tags, '{}'), ${tag.id})
where user_id = ${+req.session.id} and not (${tag.id} = any(coalesce(excluded_tags, '{}')))
set excluded_tags = array_append(excluded_tags, ${tag.id})
where user_id = ${+req.session.id} and not (${tag.id} = any(excluded_tags))
`;
if (req.session) {
if (!req.session.excluded_tags) req.session.excluded_tags = [];
if (!req.session.excluded_tags.includes(tag.id)) {
req.session.excluded_tags.push(tag.id);
}
}
// Return updated list
const tags = await db`
select t.id, t.tag, t.normalized
@@ -147,42 +103,32 @@ export default router => {
join tags t on t.id = et.id
`;
return res.json({ success: true, tags, tag }, 200);
return res.json({ success: true, tags }, 200);
});
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
}
const tagParam = decodeURIComponent(req.params.tag);
const isNum = /^\d+$/.test(tagParam);
const tag = isNum
? (await db`select id, tag, normalized from tags where id = ${+tagParam}`)[0]
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagParam}) or tag = ${tagParam}`)[0];
const tagname = decodeURIComponent(req.params.tag);
const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0];
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
await db`
update user_options
set excluded_tags = array_remove(coalesce(excluded_tags, '{}'), ${tag.id})
set excluded_tags = array_remove(excluded_tags, ${tag.id})
where user_id = ${+req.session.id}
`;
if (req.session && req.session.excluded_tags) {
req.session.excluded_tags = req.session.excluded_tags.filter(id => id !== tag.id);
}
const tags = await db`
select t.id, t.tag, t.normalized
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
join tags t on t.id = et.id
`;
return res.json({ success: true, tags, tag }, 200);
return res.json({ success: true, tags }, 200);
});
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
group.post(/\/link\/token/, lib.registeredUser, async (req, res) => {
group.post(/\/link\/token/, lib.loggedin, async (req, res) => {
// 6-char alphanumeric code
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
@@ -222,7 +168,7 @@ export default router => {
});
// Get linked accounts (Discord & Matrix)
group.get(/\/link\/accounts/, lib.registeredUser, async (req, res) => {
group.get(/\/link\/accounts/, lib.loggedin, async (req, res) => {
try {
const aliases = await db`
SELECT alias, type FROM user_alias
@@ -242,7 +188,7 @@ export default router => {
});
// Unlink account
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
try {
const alias = decodeURIComponent(req.params.alias);
const type = req.params.type;
@@ -268,7 +214,7 @@ export default router => {
// Backward compatibility routes for Discord (Deprecated)
// Discord Token Generation (Redirect to generic)
group.post(/\/discord\/token/, lib.registeredUser, async (req, res) => {
group.post(/\/discord\/token/, lib.loggedin, async (req, res) => {
// Just call the logic inline
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
try {
@@ -281,13 +227,13 @@ export default router => {
});
// Get linked Discord accounts (Legacy)
group.get(/\/discord\/linked/, lib.registeredUser, async (req, res) => {
group.get(/\/discord\/linked/, lib.loggedin, async (req, res) => {
const aliases = await db`SELECT alias FROM user_alias WHERE userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
});
// Unlink Discord account (Legacy)
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
const alias = decodeURIComponent(req.params.alias);
await db`DELETE FROM user_alias WHERE lower(alias) = lower(${alias}) AND userid = ${req.session.id} AND type = 'discord'`;
return res.json({ success: true, msg: 'Account unlinked' }, 200);
@@ -408,7 +354,7 @@ export default router => {
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.registeredUser, async (req, res) => {
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);
}
@@ -432,7 +378,7 @@ export default router => {
});
// Update Username Color preference
group.put(/\/username_color/, lib.registeredUser, async (req, res) => {
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
const { color } = req.post;
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
@@ -462,36 +408,8 @@ export default router => {
}
});
/**
* POST /api/v2/settings/password/disable
* Passkey-only account: switch off password login. Needs the current password (a hijacked session
* alone can't lock the owner out) and at least one passkey. Setting a new password re-enables it.
*/
group.post(/\/password\/disable$/, lib.registeredUser, async (req, res) => {
try {
const { current_password } = req.post || {};
const user = (await db`select password from "user" where id = ${+req.session.id}`)[0];
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
if (user.password === PASSWORD_DISABLED) return res.json({ success: false, msg: 'Password login is already disabled' }, 400);
if (!current_password || !(await lib.verify(current_password, user.password))) {
return res.json({ success: false, msg: 'Incorrect current password' }, 401);
}
const [{ n }] = await db`select count(*)::int as n from passkey_credentials where user_id = ${+req.session.id}`;
if (n < 1) return res.json({ success: false, msg: 'Add a passkey first, otherwise you could not log in anymore' }, 400);
await db`update "user" set password = ${PASSWORD_DISABLED}, force_password_change = false where id = ${+req.session.id}`;
// Sessions elsewhere may have been opened with the password: end them, keep this one
await db`delete from "user_sessions" where user_id = ${+req.session.id} and id != ${+req.session.sess_id}`;
await db`delete from login_attempts where username = ${req.session.login}`.catch(() => {});
return res.json({ success: true, msg: 'Password login disabled. Use your passkey to sign in.' });
} catch (err) {
console.error('[SETTINGS] Disable password error:', err);
return res.json({ success: false, msg: 'Failed to disable password' }, 500);
}
});
// Update password
group.put(/\/password/, lib.registeredUser, async (req, res) => {
group.put(/\/password/, lib.loggedin, async (req, res) => {
const { current_password, new_password, new_password_confirm } = req.post;
if (!new_password || !new_password_confirm) {
@@ -501,9 +419,7 @@ export default router => {
const user = (await db`select password, force_password_change from "user" where id = ${+req.session.id}`)[0];
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
// Passkey-only accounts have no current password: setting one turns password login back on
const passwordDisabled = user.password === PASSWORD_DISABLED;
if (!user.force_password_change && !passwordDisabled) {
if (!user.force_password_change) {
if (!current_password) {
return res.json({ success: false, msg: 'Current password is required' }, 400);
}
@@ -534,7 +450,7 @@ export default router => {
});
// Update email
group.put(/\/email/, lib.registeredUser, async (req, res) => {
group.put(/\/email/, lib.loggedin, async (req, res) => {
const { email } = req.post;
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
const cleanEmail = email.trim();
@@ -557,7 +473,7 @@ export default router => {
});
// Update Display Name
group.put(/\/display_name/, lib.registeredUser, async (req, res) => {
group.put(/\/display_name/, lib.loggedin, async (req, res) => {
const { display_name } = req.post;
if (display_name !== undefined && typeof display_name !== 'string') {
@@ -590,7 +506,7 @@ export default router => {
});
// Update Description
group.put(/\/description/, lib.registeredUser, async (req, res) => {
group.put(/\/description/, lib.loggedin, async (req, res) => {
if (!cfg.websrv.enable_profile_description) {
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
}
@@ -919,7 +835,7 @@ export default router => {
// GET /api/v2/settings/api-key
// Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview).
group.get(/\/api-key$/, lib.registeredUser, async (req, res) => {
group.get(/\/api-key$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -949,7 +865,7 @@ export default router => {
// POST /api/v2/settings/api-key/regenerate
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
group.post(/\/api-key\/regenerate$/, lib.registeredUser, async (req, res) => {
group.post(/\/api-key\/regenerate$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -977,7 +893,7 @@ export default router => {
// DELETE /api/v2/settings/api-key
// Revokes (deletes) the user's API key.
group.delete(/\/api-key$/, lib.registeredUser, async (req, res) => {
group.delete(/\/api-key$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
@@ -1001,7 +917,7 @@ export default router => {
// GET /api/v2/settings/api-key/sharex-config
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
group.get(/\/api-key\/sharex-config$/, lib.registeredUser, async (req, res) => {
group.get(/\/api-key\/sharex-config$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.status(403).reply({ body: 'API keys are disabled' });
}
@@ -1078,7 +994,7 @@ export default router => {
// GET /api/v2/settings/invites
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
group.get(/\/invites$/, lib.registeredUser, async (req, res) => {
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
@@ -1158,7 +1074,7 @@ export default router => {
// POST /api/v2/settings/invites/create
// Generates a new invite token if eligible and slots remain.
group.post(/\/invites\/create$/, lib.registeredUser, async (req, res) => {
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
@@ -1224,7 +1140,7 @@ export default router => {
// POST /api/v2/settings/invites/delete
// Deletes an unused invite token owned by the calling user.
group.post(/\/invites\/delete$/, lib.registeredUser, async (req, res) => {
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
@@ -1251,386 +1167,8 @@ export default router => {
}
});
// ─── Passkey Management (registered users) ──────────────────────────────
/**
* GET /api/v2/settings/passkeys
* List the current user's registered passkeys.
*/
group.get(/\/passkeys$/, lib.registeredUser, async (req, res) => {
try {
const rows = await db`
SELECT id, credential_id, name, aaguid, created_at, last_used
FROM passkey_credentials
WHERE user_id = ${req.session.id}
ORDER BY created_at DESC
`;
const inUse = await sessionPasskey(req);
return res.json({
success: true,
passkeys: rows.map(r => ({ ...r, provider: aaguidProvider(r.aaguid), current: !!inUse && r.credential_id === inUse }))
});
} catch (err) {
console.error('[PASSKEYS] List error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/begin
* Generate WebAuthn registration options for a logged-in user.
*/
group.post(/\/passkeys\/register\/begin$/, lib.registeredUser, async (req, res) => {
try {
// Get existing credentials to exclude (prevent re-registering same authenticator)
const existing = await db`
SELECT credential_id FROM passkey_credentials
WHERE user_id = ${req.session.id}
`;
const excludeCredentials = existing.map(r => ({ id: r.credential_id, type: 'public-key' }));
const challenge = generateChallenge({ type: 'user-register', userId: req.session.id });
// User handle: SHA256 of user_id encoded as base64url (stable, opaque)
const userHandle = base64url(
crypto.createHash('sha256').update(String(req.session.id)).digest().slice(0, 16)
);
const body = req.post || req.body || {};
const passkeyName = (body.name || '').trim().slice(0, 64) || null;
const options = buildRegistrationOptions({
challenge,
userId: userHandle,
userName: req.session.login || req.session.user,
displayName: req.session.display_name || req.session.user,
excludeCredentials,
rpId: getRpIdFromHost(req.headers.host)
});
// Stash the intended passkey name in challenge metadata
if (passkeyName) {
// Re-consume and re-store with name (challenges are stored by their value)
// Simpler: store name in a temporary Map keyed by challenge
options._passkeyName = passkeyName;
}
return res.json({ success: true, options, passkey_name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/register/finish
* Verify attestation and store new passkey for the logged-in user.
*/
group.post(/\/passkeys\/register\/finish$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, attestationObject, credentialId, name } = body;
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume and verify challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-register' || challengeMeta.userId !== req.session.id) {
return res.json({ success: false, msg: 'Challenge mismatch' }, 400);
}
// Verify attestation
let regResult;
try {
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
} catch (e) {
console.warn('[PASSKEYS] Registration verification failed:', e.message);
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
}
const passkeyName = ((name || '').trim().slice(0, 64)) || 'Passkey';
await db`
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
VALUES (${req.session.id}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${passkeyName})
ON CONFLICT (credential_id) DO UPDATE
SET sign_count = ${regResult.signCount}, last_used = NOW(), name = ${passkeyName}
`;
return res.json({ success: true, credential_id: credentialId, name: passkeyName });
} catch (err) {
console.error('[PASSKEYS] register/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/delete
* Remove a passkey credential owned by the current user.
* Uses POST + JSON body to avoid URL-encoding issues with credential IDs.
*/
group.post(/\/passkeys\/delete$/, lib.registeredUser, async (req, res) => {
try {
const body = req.post || req.body || {};
const credentialId = body.credential_id;
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* DELETE /api/v2/settings/passkeys/:id
* Legacy path — kept for compatibility.
*/
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
try {
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
const result = await db`
DELETE FROM passkey_credentials
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
RETURNING id
`;
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
return res.json({ success: true });
} catch (err) {
console.error('[PASSKEYS] Delete error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/begin
* Start a passkey login challenge for a registered user (no session required).
*/
group.post(/\/passkeys\/login\/begin$/, async (req, res) => {
try {
const challenge = generateChallenge({ type: 'user-login' });
const options = buildAuthenticationOptions({ challenge, allowCredentials: [], rpId: getRpIdFromHost(req.headers.host) });
return res.json({ success: true, options });
} catch (err) {
console.error('[PASSKEYS] login/begin error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
/**
* POST /api/v2/settings/passkeys/login/finish
* Verify assertion and create a full registered-user session.
*/
group.post(/\/passkeys\/login\/finish$/, async (req, res) => {
try {
const body = req.post || req.body || {};
const { challenge, clientDataJSON, authenticatorData, signature, credentialId } = body;
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
}
// Consume challenge
let challengeMeta;
try {
challengeMeta = consumeChallenge(challenge);
} catch (e) {
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
}
if (challengeMeta.type !== 'user-login') {
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
}
// Look up credential — must belong to a registered (non-anon) user
const credRows = await db`
SELECT pc.user_id, pc.public_key_spki, pc.sign_count,
u.login, u.user, u.activated, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
FROM passkey_credentials pc
JOIN "user" u ON u.id = pc.user_id
WHERE pc.credential_id = ${credentialId}
AND u.login IS NOT NULL
LIMIT 1
`;
if (credRows.length === 0) {
return res.json({ success: false, msg: 'No registered account found for this passkey.' }, 401);
}
const row = credRows[0];
if (row.banned) {
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
}
if (!row.activated) {
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
}
// Verify the assertion
try {
await verifyAuthentication({
challenge,
clientDataJSON,
authenticatorData,
signature,
spki: row.public_key_spki,
storedSignCount: row.sign_count,
rpId: getRpIdFromHost(req.headers.host)
});
} catch (e) {
console.warn('[PASSKEYS] login/finish verification failed:', e.message);
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
}
// Update sign count
await db`
UPDATE passkey_credentials SET sign_count = sign_count + 1, last_used = NOW()
WHERE credential_id = ${credentialId}
`;
// Create a full user session (same as normal login)
const stamp = Math.floor(Date.now() / 1000);
const ip = security.storableIP(security.getRealIP(req));
const sessionToken = crypto.randomBytes(32).toString('hex');
const csrfToken = crypto.randomBytes(32).toString('hex');
const sessRecord = {
user_id: row.user_id,
session: lib.sha256(sessionToken),
csrf_token: csrfToken,
browser: req.headers['user-agent'] || '',
created_at: stamp,
last_used: stamp,
last_action: '/passkey-login',
kmsi: 1,
ip,
passkey_credential_id: credentialId
};
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip', 'passkey_credential_id')}`;
res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false });
} catch (err) {
console.error('[PASSKEYS] login/finish error:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
return group;
});
// ═══════════════════ Invite Request (anon user) ═══════════════════
// Submit an invite request (requires a session — anon or registered, but mainly for anon)
router.post(/^\/api\/v2\/invite-request\/?$/, lib.loggedin, async (req, res) => {
try {
if (!req.session.is_anon) {
return res.json({ success: false, msg: 'You already have a registered account' }, 400);
}
const fingerprint = req.session.fingerprint;
if (!fingerprint) {
return res.json({ success: false, msg: 'No passkey identity found' }, 400);
}
// Check if there's already a pending request for this fingerprint
const [existing] = await db`
SELECT id, status FROM invite_requests
WHERE fingerprint = ${fingerprint} AND status = 'pending'
LIMIT 1
`;
if (existing) {
return res.json({ success: false, msg: 'You already have a pending invite request' }, 409);
}
const reason = (req.post.reason || '').trim().slice(0, 500);
const ip = security.getRealIP(req);
const ipHash = ip ? crypto.createHash('sha256').update(ip).digest('hex').slice(0, 16) : null;
await db`
INSERT INTO invite_requests (user_id, fingerprint, ip_hash, reason)
VALUES (${req.session.id}, ${fingerprint}, ${ipHash}, ${reason})
`;
// Notify all admin users
const anonLogin = req.session.login || req.session.user || 'anonymous';
const admins = await db`SELECT id FROM "user" WHERE admin = true`;
if (admins.length > 0) {
const notifications = admins.map(a => ({
user_id: a.id,
type: 'invite_request',
reference_id: 0,
data: db.json({ username: anonLogin, reason: reason.slice(0, 100) })
}));
await db`INSERT INTO notifications ${db(notifications)}`;
}
console.log(`[INVITE-REQ] New request from fp: ${fingerprint.slice(0, 12)}…`);
return res.json({ success: true, msg: 'Invite request submitted! An admin will review it shortly.' });
} catch (err) {
console.error('[INVITE-REQ] Submit error:', err);
return res.json({ success: false, msg: 'Failed to submit request' }, 500);
}
});
// Check status of current invite request
router.get(/^\/api\/v2\/invite-request\/status\/?$/, lib.loggedin, async (req, res) => {
try {
const fingerprint = req.session.fingerprint;
if (!fingerprint) {
return res.json({ success: true, status: null });
}
const [request] = await db`
SELECT ir.id, ir.status, ir.created_at, ir.reviewed_at,
it.token, it.is_used
FROM invite_requests ir
LEFT JOIN invite_tokens it ON it.id = ir.token_id
WHERE ir.fingerprint = ${fingerprint}
ORDER BY ir.created_at DESC
LIMIT 1
`;
if (!request) {
return res.json({ success: true, status: null });
}
// Auto-revoke if token was deleted or already used but status still says approved
let status = request.status;
if (status === 'approved' && (!request.token || request.is_used)) {
await db`UPDATE invite_requests SET status = 'revoked' WHERE id = ${request.id}`;
status = 'revoked';
}
return res.json({
success: true,
status,
created_at: request.created_at,
reviewed_at: request.reviewed_at,
token: status === 'approved' ? request.token : undefined
});
} catch (err) {
console.error('[INVITE-REQ] Status check error:', err);
return res.json({ success: false, msg: 'Failed to check status' }, 500);
}
});
return router;
};
-189
View File
@@ -1,189 +0,0 @@
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import audit from '../../audit.mjs';
import { isSquareClickerActive, cleanNotes, cleanTitle, canSeeItem, resolveTarget, insertMap } from '../../square_clicker.mjs';
import { convertOsu, saveDiffs } from '../../beatmap.mjs';
// Square Clicker API: user-made beatmaps for audio and video items and their scores.
// 404 when the feature is switched off (websrv.square_clicker_enabled: false, see inc/square_clicker.mjs).
// Maps of an album belong to one of its entries (album_item_id), since every entry is its own track.
const notFound = (res) => res.json({ success: false, msg: 'Not found' }, 404);
const bodyOf = (req) => req.body || req.post || {};
export default router => {
// List the maps of an item
router.get(/^\/api\/v2\/sqc\/maps\/?$/, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const itemId = parseInt(req.url.qs?.item_id, 10);
if (!itemId) return res.json({ success: false, msg: 'item_id required' }, 400);
const subId = parseInt(req.url.qs?.album_item_id, 10) || null;
const [item] = await db`SELECT id, username, COALESCE(visibility, 0) AS visibility FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
if (!item || !canSeeItem(item, req.session)) return notFound(res);
const maps = await db`
SELECT m.id, m.title, m.note_count, m.duration_ms, m.plays, m.created_at, m.user_id, m.album_item_id,
u.user AS username,
(SELECT MAX(s.score) FROM sqc_scores s WHERE s.map_id = m.id) AS best
FROM sqc_maps m
LEFT JOIN "user" u ON u.id = m.user_id
WHERE m.item_id = ${itemId} AND m.album_item_id IS NOT DISTINCT FROM ${subId}::int
ORDER BY m.created_at DESC
LIMIT 100
`;
return res.json({ success: true, maps });
});
// One map with its notes and top 10 scores
router.get(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`
SELECT m.id, m.item_id, m.album_item_id, m.title, m.notes, m.note_count, m.duration_ms, m.plays, m.created_at, m.user_id,
u.user AS username, i.username AS item_owner, COALESCE(i.visibility, 0) AS visibility
FROM sqc_maps m
JOIN items i ON i.id = m.item_id AND i.active = true AND i.is_deleted = false
LEFT JOIN "user" u ON u.id = m.user_id
WHERE m.id = ${id}
LIMIT 1
`;
if (!map || !canSeeItem({ visibility: map.visibility, username: map.item_owner }, req.session)) return notFound(res);
const scores = await db`
SELECT s.score, s.accuracy, s.max_combo, s.created_at, u.user AS username
FROM sqc_scores s
LEFT JOIN "user" u ON u.id = s.user_id
WHERE s.map_id = ${id}
ORDER BY s.score DESC, s.created_at ASC
LIMIT 10
`;
delete map.item_owner;
delete map.visibility;
return res.json({ success: true, map, scores });
});
// Create a map (logged in; audio and video items)
router.post(/^\/api\/v2\/sqc\/maps\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const b = bodyOf(req);
const target = await resolveTarget(b.item_id, b.album_item_id, req.session);
if (target.error) return res.json({ success: false, msg: target.error }, target.code);
const duration = Math.max(0, Math.min(6 * 3600 * 1000, parseInt(b.duration_ms, 10) || 0));
const v = cleanNotes(b.notes, duration);
if (v.error) return res.json({ success: false, msg: v.error }, 400);
const id = await insertMap({ itemId: target.item.id, albumItemId: target.albumItemId, userId: req.session.id, title: b.title, notes: v.notes, durationMs: duration });
return res.json({ success: true, id });
});
// Import beatmap difficulties (.osu texts; the browser unpacks .osz sets and sends only these) as maps
// of an item or album entry. Standard mode only; other modes and invalid files are reported as skipped.
router.post(/^\/api\/v2\/sqc\/import\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const b = bodyOf(req);
const target = await resolveTarget(b.item_id, b.album_item_id, req.session);
if (target.error) return res.json({ success: false, msg: target.error }, target.code);
const files = Array.isArray(b.files) ? b.files.slice(0, 40) : [];
if (!files.length) return res.json({ success: false, msg: 'No .osu files' }, 400);
if (files.reduce((sum, f) => sum + String((f && f.text) || '').length, 0) > 16 * 1024 * 1024) {
return res.json({ success: false, msg: 'Too large' }, 413);
}
const diffs = [], skipped = [];
for (const f of files) {
const d = convertOsu(String((f && f.text) || ''));
if (d.error) skipped.push({ version: d.version || String((f && f.name) || '?'), reason: d.error });
else diffs.push(d);
}
diffs.sort((a, z) => a.notes.length - z.notes.length);
const duration = Math.max(0, parseInt(b.duration_ms, 10) || 0);
const r = await saveDiffs({ itemId: target.item.id, albumItemId: target.albumItemId, userId: req.session.id, diffs, durationMs: duration });
return res.json({ success: true, imported: r.ids.length, ids: r.ids, skipped: [...skipped, ...r.skipped] });
});
// Edit a map (its creator, or staff: audited). Title and/or notes; changed notes clear the old scores,
// which were made on a different map.
router.put(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`SELECT id, item_id, user_id, title, duration_ms FROM sqc_maps WHERE id = ${id} LIMIT 1`;
if (!map) return notFound(res);
const isOwner = map.user_id === req.session.id;
const isStaff = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isStaff) return res.json({ success: false, msg: 'Unauthorized' }, 403);
const b = bodyOf(req);
const upd = {};
if (b.title !== undefined) upd.title = cleanTitle(b.title);
let notesChanged = false;
if (b.notes !== undefined) {
const v = cleanNotes(b.notes, map.duration_ms);
if (v.error) return res.json({ success: false, msg: v.error }, 400);
upd.notes = db.json(v.notes);
upd.note_count = v.notes.length;
notesChanged = true;
}
if (!Object.keys(upd).length) return res.json({ success: false, msg: 'Nothing to change' }, 400);
await db`UPDATE sqc_maps SET ${db(upd)} WHERE id = ${id}`;
let scoresCleared = 0;
if (notesChanged) {
const r = await db`DELETE FROM sqc_scores WHERE map_id = ${id}`;
scoresCleared = r.count || 0;
await db`UPDATE sqc_maps SET plays = 0 WHERE id = ${id}`;
}
if (!isOwner) {
audit.log(req.session.id, 'sqc_map_edit', 'sqc_map', id, { item_id: map.item_id, title: upd.title || map.title, notes_changed: notesChanged }).catch(() => {});
}
return res.json({ success: true, id, scores_cleared: scoresCleared });
});
// Submit a score (logged in). Plausibility-checked against the map, not trusted blindly.
router.post(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/scores\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`SELECT id, note_count FROM sqc_maps WHERE id = ${id} LIMIT 1`;
if (!map) return notFound(res);
const b = bodyOf(req);
const hits = b.hits && typeof b.hits === 'object' ? b.hits : {};
const h = {
300: Math.max(0, parseInt(hits[300], 10) || 0),
100: Math.max(0, parseInt(hits[100], 10) || 0),
50: Math.max(0, parseInt(hits[50], 10) || 0),
miss: Math.max(0, parseInt(hits.miss, 10) || 0),
};
if (h[300] + h[100] + h[50] + h.miss !== map.note_count) {
return res.json({ success: false, msg: 'Score does not match the map' }, 400);
}
const maxCombo = Math.max(0, Math.min(map.note_count, parseInt(b.max_combo, 10) || 0));
const accuracy = map.note_count ? +(((h[300] * 300 + h[100] * 100 + h[50] * 50) / (map.note_count * 300)) * 100).toFixed(2) : 0;
// Upper bound: every note a 300 at full combo multiplier
const maxScore = map.note_count * 300 * (1 + map.note_count / 25);
const score = Math.max(0, Math.min(Math.round(maxScore), parseInt(b.score, 10) || 0));
await db`
INSERT INTO sqc_scores ${db({
map_id: id,
user_id: req.session.id,
score,
accuracy,
max_combo: maxCombo,
hits: db.json(h),
created_at: ~~(Date.now() / 1e3),
})}
`;
await db`UPDATE sqc_maps SET plays = plays + 1 WHERE id = ${id}`;
return res.json({ success: true, score, accuracy });
});
// Delete a map: its creator, or staff (audited)
router.delete(/^\/api\/v2\/sqc\/maps\/(?<id>\d+)\/?$/, lib.loggedin, async (req, res) => {
if (!isSquareClickerActive(new Date(), req.session)) return notFound(res);
const id = parseInt(req.params.id, 10);
const [map] = await db`SELECT id, item_id, user_id, title FROM sqc_maps WHERE id = ${id} LIMIT 1`;
if (!map) return notFound(res);
const isOwner = map.user_id === req.session.id;
const isStaff = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isStaff) return res.json({ success: false, msg: 'Unauthorized' }, 403);
await db`DELETE FROM sqc_maps WHERE id = ${id}`;
if (!isOwner) {
audit.log(req.session.id, 'sqc_map_delete', 'sqc_map', id, { item_id: map.item_id, title: map.title }).catch(() => {});
}
return res.json({ success: true });
});
return router;
};
+65 -550
View File
@@ -5,263 +5,8 @@ import queue from "../../queue.mjs";
import cfg from "../../config.mjs";
import fs from "fs";
import path from "path";
import { logAnonActivity } from "../../anon_auth.mjs";
import { canAnonDo, isAnonSession, getSessionOwnerName } from "../../settings.mjs";
export default router => {
router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
const isModOrAdmin = !!(req.session?.admin || req.session?.is_moderator);
if (!isModOrAdmin) {
return res.json({ success: false, msg: 'Bulk selection is only available to administrators and moderators' }, 403);
}
const action = req.body?.action || req.post?.action || 'add';
const rawIds = req.body?.item_ids || req.post?.item_ids;
if (!rawIds || !Array.isArray(rawIds) || rawIds.length === 0) {
return res.json({ success: false, msg: 'No items selected' }, 400);
}
// Sanitize item IDs
const itemIds = [...new Set(rawIds.map(id => +id).filter(id => Number.isInteger(id) && id > 0))].slice(0, 500);
if (itemIds.length === 0) {
return res.json({ success: false, msg: 'Invalid item IDs' }, 400);
}
// 1. ADD TAGS
if (action === 'add') {
if (isAnonSession(req.session) && !canAnonDo('tag')) {
return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
}
let rawTags = req.body?.tags || req.post?.tags;
if (typeof rawTags === 'string') {
rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
}
if (!Array.isArray(rawTags) || rawTags.length === 0) {
return res.json({ success: false, msg: 'No tags provided' }, 400);
}
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
const validTags = [...new Set(
rawTags
.map(t => (typeof t === 'string' ? t.trim() : ''))
.filter(t => t.length > 0 && t.length <= 85 && !protectedTags.includes(t.toLowerCase()))
)];
if (validTags.length === 0) {
return res.json({ success: false, msg: 'Tags invalid or contain only reserved names' }, 400);
}
try {
const tagIds = [];
for (const tagname of validTags) {
let tagRow = await db`
SELECT id FROM "tags"
WHERE normalized = slugify(${tagname})
LIMIT 1
`;
let tagid = tagRow?.[0]?.id;
if (!tagid) {
const created = await db`
INSERT INTO "tags" ${db({ tag: tagname })}
RETURNING id
`;
tagid = created?.[0]?.id;
}
if (tagid) tagIds.push({ id: +tagid, name: tagname });
}
// Insert assignments
for (const postid of itemIds) {
for (const { id: tagid } of tagIds) {
try {
await db`
INSERT INTO "tags_assign" (tag_id, item_id, user_id)
VALUES (${tagid}, ${postid}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
} catch (assignErr) {
// Ignore duplicate errors if table has constraint
if (assignErr.code !== '23505') {
console.warn(`[BULK_TAG_ASSIGN_WARN] Item ${postid}, tag ${tagid}:`, assignErr.message);
}
}
}
}
await audit.log(req.session.id, 'bulk_add_tags', 'items', null, { item_ids: itemIds, tags: validTags }).catch(() => {});
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'bulk_tag',
targetId: itemIds[0],
details: { item_ids: itemIds, tags: validTags }
}).catch(() => {});
}
// Notify affected items asynchronously
(async () => {
for (const postid of itemIds) {
try {
const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
} catch {}
}
})().catch(() => {});
return res.json({
success: true,
count: itemIds.length,
tags: validTags,
msg: `Successfully added ${validTags.length} tag(s) to ${itemIds.length} item(s)`
});
} catch (err) {
console.error('[BULK_ADD_TAGS_ERROR]', err);
return res.json({ success: false, msg: 'Failed to add tags' }, 500);
}
}
// 2. REMOVE TAGS
if (action === 'remove') {
const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
let rawTags = req.body?.tags || req.post?.tags;
if (typeof rawTags === 'string') {
rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
}
if (!Array.isArray(rawTags) || rawTags.length === 0) {
return res.json({ success: false, msg: 'No tags provided to remove' }, 400);
}
try {
// Resolve tags to remove
const tagRows = await db`
SELECT id, tag, normalized FROM "tags"
WHERE normalized IN ${db(rawTags.map(t => t.trim().toLowerCase()))}
`;
if (tagRows.length === 0) {
return res.json({ success: false, msg: 'Tags not found' }, 404);
}
const tagIds = tagRows.map(r => r.id);
if (isModOrAdmin) {
await db`
DELETE FROM "tags_assign"
WHERE item_id IN ${db(itemIds)}
AND tag_id IN ${db(tagIds)}
`;
} else {
// Normal user can only remove tags from items they own
await db`
DELETE FROM "tags_assign"
WHERE item_id IN ${db(itemIds)}
AND tag_id IN ${db(tagIds)}
AND item_id IN (
SELECT id FROM items WHERE username = ${getSessionOwnerName(req.session)}
)
`;
}
await audit.log(req.session.id, 'bulk_remove_tags', 'items', null, { item_ids: itemIds, tags: tagRows.map(r => r.tag) }).catch(() => {});
// Realtime notifications
(async () => {
for (const postid of itemIds) {
try {
const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
} catch {}
}
})().catch(() => {});
return res.json({
success: true,
count: itemIds.length,
msg: `Successfully removed tag(s) from ${itemIds.length} item(s)`
});
} catch (err) {
console.error('[BULK_REMOVE_TAGS_ERROR]', err);
return res.json({ success: false, msg: 'Failed to remove tags' }, 500);
}
}
// 3. SET RATING (sfw / nsfw / nsfl)
if (action === 'set_rating') {
if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
}
const rating = (req.body?.rating || req.post?.rating || '').toLowerCase();
if (!['sfw', 'nsfw', 'nsfl'].includes(rating)) {
return res.json({ success: false, msg: 'Invalid rating. Choose sfw, nsfw, or nsfl' }, 400);
}
const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
const targetTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : nsflId);
try {
let eligibleIds = itemIds;
if (!isModOrAdmin) {
const ownedItems = await db`
SELECT id FROM items
WHERE id IN ${db(itemIds)}
AND username = ${getSessionOwnerName(req.session)}
AND active = true AND is_deleted = false
`;
eligibleIds = ownedItems.map(r => r.id);
}
if (eligibleIds.length === 0) {
return res.json({ success: false, msg: 'You do not have permission to rate the selected items' }, 403);
}
await db.begin(async sql => {
// Delete existing rating tags
await sql`
DELETE FROM tags_assign
WHERE item_id IN ${sql(eligibleIds)}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
// Insert new rating tags
for (const id of eligibleIds) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${id}, ${targetTagId}, ${+req.session.id})
`;
}
});
await audit.log(req.session.id, 'bulk_set_rating', 'items', null, { item_ids: eligibleIds, rating }).catch(() => {});
// Queue blur thumbnail verification where needed
(async () => {
for (const id of eligibleIds) {
try {
const blurPath = path.join(cfg.paths.t, `${id}_blur.webp`);
await fs.promises.access(blurPath).catch(() => queue.genBlurredThumbnail(id, false));
const freshTags = await lib.getTags(id);
await db.notify('tags', JSON.stringify({ item_id: id, fresh: true, tags: freshTags }));
} catch {}
}
})().catch(() => {});
return res.json({
success: true,
count: eligibleIds.length,
rating,
msg: `Successfully set rating to ${rating.toUpperCase()} for ${eligibleIds.length} item(s)`
});
} catch (err) {
console.error('[BULK_SET_RATING_ERROR]', err);
return res.json({ success: false, msg: 'Failed to update rating' }, 500);
}
}
return res.json({ success: false, msg: 'Unknown bulk action' }, 400);
});
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
group.get(/$/, lib.loggedin, async (req, res) => {
// get tags
@@ -272,18 +17,14 @@ export default router => {
});
}
const subf0ck = req.url?.qs?.subf0ck_id || req.url?.qs?.subf0ck || null;
return res.json({
success: true,
tags: await lib.getTags(+req.params.postid, req.session, subf0ck)
tags: await lib.getTags(+req.params.postid, req.session)
});
});
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
if (isAnonSession(req.session) && !canAnonDo('tag')) {
return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
}
const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
return res.json({
@@ -310,22 +51,6 @@ export default router => {
});
}
const subf0ck = req.post?.subf0ck_id || req.body?.subf0ck_id || req.post?.subf0ck || req.body?.subf0ck || null;
let albumItemId = null;
if (subf0ck) {
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
}
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
try {
let tagid = (await db`
select id
@@ -334,257 +59,91 @@ export default router => {
`)?.[0]?.id;
if (!tagid) { // create new tag
try {
tagid = (await db`
insert into "tags" ${db({
tag: tagname
})
}
returning id
`)[0].id;
} catch (e) {
tagid = (await db`
select id
from "tags"
where normalized = slugify(${tagname})
`)?.[0]?.id;
}
}
if (albumItemId) {
await db`
INSERT INTO "album_items_tags_assign" (album_item_id, tag_id, user_id)
VALUES (${+albumItemId}, ${+tagid}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
await db`
INSERT INTO "tags_assign" (item_id, tag_id, user_id)
VALUES (${+postid}, ${+tagid}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
// Ensure this album item also inherits parent's rating tag if it doesn't have one
const subRating = await db`
SELECT tag_id FROM album_items_tags_assign
WHERE album_item_id = ${+albumItemId} AND tag_id IN (1, 2, ${nsflId})
LIMIT 1
`;
if (subRating.length === 0) {
const parentRating = await db`
SELECT tag_id FROM tags_assign
WHERE item_id = ${+postid} AND tag_id IN (1, 2, ${nsflId})
LIMIT 1
`;
if (parentRating.length > 0) {
await db`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${+albumItemId}, ${parentRating[0].tag_id}, ${+req.session.id})
ON CONFLICT DO NOTHING
`.catch(() => {});
}
}
} else {
await db`
insert into "tags_assign" ${db({
tag_id: +tagid,
item_id: +postid,
user_id: +req.session.id
tagid = (await db`
insert into "tags" ${db({
tag: tagname
})
}
`;
returning id
`)[0].id;
}
await db`
insert into "tags_assign" ${db({
tag_id: +tagid,
item_id: +postid,
user_id: +req.session.id
})
}
`;
} catch (err) {
console.error('[ADD_TAG_ERROR]', err);
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
return res.json({
success: false,
msg: isDuplicate ? 'Tag already exists' : 'Failed to add tag',
tags: await lib.getTags(postid, req.session, subf0ck)
tags: await lib.getTags(postid)
});
}
const freshTags = await lib.getTags(postid, req.session, subf0ck);
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'tag',
targetId: postid,
details: { tag: tagname, subf0ck_id: subf0ck }
});
}
console.log(`[API] Notifying 'tags' for item ${postid} (subf0ck: ${subf0ck || 'none'}) with ${freshTags.length} tags`);
await db.notify('tags', JSON.stringify({ item_id: postid, subf0ck_id: subf0ck, fresh: true, tags: freshTags }));
const freshTags = await lib.getTags(postid);
console.log(`[API] Notifying 'tags' for item ${postid} with ${freshTags.length} tags`);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
return res.json({
success: true,
postid: postid,
subf0ck_id: subf0ck,
tag: tagname,
tags: freshTags
});
});
group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => {
if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
}
group.put(/\/cycle-rating$/, lib.modAuth, async (req, res) => {
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
const postid = +req.params.postid;
const item = await db`
SELECT id, username, active, is_deleted
FROM items
WHERE id = ${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 ownerName = getSessionOwnerName(req.session);
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
const nsflId = cfg.nsfl_tag_id || 3;
// Cycle: SFW(1) → NSFW(2) → NSFL(nsflId) → SFW(1); untagged items jump straight to SFW
const cycle = [1, 2, nsflId];
let nextTagId;
let ratingTagId = 0;
const currentTags = await lib.getTags(postid);
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
const subf0ck = req.body?.subf0ck_id || req.post?.subf0ck_id || req.url?.qs?.subf0ck_id || null;
let albumItemId = null;
if (subf0ck) {
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
let nextTagId;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
try {
await db.begin(async sql => {
// Lock the item row exclusively
await sql`SELECT id FROM items WHERE id = ${postid} FOR UPDATE`;
// Remove any existing rating tag
await db`DELETE FROM tags_assign WHERE item_id = ${postid} AND tag_id = ANY(ARRAY[1, 2, ${nsflId}]::int[])`;
if (nextTagId > 0) {
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
}
if (albumItemId) {
const existingRating = await sql`
SELECT tag_id FROM album_items_tags_assign
WHERE album_item_id = ${albumItemId}
AND tag_id IN (1, 2, ${nsflId})
LIMIT 1
`;
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId);
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
await sql`
DELETE FROM album_items_tags_assign
WHERE album_item_id = ${albumItemId}
AND tag_id IN (1, 2, ${nsflId})
`;
if (nextTagId > 0) {
await sql`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${albumItemId}, ${nextTagId}, ${+req.session.id})
`;
}
} else {
const existingRating = await sql`
SELECT tag_id FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
ORDER BY tag_id DESC
LIMIT 1
`;
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
// Remove ALL existing rating tags for this item atomically
await sql`
DELETE FROM tags_assign
WHERE item_id = ${postid}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
if (nextTagId > 0) {
await sql`
INSERT INTO tags_assign (item_id, tag_id, user_id)
VALUES (${postid}, ${nextTagId}, ${+req.session.id})
`;
}
// Propagate rating to all album sub-items if this is an album
const albumCheck = await sql`SELECT id FROM items WHERE id = ${postid} AND is_album = true LIMIT 1`;
if (albumCheck.length > 0) {
const subItems = await sql`SELECT id FROM album_items WHERE item_id = ${postid}`;
for (const sub of subItems) {
await sql`
DELETE FROM album_items_tags_assign
WHERE album_item_id = ${sub.id}
AND tag_id IN (1, 2, ${nsflId})
`;
if (nextTagId > 0) {
await sql`
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
VALUES (${sub.id}, ${nextTagId}, ${+req.session.id})
ON CONFLICT DO NOTHING
`;
}
}
}
}
// Automatically generate/verify blurred thumbnail on cycle
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
await queue.genBlurredThumbnail(postid, false);
}
});
// Automatically generate/verify blurred thumbnail on cycle
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
await queue.genBlurredThumbnail(postid, false);
}
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
const { label, cls } = labels[nextTagId] || { label: 'SFW', cls: 'sfw' };
const { label, cls } = labels[nextTagId];
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }).catch(() => {});
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'cycle_rating',
targetId: postid,
details: { from: ratingTagId, to: nextTagId, rating_label: label }
});
}
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId });
const freshTags = await lib.getTags(postid);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {});
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
} catch (err) {
console.error('[CYCLE_RATING_ERROR]', err);
return res.json({ success: false, msg: 'Failed to update rating' });
}
});
@@ -662,83 +221,39 @@ export default router => {
const postid = +req.params.postid;
const tagname = decodeURIComponent(req.params.tagname);
const subf0ck = req.post?.subf0ck_id || req.body?.subf0ck_id || req.url?.qs?.subf0ck_id || req.url?.qs?.subf0ck || null;
let albumItemId = null;
if (subf0ck) {
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
if (!albumItemId) {
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
albumItemId = row?.[0]?.id || null;
}
}
const tags = await lib.getTags(postid);
const tags = await lib.getTags(postid, req.session, subf0ck);
const tagid = tags.filter(t => t.tag === tagname || t.normalized === tagname.toLowerCase())[0]?.id ?? null;
const tagid = tags.filter(t => t.tag === tagname)[0]?.id ?? null;
if (!tagid) {
return res.json({
success: false,
msg: 'tag is not assigned',
tags: await lib.getTags(postid, req.session, subf0ck)
tags: await lib.getTags(postid)
});
}
let reply = false;
if (albumItemId) {
const q = await db`
DELETE FROM album_items_tags_assign
WHERE album_item_id = ${+albumItemId}
AND tag_id = ${+tagid}
`;
reply = !!q;
const otherUses = await db`
SELECT 1 FROM album_items_tags_assign aita
JOIN album_items ai ON ai.id = aita.album_item_id
WHERE ai.item_id = ${postid} AND aita.tag_id = ${+tagid}
LIMIT 1
`;
if (otherUses.length === 0) {
await db`
DELETE FROM tags_assign
WHERE item_id = ${postid} AND tag_id = ${+tagid}
`.catch(() => {});
}
} else {
let q = await db`
delete from "tags_assign"
where tag_id = ${+tagid}
and item_id = ${+postid}
`;
reply = !!q;
}
let q = await db`
delete from "tags_assign"
where tag_id = ${+tagid}
and item_id = ${+postid}
`;
const reply = !!q;
if (reply) {
const reason = req.post?.reason || req.url?.qs?.reason || 'No reason provided';
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, subf0ck_id: subf0ck, reason });
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'delete_tag',
targetId: postid,
details: { tag: tagname, subf0ck_id: subf0ck, reason }
});
}
const reason = req.post.reason || req.url.qs.reason || 'No reason provided';
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, reason });
}
const freshTags = await lib.getTags(postid, req.session, subf0ck);
console.log(`[API] Notifying 'tags' (delete) for item ${postid} (subf0ck: ${subf0ck || 'none'})`);
await db.notify('tags', JSON.stringify({ item_id: postid, subf0ck_id: subf0ck, fresh: true, tags: freshTags }));
const freshTags = await lib.getTags(postid);
console.log(`[API] Notifying 'tags' (delete) for item ${postid}`);
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
return res.json({
success: reply,
tagid,
subf0ck_id: subf0ck,
tags: freshTags
});
})
});
});
+13 -58
View File
@@ -3,13 +3,11 @@ import { spawn as _spawnRaw } from 'child_process';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs, canAnonDo, isAnonSession } from '../../settings.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";
import { addPrivateItem } from "../../private_items.mjs";
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../../transcode.mjs";
// ──────────────────────────────────────────────────────────────────────
// In-memory job progress map (keyed by jobId string)
@@ -233,21 +231,9 @@ const collectBody = (req) => {
export default router => {
router.group(/^\/api\/v2/, group => {
const uploadApiAuth = (req, res, next) => {
if (!req.session) {
return res.json({ success: false, msg: 'Unauthorized' }, 401);
}
if (isAnonSession(req.session)) {
if (!canAnonDo('upload')) {
return res.json({ success: false, msg: 'Action requires a registered account or anonymous upload permission' }, 403);
}
return next();
}
return lib.registeredUser(req, res, next);
};
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, uploadApiAuth, (req, res) => {
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.loggedin, (req, res) => {
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
const state = progressMap.get(jobId);
res.setHeader?.('Cache-Control', 'no-store');
@@ -320,7 +306,7 @@ export default router => {
return [...new Set(tags)];
};
group.get(/\/meta\/extract-url$/, uploadApiAuth, async (req, res) => {
group.get(/\/meta\/extract-url$/, lib.loggedin, async (req, res) => {
const url = req.url.qs?.url;
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
@@ -371,7 +357,7 @@ export default router => {
}
});
group.post(/\/upload-url$/, uploadApiAuth, async (req, res) => {
group.post(/\/upload-url$/, lib.loggedin, async (req, res) => {
try {
if (!cfg.websrv.web_url_upload) {
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
@@ -477,16 +463,10 @@ export default router => {
RETURNING id
`;
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, req.session.user);
}
// Auto-subscribe uploader
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
// Download YouTube thumbnail as our thumbnail
try {
@@ -537,9 +517,7 @@ export default router => {
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility,
is_album: false,
album_count: 0
visibility: targetVisibility
})})`;
} catch (err) {
console.error('[UPLOAD-URL] YouTube new_item notify failed:', err);
@@ -735,13 +713,6 @@ export default router => {
source = source.replace(/\.mkv$/, '.mp4');
mime = 'video/mp4';
}
if (TRANSCODE_TO_MP4.has(mime)) { // .mpg etc.: browsers can't play it, re-encode
const converted = source.replace(/\.[^./]+$/, '') + '.conv.mp4';
await transcodeToMp4(queue, source, converted);
await fs.unlink(source).catch(() => {});
source = converted;
mime = 'video/mp4';
}
if (source.match(/\.opus$/)) {
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]);
await fs.unlink(source).catch(() => {});
@@ -826,30 +797,16 @@ export default router => {
RETURNING id
`;
if (targetVisibility === 2) {
addPrivateItem(itemid, filename, session.user);
}
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { }
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
} catch (err) { }
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
if (mime.startsWith('audio/') && queue._lastCoverExtracted) {
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
}
await queue.genBlurredThumbnail(itemid, isApprovalRequired);
} catch (err) {
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
const outPath = path.join(tDir, `${itemid}.webp`);
if (mime.startsWith('audio/')) {
await queue.genAudioPlaceholder(outPath).catch(() => {});
} else {
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', outPath]).catch(() => {});
}
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
}
// Assign rating tag (only if a rating was selected)
@@ -883,9 +840,7 @@ export default router => {
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility,
is_album: false,
album_count: 0
visibility: targetVisibility
})})`;
} catch (err) {
console.error('[UPLOAD-URL] new_item notify failed:', err);
+8 -45
View File
@@ -1,57 +1,20 @@
import cfg from "../config.mjs";
import security from "../security.mjs";
export default (router, tpl) => {
router.get(/^\/banned\/?$/, async (req, res) => {
let isBanned = false;
let reason = 'Violation of community rules';
let expires = null;
if (req.session && req.session.banned) {
isBanned = true;
reason = req.session.ban_reason || reason;
expires = req.session.ban_expires;
}
const clientIp = security.getRealIP(req);
const ipBan = await security.isIpBanned(clientIp);
if (ipBan) {
isBanned = true;
reason = ipBan.reason || reason;
expires = ipBan.expires;
}
const fp = req.session?.fingerprint || req.session?.anon_fingerprint;
if (fp) {
const fpBan = await security.isFingerprintBanned(fp);
if (fpBan) {
isBanned = true;
reason = fpBan.reason || reason;
expires = fpBan.expires;
}
}
if (req.cookies && req.cookies.f0ck_banned) {
try {
const bData = JSON.parse(decodeURIComponent(req.cookies.f0ck_banned));
if (bData && (bData.reason || bData.banned)) {
isBanned = true;
reason = bData.reason || reason;
expires = bData.expires || expires;
}
} catch (e) {}
if (!req.session || !req.session.banned) {
return res.writeHead(302, {
"Location": "/"
}).end();
}
res.reply({
body: tpl.render("banned", {
session: req.session,
reason: reason,
expires: expires ? new Date(expires).toLocaleString() : 'Permanent',
isBanned: isBanned,
clientIp: clientIp,
page_meta: {
title: isBanned ? 'Banned' : 'Ban Status'
}
reason: req.session.ban_reason,
expires: req.session.ban_expires ? new Date(req.session.ban_expires).toLocaleString() : 'Permanent',
ban_video: cfg.websrv.ban_video,
hideNavbar: true
}, req)
});
});
-740
View File
@@ -1,740 +0,0 @@
import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import queue from "../queue.mjs";
import { chanCurl } from "../chan_http.mjs";
import { getSessionOwnerName, getEnableItemSlugs, isOnaraEnabledFor } from "../settings.mjs";
// Link path for a local item: its slug when slugs are enabled, else the numeric id
const itemPath = (id, slug) => (getEnableItemSlugs() && slug) ? slug : id;
/**
* chan.mjs — 4chan thread viewer & catalogue routes
* Restricted to users with the '4chan' group (and admins).
*/
export default (router, tpl) => {
const COMMON_BOARDS = [
{ id: 'wsg', name: 'Worksafe GIF', icon: 'fa-film' },
{ id: 'gif', name: 'Adult GIF', icon: 'fa-video' },
{ id: 'b', name: 'Random', icon: 'fa-dice' },
{ id: 'v', name: 'Video Games', icon: 'fa-gamepad' },
{ id: 'vg', name: 'Video Game Generals', icon: 'fa-gamepad' },
{ id: 'a', name: 'Anime & Manga', icon: 'fa-tv' },
{ id: 'g', name: 'Technology', icon: 'fa-microchip' },
{ id: 'pol', name: 'Politically Incorrect', icon: 'fa-globe' },
{ id: 'tv', name: 'Television & Film', icon: 'fa-tv' },
{ id: 'mu', name: 'Music', icon: 'fa-music' },
{ id: 'fit', name: 'Fitness', icon: 'fa-dumbbell' },
{ id: 'ck', name: 'Food & Cooking', icon: 'fa-utensils' }
];
/**
* Helper to fetch data via curl respecting SOCKS5 proxy if configured.
*/
async function fetchWithProxy(url) {
const { bin, args } = chanCurl(url, ['--max-time', '30']);
const { stdout } = await queue.spawn(bin, args, { encoding: 'utf8' });
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
if (!text.startsWith('{') && !text.startsWith('[')) {
throw new Error(`Expected JSON from ${url}, got: ${text.slice(0, 100)}`);
}
return JSON.parse(text);
}
function formatComment(com) {
if (!com) return '';
let formatted = com;
// 1. Normalize existing 4chan intra-thread quotelinks to standard format
formatted = formatted.replace(/<a\s+[^>]*href="#p(\d+)"[^>]*>.*?<\/a>/gi, '___CHANREF_$1___');
// 2. Normalize cross-thread/cross-board quotelinks if any
formatted = formatted.replace(/<a\s+[^>]*href="(\/[^"]+)"[^>]*>(.*?)<\/a>/gi, '<a href="$1" class="chan-ref chan-cross-ref" target="_blank">$2</a>');
// 3. Format any unlinked quotes &gt;&gt;123456
formatted = formatted.replace(/(?<!&gt;)&gt;&gt;(\d+)\b/g, '___CHANREF_$1___');
// 4. Also support plain >>123456 if plain text was supplied
formatted = formatted.replace(/(?<![>\w])>>(\d+)\b/g, '___CHANREF_$1___');
// 5. Restore CHANREF placeholders
formatted = formatted.replace(/___CHANREF_(\d+)___/g, '<a href="#p$1" class="chan-ref quotelink" data-post="$1">&gt;&gt;$1</a>');
// 6. Format quote lines (greentext)
formatted = formatted.replace(/(^|<br\s*\/?>)&gt;(?!&gt;)([^\n<]+)/g, '$1<span class="chan-quote">&gt;$2</span>');
return formatted;
}
function extractQuotes(com) {
if (!com) return [];
const quotes = new Set();
for (const m of com.matchAll(/href="#p(\d+)"/g)) {
quotes.add(Number(m[1]));
}
for (const m of com.matchAll(/(?<!&gt;)&gt;&gt;(\d+)\b/g)) {
quotes.add(Number(m[1]));
}
for (const m of com.matchAll(/(?<![>\w])>>(\d+)\b/g)) {
quotes.add(Number(m[1]));
}
return Array.from(quotes);
}
// ───────────────────────────────────────────────────────────────────────────
// 0. GET /4 -> redirect to default board catalogue
// ───────────────────────────────────────────────────────────────────────────
router.get(/^\/4\/?$/, lib.chanAuth, (req, res) => res.redirect('/4/wsg/catalogue'));
// ───────────────────────────────────────────────────────────────────────────
// 1. GET /4/:board/catalogue (and alias /4/:board/catalog)
// ───────────────────────────────────────────────────────────────────────────
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?:catalogue|catalog)\/?$/, lib.chanAuth, async (req, res) => {
const board = req.params.board.toLowerCase();
try {
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
const threads = [];
for (const page of pages) {
for (const t of (page.threads || [])) {
threads.push({
no: t.no,
sub: t.sub || '',
com: t.com ? t.com.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '').slice(0, 180) : '',
replies: t.replies || 0,
images: t.images || 0,
tim: t.tim,
ext: t.ext,
sticky: !!t.sticky,
closed: !!t.closed,
time: t.time,
thumb: t.tim ? `/api/v2/scroller/external/4chan/${board}/media/${t.tim}s.jpg` : null
});
}
}
const opUrls = [];
threads.forEach(t => {
if (t.tim && t.ext) {
const ext = (t.ext || '').toLowerCase();
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${t.ext}`);
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${ext}`);
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${t.ext}`);
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${ext}`);
opUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${t.tim}${ext}`);
}
});
const rehosts = {};
const rehostPaths = {};
if (opUrls.length > 0) {
try {
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${opUrls})`;
rows.forEach(r => {
rehostPaths[r.id] = itemPath(r.id, r.slug);
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id;
});
} catch (_) {}
}
threads.forEach(t => {
t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null;
t.local_path = t.local_id ? rehostPaths[t.local_id] : null;
});
const data = {
board,
threads,
common_boards: COMMON_BOARDS,
session: req.session ? { ...req.session } : false,
domain: cfg.main.url.domain,
tmp: null,
page_meta: {
title: `/${board}/ - Catalogue`,
description: `4chan /${board}/ thread catalogue`,
url: `https://${cfg.main.url.domain}/4/${board}/catalogue`
}
};
return res.reply({
body: tpl.render('chan/catalogue', data, req)
});
} catch (err) {
console.error(`[CHAN] Failed to fetch catalogue for /${board}/:`, err.message);
return res.reply({
code: 500,
body: tpl.render('error', {
message: `Could not load catalogue for /${board}/. The board may not exist or 4chan is currently unreachable.`,
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false
}, req)
});
}
});
function formatPosts(posts, board, rehostMap, userFavSet, targetPostNo = null, lang = 'en') {
// Build reply mapping (targetPostNo -> array of replier post numbers)
const postNoSet = new Set(posts.map(p => p.no));
const replyMap = new Map();
for (const p of posts) {
if (!p.com) continue;
const quotes = extractQuotes(p.com);
for (const qNo of quotes) {
if (postNoSet.has(qNo) && qNo !== p.no) {
if (!replyMap.has(qNo)) replyMap.set(qNo, []);
replyMap.get(qNo).push(p.no);
}
}
}
return posts.map(p => {
const ext = (p.ext || '').toLowerCase();
const isVideo = ['.webm', '.mp4'].includes(ext);
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
const externalMediaUrl = p.tim ? `https://i.4cdn.org/${board}/${p.tim}${ext}` : null;
const rehostInfo = (p.tim && rehostMap[p.tim]) || (externalMediaUrl && rehostMap[externalMediaUrl]) || null;
const localId = rehostInfo ? rehostInfo.id : null;
const effectiveTime = (localId && rehostInfo?.stamp) ? Number(rehostInfo.stamp) : p.time;
const isoStr = new Date(effectiveTime * 1000).toISOString();
const postReplies = replyMap.get(p.no) || [];
return {
no: p.no,
sub: p.sub || '',
com_raw: p.com || '',
com_formatted: formatComment(p.com || ''),
name: p.name || 'Anonymous',
trip: p.trip || '',
time: effectiveTime,
timeago: lib.timeAgo(effectiveTime * 1000, lang),
date_str: (localId && rehostInfo?.stamp) ? new Date(effectiveTime * 1000).toLocaleString() : (p.now || new Date(effectiveTime * 1000).toLocaleString()),
iso_str: isoStr,
has_media: !!(p.tim && p.ext),
tim: p.tim,
ext: ext,
filename: p.filename ? `${p.filename}${ext}` : null,
w: p.w,
h: p.h,
fsize: p.fsize ? lib.formatSize(p.fsize) : null,
dest: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}` : null,
thumb: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg` : null,
external_media_url: externalMediaUrl,
is_video: isVideo,
is_image: isImage,
local_id: localId,
local_path: rehostInfo ? rehostInfo.path : null,
rehosted: !!localId,
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false,
is_onara_active: targetPostNo ? p.no === targetPostNo : false,
replies: postReplies,
replies_count: postReplies.length
};
});
}
// ───────────────────────────────────────────────────────────────────────────
// 2. GET /4/:board/:thread — Overview of all posts in that thread
// ───────────────────────────────────────────────────────────────────────────
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/?$/, lib.chanAuth, async (req, res) => {
const board = req.params.board.toLowerCase();
const tid = Number(req.params.thread);
try {
const data = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
const posts = data.posts || [];
if (!posts.length) throw new Error('Empty thread data');
const op = posts[0];
const mediaPosts = posts.filter(p => p.tim && p.ext);
// Check which media URLs are already rehosted on this platform
const rehosts = {};
const cdnUrls = [];
mediaPosts.forEach(p => {
const ext = (p.ext || '').toLowerCase();
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const rehostMap = {};
const rehostPaths = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
rehostPaths[r.id] = info.path;
rehosts[r.src] = r.id;
rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/);
if (m) {
rehosts[m[1]] = r.id;
rehostMap[m[1]] = info;
}
});
} catch (e) {
console.error('[CHAN] Rehost check error:', e.message);
}
}
// Find which rehosted items the current user has favorited
const userFavSet = new Set();
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
if (req.session?.id && rehostedIdList.length > 0) {
try {
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
} catch (_) {}
}
// Format posts for template
const formattedPosts = formatPosts(posts, board, rehostMap, userFavSet, null, req.lang || 'en');
const firstMediaPost = formattedPosts.find(p => p.has_media);
const viewData = {
board,
tid,
op,
posts: formattedPosts,
media_count: mediaPosts.length,
first_media_no: firstMediaPost ? firstMediaPost.no : null,
rehosts_count: Object.keys(rehosts).length,
session: req.session ? { ...req.session } : false,
csrf_token: req.session?.csrf_token || '',
domain: cfg.main.url.domain,
tmp: null,
page_meta: {
title: `/${board}/${tid} - ${op.sub || 'Thread Overview'}`,
description: op.sub || (op.com ? op.com.replace(/<[^>]+>/g, '').slice(0, 160) : `4chan /${board}/ thread ${tid}`),
url: `https://${cfg.main.url.domain}/4/${board}/${tid}`
}
};
return res.reply({
body: tpl.render('chan/thread', viewData, req)
});
} catch (err) {
console.error(`[CHAN] Failed to load thread /${board}/${tid}:`, err.message);
return res.reply({
code: 404,
body: tpl.render('error', {
message: `Could not load thread /${board}/${tid}. It may be archived or 4chan is currently unreachable.`,
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false
}, req)
});
}
});
// ───────────────────────────────────────────────────────────────────────────
// 3. GET /4/:board/:thread/:post — Show media item in normal item view
// ───────────────────────────────────────────────────────────────────────────
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/(?<post>\d+)\/?$/, lib.chanAuth, async (req, res) => {
const board = req.params.board.toLowerCase();
const tid = Number(req.params.thread);
const postNo = Number(req.params.post);
try {
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
const posts = threadData.posts || [];
if (!posts.length) throw new Error('Empty thread data');
const op = posts[0];
const mediaPosts = posts.filter(p => p.tim && p.ext);
if (!mediaPosts.length) {
// No media at all in thread, redirect to thread overview
return res.redirect(`/4/${board}/${tid}`);
}
// Find target post
let targetPost = mediaPosts.find(p => p.no === postNo);
if (!targetPost) {
// Post has no media or doesn't exist; pick closest or first media post
targetPost = mediaPosts[0];
}
// Check rehost status in DB
const cdnUrls = [];
mediaPosts.forEach(p => {
const ext = (p.ext || '').toLowerCase();
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const rehosts = {};
const rehostMap = {};
const rehostPaths = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
rehostPaths[r.id] = info.path;
rehosts[r.src] = r.id;
rehostMap[r.src] = info;
const m = r.src.match(/(\d{13,20})/);
if (m) {
rehosts[m[1]] = r.id;
rehostMap[m[1]] = info;
}
});
} catch (e) {
console.error('[CHAN] Rehost check error:', e.message);
}
}
const mediaIndex = mediaPosts.findIndex(p => p.no === targetPost.no);
const prevMedia = mediaIndex > 0 ? mediaPosts[mediaIndex - 1] : null;
const nextMedia = mediaIndex < mediaPosts.length - 1 ? mediaPosts[mediaIndex + 1] : null;
const ext = (targetPost.ext || '').toLowerCase();
const isVideo = ['.webm', '.mp4'].includes(ext);
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
const externalMediaUrl = targetPost.tim ? `https://i.4cdn.org/${board}/${targetPost.tim}${ext}` : null;
const localId = (targetPost.tim && rehosts[targetPost.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null;
let itemRating = null;
let itemTags = [];
let canManage = false;
let localDest = null;
let localThumb = null;
let localTitle = null;
let localStamp = null;
let itemFavorites = [];
let userHasFavorited = false;
if (localId) {
try {
const localItem = await db`SELECT id, username, dest, title, mime, width, height, stamp FROM items WHERE id = ${localId} LIMIT 1`;
if (localItem.length > 0) {
const li = localItem[0];
localStamp = li.stamp;
if (li.title) localTitle = li.title;
if (li.dest) {
localDest = `/b/${li.dest}`;
localThumb = `/t/${li.id}.webp`;
}
if (req.session) {
canManage = !!((li.username && getSessionOwnerName(req.session) && li.username.toLowerCase() === getSessionOwnerName(req.session).toLowerCase()) ||
req.session.admin || req.session.is_moderator);
}
}
itemTags = await lib.getTags(localId, req.session);
const ratingTag = itemTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
if (ratingTag) itemRating = ratingTag.normalized;
itemFavorites = await db`
select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
left join "anon_identities" on "anon_identities".user_id = "favorites".user_id
where "favorites".item_id = ${localId}
`;
userHasFavorited = lib.userHasFavorited(req.session, itemFavorites);
} catch (e) {
console.error('[CHAN] Failed to fetch tags for rehosted item:', e.message);
}
}
// Construct item matching f0ckm's standard item view expectations
const effectiveStamp = (localId && localStamp) ? Number(localStamp) : targetPost.time;
const item = {
id: targetPost.no,
slug: null,
title: localTitle || targetPost.sub || op.sub || `/${board}/${tid} #${targetPost.no}`,
dest: localDest || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}${ext}`,
thumbnail: localThumb || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}s.jpg`,
mime: isVideo ? (ext === '.mp4' ? 'video/mp4' : 'video/webm') : (ext === '.gif' ? 'image/gif' : (ext === '.png' ? 'image/png' : 'image/jpeg')),
width: targetPost.w || null,
height: targetPost.h || null,
size: targetPost.fsize ? lib.formatSize(targetPost.fsize) : '0 B',
username: targetPost.name || 'Anonymous',
stamp: effectiveStamp,
timestamp: {
timeago: lib.timeAgo(effectiveStamp * 1000, req.lang || 'en'),
timefull: new Date(effectiveStamp * 1000).toISOString()
},
comment: targetPost.com ? targetPost.com.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '') : '',
is_chan: true,
external_board: board,
external_tid: tid,
external_id: targetPost.no,
external_thread_url: `https://boards.4chan.org/${board}/thread/${tid}#p${targetPost.no}`,
external_media_url: externalMediaUrl,
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
local_id: localId,
local_path: localId ? (rehostPaths[localId] || localId) : null,
rehosted: !!localId,
is_sfw: itemRating === 'sfw',
is_nsfw: itemRating === 'nsfw',
is_nsfl: itemRating === 'nsfl',
is_untagged: !itemRating,
favorites: itemFavorites,
user_has_favorited: userHasFavorited,
halls: [],
user_halls: [],
tags: itemTags
};
// F0ckm convention: Next post (right arrow / D) corresponds to pagination.prev,
// Previous post (left arrow / A) corresponds to pagination.next.
const pagination = {
prev: nextMedia ? nextMedia.no : null,
next: prevMedia ? prevMedia.no : null
};
const link = {
main: `/4/${board}/${tid}/`,
mainDisplay: `/4/${board}/${tid}/`,
suffix: ''
};
// Find which rehosted items the current user has favorited
const userFavSet = new Set();
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
if (req.session?.id && rehostedIdList.length > 0) {
try {
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
} catch (_) {}
}
const chanMediaList = mediaPosts.map((p, idx) => {
const pExt = (p.ext || '').toLowerCase();
const pVid = ['.webm', '.mp4'].includes(pExt);
const pUrl = `https://i.4cdn.org/${board}/${p.tim}${pExt}`;
const pLocalId = (p.tim && rehosts[p.tim]) || (pUrl && rehosts[pUrl]) || null;
return {
no: p.no,
tim: p.tim,
ext: pExt,
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${pExt}`,
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
is_video: pVid,
is_image: !pVid,
is_active: p.no === targetPost.no,
index: idx + 1,
local_id: pLocalId,
local_path: pLocalId ? (rehostPaths[pLocalId] || pLocalId) : null,
rehosted: !!pLocalId,
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
width: p.w || null,
height: p.h || null
};
});
const chanThreadMeta = {
board,
tid,
subject: op.sub || `Thread #${tid}`,
active_post: targetPost.no,
active_index: mediaIndex + 1,
media_count: mediaPosts.length,
media_posts: chanMediaList
};
const data = {
item,
pagination,
link,
chan_thread: chanThreadMeta,
session: req.session ? { ...req.session } : false,
csrf_token: req.session?.csrf_token || '',
domain: cfg.main.url.domain,
tmp: null,
hidePagination: true,
enable_item_title: true,
enable_item_slugs: false,
user_alternative_steuerung: req.session?.user_alternative_steuerung,
user_alternative_infobox: req.session?.user_alternative_infobox,
can_manage_item: canManage,
can_rate_item: canManage,
can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))),
user_has_favorited: userHasFavorited,
isSubscribed: false,
item_username_lower: (item.username || '').toLowerCase(),
item_rating_class: item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')),
item_rating_label: item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')),
is_flash_item: false,
is_archive_item: false,
item_has_dimensions: !!(item.width && item.height),
is_mod_or_admin: !!(req.session && (req.session.admin || req.session.is_moderator)),
halls_enabled: false,
default_layout: cfg.websrv?.default_layout || 'legacy',
page_meta: {
title: `/${board}/${tid}/${targetPost.no} - ${item.title}`,
description: item.comment || `4chan media post #${targetPost.no} in /${board}/${tid}`,
url: `https://${cfg.main.url.domain}/4/${board}/${tid}/${targetPost.no}`
}
};
// Handle AJAX requests from loadItemAjax
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
req.url.qs?.ajax === '1' ||
(req.headers.accept && req.headers.accept.includes('application/json'));
if (isAjax) {
const isModern = req.session?.use_new_layout;
const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy';
const html = tpl.render(partialTpl, data, req);
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
html,
item,
pagination,
chan_thread: chanThreadMeta
})
});
}
// Full page render: if Onara is active, render thread page with onara modal open
const isOnara = isOnaraEnabledFor(req);
if (isOnara) {
const isModern = req.session?.use_new_layout;
const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy';
const itemHtml = tpl.render(partialTpl, data, req);
const formattedPosts = formatPosts(posts, board, rehostMap, userFavSet, targetPost.no, req.lang || 'en');
const firstMediaPost = formattedPosts.find(p => p.has_media);
const threadViewData = {
board,
tid,
op,
posts: formattedPosts,
media_count: mediaPosts.length,
first_media_no: firstMediaPost ? firstMediaPost.no : null,
rehosts_count: Object.keys(rehosts).length,
session: req.session ? { ...req.session } : false,
csrf_token: req.session?.csrf_token || '',
domain: cfg.main.url.domain,
tmp: null,
page_meta: data.page_meta,
is_onara_item: true,
onara: true,
onara_item_html: itemHtml,
item: data.item
};
return res.reply({
body: tpl.render('chan/thread', threadViewData, req)
});
}
// Standard full page render (when Onara is disabled)
return res.reply({
body: tpl.render('item', data, req)
});
} catch (err) {
console.error(`[CHAN] Failed to load item /4/${board}/${tid}/${postNo}:`, err.message);
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
req.url.qs?.ajax === '1' ||
(req.headers.accept && req.headers.accept.includes('application/json'));
if (isAjax) {
return res.reply({
code: 404,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
message: `Could not load media post #${postNo} in /${board}/${tid}: ${err.message}`
})
});
}
return res.reply({
code: 404,
body: tpl.render('error', {
message: `Could not load media post #${postNo} in /${board}/${tid}.`,
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false
}, req)
});
}
});
// ───────────────────────────────────────────────────────────────────────────
// 4. GET /api/v2/chan/:board/:thread/media — Media list for thread (sidebar)
// ───────────────────────────────────────────────────────────────────────────
router.get(/^\/api\/v2\/chan\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/media\/?$/, lib.chanAuth, async (req, res) => {
const board = req.params.board.toLowerCase();
const tid = Number(req.params.thread);
try {
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
const posts = threadData.posts || [];
const op = posts[0] || {};
const mediaPosts = posts.filter(p => p.tim && p.ext);
const cdnUrls = [];
mediaPosts.forEach(p => {
const ext = (p.ext || '').toLowerCase();
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const rehosts = {};
const rehostPaths = {};
if (cdnUrls.length > 0) {
try {
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdnUrls})`;
rows.forEach(r => {
rehostPaths[r.id] = itemPath(r.id, r.slug);
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id;
});
} catch (_) {}
}
const media = mediaPosts.map((p, idx) => {
const ext = (p.ext || '').toLowerCase();
const isVideo = ['.webm', '.mp4'].includes(ext);
const externalMediaUrl = `https://i.4cdn.org/${board}/${p.tim}${ext}`;
return {
no: p.no,
tim: p.tim,
ext,
filename: p.filename ? `${p.filename}${ext}` : null,
w: p.w,
h: p.h,
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`,
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
is_video: isVideo,
is_image: !isVideo,
index: idx + 1,
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
local_path: rehostPaths[(p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])] || null,
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
};
});
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' },
body: JSON.stringify({
success: true,
board,
tid,
subject: op.sub || `Thread #${tid}`,
total: media.length,
items: media,
media
})
});
} catch (err) {
console.error(`[CHAN] Media API error for /${board}/${tid}:`, err.message);
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'Failed to fetch thread media' })
});
}
});
return router;
};
+18 -104
View File
@@ -7,12 +7,11 @@ import audit from "../audit.mjs";
import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
import { resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
import { getEnableAnonymousAccess, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from "../settings.mjs";
export default (router, tpl) => {
// Get comments for an item
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
const itemId = req.params.itemid;
@@ -64,22 +63,11 @@ export default (router, tpl) => {
}
// Transform for frontend if needed, or send as is
const anonymize = isAnonymizeSession(req.session);
const outComments = anonymize
? comments.map(c => ({
...c,
username: 'anonymous',
display_name: null,
username_color: null,
avatar: null,
avatar_file: null
}))
: comments;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
success: true,
comments: outComments,
comments,
is_subscribed,
is_locked,
user_id: req.session ? req.session.id : null,
@@ -134,9 +122,6 @@ export default (router, tpl) => {
// Browse User Comments
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
if (isAnonymizeSession(req.session)) {
return req.session ? res.redirect('/') : res.redirect('/login');
}
const user = decodeURIComponent(req.params.user);
try {
@@ -171,15 +156,7 @@ export default (router, tpl) => {
}
let globalfilterTags = [...(cfg.nsfp || [])];
if (req.session && isAnonSession(req.session)) {
const allowedModes = getAnonAllowedModes();
if (allowedModes.includes('nsfw')) globalfilterTags = globalfilterTags.filter(id => id !== 2);
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (allowedModes.includes('nsfl')) globalfilterTags = globalfilterTags.filter(id => id !== nsflId);
} else if (cfg.websrv.public_nsfw) {
globalfilterTags = globalfilterTags.filter(id => id !== 2);
}
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
/* <mode-override> */
@@ -205,7 +182,7 @@ export default (router, tpl) => {
WHERE c.user_id = ${userId} AND c.is_deleted = false
AND i.active = true AND i.is_deleted = false
AND ${db.unsafe(modequery)}
${(!req.session || isAnonSession(req.session)) && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
ORDER BY c.created_at DESC
LIMIT ${limit} OFFSET ${offset}
@@ -398,14 +375,8 @@ export default (router, tpl) => {
// Post a comment
router.post('/api/comments', async (req, res) => {
// Anonymous users must have an active passkey session — no SSH header fallback (hard cut)
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
if (isAnonSession(req.session) && !canAnonDo('comment')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Anonymous commenting is disabled" }) });
}
// Rate limit regular users (admins and mods are exempt)
if (!req.session.admin && !req.session.is_moderator) {
if (isCommentRateLimited(req.session.id)) {
@@ -450,13 +421,11 @@ export default (router, tpl) => {
}
}
const auditIp = resolveAuditIP(req);
const insertData = {
item_id,
user_id: req.session.id,
parent_id: parent_id || null,
content: content || '',
ip: auditIp
content: content || ''
};
if (video_time !== null) insertData.video_time = video_time;
@@ -467,14 +436,6 @@ export default (router, tpl) => {
const commentId = parseInt(newComment[0].id, 10);
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'comment',
targetId: commentId,
details: { item_id, parent_id }
});
}
// Link uploaded files to this comment (if any)
let activityFiles = [];
const fileIdsRaw = body.file_ids || '';
@@ -613,9 +574,6 @@ export default (router, tpl) => {
const itemQuery = await db`
SELECT
i.slug,
i.mime,
i.dest,
i.has_coverart,
i.xd_score,
COALESCE(i.visibility, 0) as visibility,
(SELECT ta.tag_id FROM tags_assign ta
@@ -655,13 +613,10 @@ export default (router, tpl) => {
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.is_anon ? 'Anonymous' : (req.session.display_name || null),
display_name: req.session.display_name || null,
xd_score: xdRow?.xd_score ?? null,
video_time: newComment[0]?.video_time ?? null,
files: activityFiles,
is_anon: !!req.session.is_anon,
anon_fingerprint: req.session.fingerprint || null,
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
files: activityFiles
};
// 1. Thread live update
@@ -676,10 +631,6 @@ export default (router, tpl) => {
db.notify('activity', JSON.stringify({
user_id: req.session.id,
item_id: item_id,
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
mime: itemQuery[0]?.mime || null,
dest: itemQuery[0]?.dest || null,
has_coverart: !!itemQuery[0]?.has_coverart,
type: 'comment',
body: notifyBody,
id: commentId,
@@ -691,14 +642,11 @@ export default (router, tpl) => {
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.is_anon ? 'anonymous' : req.session.user,
username: req.session.user,
username_color: req.session.username_color,
display_name: req.session.is_anon ? 'Anonymous' : (req.session.display_name || null),
display_name: req.session.display_name || null,
files: activityFiles,
is_long: activityIsLong,
is_anon: !!req.session.is_anon,
anon_fingerprint: req.session.fingerprint || null,
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
is_long: activityIsLong
}));
// Automatically subscribe user to the thread
@@ -795,14 +743,6 @@ export default (router, tpl) => {
old_content: comment[0].content
});
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'comment_delete',
targetId: commentId,
details: { item_id: comment[0].item_id }
});
}
// Handle attachments cleanup
const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`;
for (const f of files) {
@@ -1076,16 +1016,8 @@ 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');
let globalfilterTags = [...(cfg.nsfp || [])];
if (req.session && isAnonSession(req.session)) {
const allowedModes = getAnonAllowedModes();
if (allowedModes.includes('nsfw')) globalfilterTags = globalfilterTags.filter(id => id !== 2);
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (allowedModes.includes('nsfl')) globalfilterTags = globalfilterTags.filter(id => id !== nsflId);
} else if (cfg.websrv.public_nsfw) {
globalfilterTags = globalfilterTags.filter(id => id !== 2);
}
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 excludedTags = req.session ? (req.session.excluded_tags || []) : [];
@@ -1099,8 +1031,6 @@ export default (router, tpl) => {
? 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 { mimeSQL: activityMimeSQL } = f0cklib.resolveMimeSQL(req.url.qs?.mime || (req.cookies?.mime || null), req.session, 'i');
const comments = await db`
SELECT
c.*,
@@ -1108,7 +1038,6 @@ export default (router, tpl) => {
i.id as item_id,
i.slug as item_slug,
i.dest as item_dest,
i.has_coverart,
(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,
@@ -1128,10 +1057,9 @@ export default (router, tpl) => {
WHERE c.is_deleted = false
AND i.active = true
AND i.is_deleted = false
${activityMimeSQL}
${visibilityFilter}
AND ${db.unsafe(modequery)}
${(!req.session || isAnonSession(req.session)) && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
ORDER BY c.created_at DESC
@@ -1207,7 +1135,6 @@ export default (router, tpl) => {
}
}
const isAnonymized = isAnonymizeSession(req.session);
const processedComments = comments.map(c => {
let ratingLabel = '?';
let ratingClass = 'untagged';
@@ -1217,6 +1144,9 @@ export default (router, tpl) => {
const commentContent = (c.content || '').trim();
const commentFiles = filesMap.get(c.id) || [];
// Compute overflow hint: true if content is likely taller than the 80px clamp.
// ~120 chars ≈ 2-3 wrapped lines in the sidebar; any newlines > 2 or file
// attachments (images/video) will also push height above the limit.
const isLong = commentContent.length > 120
|| commentContent.split('\n').length > 2
|| commentFiles.length > 0
@@ -1224,22 +1154,17 @@ export default (router, tpl) => {
return {
...c,
username: isAnonymized ? 'anonymous' : c.username,
display_name: isAnonymized ? null : c.display_name,
username_color: isAnonymized ? null : c.username_color,
avatar: isAnonymized ? null : c.avatar,
avatar_file: isAnonymized ? null : c.avatar_file,
banner_file: isAnonymized ? null : c.banner_file,
content: commentContent,
username_color: c.username_color,
item_rating_class: ratingClass,
item_rating_label: ratingLabel,
files: commentFiles,
poll: pollMap.get(c.id) || null,
is_long: isLong
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
};
});
if (req.url.qs?.json === 'true' || req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
headers: {
@@ -1443,9 +1368,6 @@ export default (router, tpl) => {
// POST /api/polls/:pollId/vote — cast or change vote
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (isAnonSession(req.session) && !canAnonDo('poll_vote')) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Anonymous poll voting is disabled' }) });
}
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
@@ -1484,14 +1406,6 @@ export default (router, tpl) => {
`;
}
if (req.session?.is_anon) {
await logAnonActivity(req, {
action: 'poll_vote',
targetId: pollId,
details: { option_id: optionId }
});
}
// Return updated tally
const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true;
+80 -272
View File
@@ -2,18 +2,9 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import queue from "../queue.mjs";
import { chanCurl } from "../chan_http.mjs";
import { promises as fs } from "fs";
import path from "path";
import { getManualApproval, getBypassDuplicateCheck, canUseChan, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
// Link path for a local item: its slug when slugs are enabled, else the numeric id
const itemPath = async (id, slug) => {
if (!getEnableItemSlugs()) return id;
if (slug === undefined) slug = (await db`SELECT slug FROM items WHERE id = ${id} LIMIT 1`)[0]?.slug;
return slug || id;
};
import { applyWordFilter } from "../wordfilter.mjs";
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
/**
* external.mjs — External source handlers (4chan threads, etc.)
@@ -53,8 +44,18 @@ export default (router) => {
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
*/
async function fetchWithProxy(url, asBuffer = false) {
const { bin, args } = chanCurl(url, ['--max-time', '30']);
const { stdout } = await queue.spawn(bin, args, { encoding: asBuffer ? 'buffer' : 'utf8' });
const curlArgs = [
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '30',
url
];
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);
}
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' });
if (asBuffer) return stdout;
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
if (!text.startsWith('{') && !text.startsWith('[')) {
@@ -66,7 +67,7 @@ export default (router) => {
// GET /api/v2/scroller/external/4chan/:board/:tid
// Proxies 4chan thread JSON
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.chanAuth, async (req, res) => {
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.loggedin, async (req, res) => {
if (!proxyRateLimit(req, res)) return;
const { board, tid } = req.params || {};
@@ -84,26 +85,12 @@ export default (router) => {
// Check which media URLs are already rehosted on this platform
const rehosts = {};
const rehostPaths = {};
const mediaPosts = posts.filter(p => p.tim && p.ext);
const cdn4Urls = [];
mediaPosts.forEach(p => {
const ext = (p.ext || '').toLowerCase();
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
cdn4Urls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
});
const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
if (cdn4Urls.length > 0) {
try {
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdn4Urls})`;
for (const r of rows) rehostPaths[r.id] = await itemPath(r.id, r.slug);
rows.forEach(r => {
rehosts[r.src] = r.id;
const m = r.src.match(/(\d{13,20})/);
if (m) rehosts[m[1]] = r.id;
});
const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`;
rows.forEach(r => { rehosts[r.src] = r.id; });
} catch (e) {
console.error('[EXTERNAL] DB src check error:', e.message);
}
@@ -111,7 +98,7 @@ export default (router) => {
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
body: JSON.stringify({ success: true, posts, board, tid, rehosts, rehost_paths: rehostPaths })
body: JSON.stringify({ success: true, posts, board, tid, rehosts })
});
} catch (err) {
@@ -172,7 +159,7 @@ export default (router) => {
// GET /api/v2/scroller/external/4chan/:board/catalog
// Proxies 4chan board catalog JSON
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.chanAuth, async (req, res) => {
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.loggedin, async (req, res) => {
if (!proxyRateLimit(req, res)) return;
const { board } = req.params || {};
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
@@ -210,7 +197,7 @@ export default (router) => {
// GET /api/v2/scroller/external/4chan/:board/find/:postno
// Resolves a post number to its parent thread ID
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.chanAuth, async (req, res) => {
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.loggedin, async (req, res) => {
if (!proxyRateLimit(req, res)) return;
const { board, postno } = req.params || {};
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
@@ -272,7 +259,7 @@ export default (router) => {
// F-001: Allowed file extensions for the media proxy (prevents abuse as generic proxy)
const ALLOWED_MEDIA_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'webm', 'mp4'];
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.chanAuth, async (req, res) => {
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.loggedin, async (req, res) => {
if (!proxyRateLimit(req, res)) return;
const { board, file } = req.params || {};
@@ -295,10 +282,19 @@ export default (router) => {
};
const contentType = mimes[ext] || 'application/octet-stream';
const { bin: curlBin, args: curlArgs } = chanCurl(url, ['--max-time', '60']);
const curlArgs = [
'-s', '-f', '-L',
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
'--max-time', '60',
url
];
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);
}
const { spawn } = await import('child_process');
const curl = spawn(curlBin, curlArgs);
const curl = spawn('curl', curlArgs);
res.writeHead(200, {
'Content-Type': contentType,
@@ -322,8 +318,8 @@ export default (router) => {
// POST /api/v2/scroller/rehost
// Downloads an external item and adds it to the platform
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.chanAuth, async (req, res) => {
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename, width: postWidth, height: postHeight } = req.post || {};
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename } = req.post || {};
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
@@ -339,29 +335,34 @@ export default (router) => {
|| url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1]
|| null;
// Rating is optional: do not force sfw/nsfw based on board
const validRatings = ['sfw', 'nsfw', 'nsfl'];
const rating = (initialRating && validRatings.includes(String(initialRating).toLowerCase()))
? String(initialRating).toLowerCase()
: null;
let rating = initialRating;
if (board === 'gif') rating = 'nsfw';
else if (board === 'wsg') rating = 'sfw';
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) });
}
const session = req.session;
// Anon sessions carry user = 'anonymous'; credit the upload to the real shadow account
const ownerName = getSessionOwnerName(session);
try {
const uuid = await queue.genuuid();
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
// Download via curl (lightweight)
const { bin: curlBin, args: curlArgs } = chanCurl(url, [
'-o', tmpPath,
const curlArgs = [
'-s', '-f', '-L', url, '-o', tmpPath,
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
'--connect-timeout', '30',
'--max-time', '300'
]);
'--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'
];
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);
}
await queue.spawn(curlBin, curlArgs);
await queue.spawn('curl', curlArgs);
// Detect MIME
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
@@ -381,16 +382,14 @@ export default (router) => {
if (repost) {
await fs.unlink(finalTmp).catch(() => {});
// Auto-subscribe user to the existing item they attempted to rehost
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: repost, item_path: await itemPath(repost), msg: 'Already on site' })
body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' })
});
}
}
@@ -403,43 +402,18 @@ export default (router) => {
if (phashMatch) {
await fs.unlink(finalTmp).catch(() => {});
// Auto-subscribe user to the existing item they attempted to rehost (visual match)
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
return res.reply({
code: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, item_path: await itemPath(phashMatch), msg: 'Already on site (visual match)' })
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' })
});
}
}
let itemWidth = Number(postWidth) || null;
let itemHeight = Number(postHeight) || null;
if (!itemWidth || !itemHeight) {
try {
if (mime.startsWith('image/')) {
const { stdout: magickOut } = await queue.spawn('magick', [
'identify', '-format', '%wx%h\n', finalTmp + '[0]'
], { quiet: true, ignoreExitCode: true });
const m = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
if (m) { itemWidth = parseInt(m[1], 10); itemHeight = parseInt(m[2], 10); }
} else if (mime.startsWith('video/')) {
const { stdout: probeOut } = await queue.spawn('ffprobe', [
'-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', finalTmp
], { quiet: true, ignoreExitCode: true });
const parts = probeOut.trim().split(',');
if (parts.length >= 2) {
const w = parseInt(parts[0], 10), h = parseInt(parts[1], 10);
if (w > 0 && h > 0) { itemWidth = w; itemHeight = h; }
}
}
} catch (e) {}
}
const filename = `${uuid}.${ext}`;
const isApprovalRequired = getManualApproval();
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
@@ -457,26 +431,22 @@ export default (router) => {
size: (await fs.stat(path.join(destDir, filename))).size,
checksum: insertChecksum,
phash: phash,
username: ownerName,
username: session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
original_filename: original_filename || null,
width: itemWidth,
height: itemHeight,
slug: lib.generateSlug(11)
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'width', 'height', 'slug')}
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
RETURNING id
`;
// Automatically subscribe user to the new item
if (cfg.enable_comments !== false) {
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
}
try {
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
// Process thumbnail
try {
@@ -486,14 +456,16 @@ export default (router) => {
console.error('[REHOST] Thumbnail error:', err);
}
// Tags: Only assign rating tag if explicitly specified; do NOT auto-tag board or sfw/nsfw
if (rating) {
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
}
// Tags
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
const rawList = Array.isArray(tagsRaw) ? tagsRaw : (typeof tagsRaw === 'string' ? tagsRaw.split(',') : []);
const tags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
// Board tag in chan-style format e.g. /gif/, /wsg/
if (board) tags.push(`/${board}/`);
// Auto-tag rating based on board
if (board === 'wsg') tags.push('sfw');
else if (board === 'gif') tags.push('nsfw');
for (const tagName of tags) {
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
if (tagRow.length === 0) {
@@ -505,21 +477,9 @@ export default (router) => {
}
}
// Insert optional first comment if provided
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
try {
const filteredComment = await applyWordFilter(comment.trim());
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: session.id,
content: filteredComment
})}
`;
} catch (err) {
console.error('[REHOST] Comment insert error:', err);
}
}
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
@@ -530,12 +490,10 @@ export default (router) => {
id: itemid,
dest: filename,
mime: mime,
username: ownerName,
username: session.user,
display_name: session.display_name || null,
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: false,
is_album: false,
album_count: 0
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
is_oc: false
})})`;
} catch (err) {
console.error('[REHOST] new_item notify failed:', err);
@@ -563,7 +521,7 @@ export default (router) => {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, item_id: itemid, item_path: await itemPath(itemid) })
body: JSON.stringify({ success: true, item_id: itemid })
});
} catch (err) {
@@ -576,155 +534,5 @@ export default (router) => {
}
});
// GET /api/v2/scroller/rehost/details/:id
// Retrieve current rating and tags for a rehosted item
router.get(/^\/api\/v2\/scroller\/rehost\/details\/(?<id>\d+)\/?$/, lib.chanAuth, async (req, res) => {
const itemId = Number(req.params.id);
try {
const tags = await lib.getTags(itemId, req.session);
const ratingTag = tags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
const rating = ratingTag ? ratingTag.normalized : 'untagged';
const userTags = tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag);
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, item_id: itemId, rating, tags: userTags })
});
} catch (err) {
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: err.message })
});
}
});
// POST /api/v2/scroller/rehost/details
// Set rating, add tags, and or post a comment directly on a rehosted item
router.post(/^\/api\/v2\/scroller\/rehost\/details\/?$/, lib.chanAuth, async (req, res) => {
const session = req.session;
if (!session || !session.user) {
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
}
const { item_id, rating, tags, comment } = req.post || req.body || {};
const itemId = parseInt(item_id, 10);
if (!itemId || isNaN(itemId)) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Valid item_id is required' }) });
}
try {
const rows = await db`SELECT id, username FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
if (!rows.length) {
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) });
}
const ownerName = getSessionOwnerName(session);
const isOwner = !!(rows[0].username && ownerName && rows[0].username.toLowerCase() === ownerName.toLowerCase());
const isAdmin = !!(session.admin || session.is_moderator);
const isChanUser = canUseChan(session);
const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) ||
(comment && typeof comment === 'string' && comment.trim().length > 0);
if (hasTagsOrComment && !isOwner && !isAdmin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Only owner can edit tags and comment' }) });
}
if (!isOwner && !isAdmin && !isChanUser) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
}
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
// 1. Update Rating if provided
if (rating !== undefined && rating !== null && rating !== '') {
const r = String(rating).toLowerCase().trim();
await db`
DELETE FROM tags_assign
WHERE item_id = ${itemId}
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
`;
if (r === 'sfw') {
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 1, user_id: session.id })} ON CONFLICT DO NOTHING`;
} else if (r === 'nsfw') {
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 2, user_id: session.id })} ON CONFLICT DO NOTHING`;
} else if (r === 'nsfl') {
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: nsflId, user_id: session.id })} ON CONFLICT DO NOTHING`;
}
// If r === 'untagged', no rating tag is inserted
}
// 2. Sync Tags if provided
if (tags !== undefined && tags !== null) {
const rawList = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
const cleanTags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
// Delete existing non-rating tags for this item by this user (or all non-rating tags if owner)
await db`
DELETE FROM tags_assign
WHERE item_id = ${itemId}
AND tag_id NOT IN (1, 2, ${nsflId})
AND tag_id NOT IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))
${isOwner ? db`` : db`AND user_id = ${session.id}`}
`;
for (const tagName of cleanTags) {
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')} ON CONFLICT DO NOTHING`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
}
if (tagRow.length) {
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: tagRow[0].id, user_id: session.id })} ON CONFLICT DO NOTHING`;
}
}
}
// 3. Add Comment if provided
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
const filteredComment = await applyWordFilter(comment.trim());
await db`
INSERT INTO comments ${db({
item_id: itemId,
user_id: session.id,
content: filteredComment
})}
`;
}
const freshTags = await lib.getTags(itemId, session);
await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags })).catch(() => {});
const ratingTag = freshTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
const cleanTagObjects = freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => ({
id: t.id,
tag: t.tag,
normalized: t.normalized,
badge: t.badge
}));
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
item_id: itemId,
item_path: await itemPath(itemId),
rating: ratingTag ? ratingTag.normalized : 'untagged',
tags: cleanTagObjects
})
});
} catch (err) {
console.error('[REHOST-DETAILS] Error:', err);
return res.reply({
code: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: false, msg: 'Failed to save details' })
});
}
});
return router;
};
+9 -21
View File
@@ -87,29 +87,17 @@ export default (router, tpl) => {
`;
if (items.length > 0) {
const inputs = [];
for (const item of items) {
const filePath = path.join(cfg.paths.t, `${item.id}.webp`);
try {
await fs.access(filePath);
inputs.push(`${filePath}[0]`);
} catch {
// Ignore missing thumbnail
}
}
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
await fs.mkdir(CACHE_DIR, { recursive: true });
const { execFile } = await import('child_process');
const util = await import('util');
const execFilePromise = util.promisify(execFile);
if (inputs.length > 0) {
await fs.mkdir(CACHE_DIR, { recursive: true });
const { execFile } = await import('child_process');
const util = await import('util');
const execFilePromise = util.promisify(execFile);
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
}
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));
}
} catch (e) {
console.error('[HALL_IMAGE] Error:', e);
+576 -849
View File
File diff suppressed because it is too large Load Diff
+130 -376
View File
@@ -1,7 +1,6 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { invalidateHiddenItems } from "../hidden_items.mjs";
import { promises as fs } from "fs";
import cfg from "../config.mjs";
import fetch from "flumm-fetch";
@@ -11,23 +10,18 @@ import { getManualApproval } from "../settings.mjs";
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
import { setMotd } from "../motd.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import queue from "../queue.mjs";
export default (router, tpl) => {
// Moderator Dashboard
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
const trashCount = (await db`select count(*) as c from "items" where active = false and is_deleted = true and is_purged = false`)[0].c;
const reportsCount = (await db`select count(*)::int as c from reports where status = 'pending'`.catch(() => [{ c: 0 }]))[0].c;
res.reply({
body: tpl.render("mod", {
session: req.session,
pendingCount: parseInt(pendingCount),
trashCount: parseInt(trashCount),
reportsCount: parseInt(reportsCount) || 0,
manualApproval: getManualApproval(),
tmp: null
}, req)
@@ -45,119 +39,25 @@ export default (router, tpl) => {
});
// Approval Queue (View only — GET is safe, no state change)
// Tag badge classes for queue cards (shared by the approval queue and soft-deleted views)
const processQueueItems = (items) => items.map(p => ({
...p,
tags: (p.tags || [])
.filter(t => t.tag !== null)
.map(t => {
let badge = "badge-light";
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
else if (t.normalized === "german") badge = "badge-german badge-light";
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") badge = "badge-success";
else if (t.normalized === "nsfw") badge = "badge-danger";
return { ...t, badge };
})
}));
const QUEUE_PAGE_SIZE = 20;
const queuePage = (req) => Math.max(1, +req.url.qs.page || 1);
// Approval queue: uploads waiting for approval (not deleted)
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
const page = queuePage(req);
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
// View Queue
const page = +req.url.qs.page || 1;
const limit = 20;
// Fetch Pending (not deleted)
const pending = await db`
select i.id, i.mime, i.username, i.dest, i.title, i.stamp, i.size, i.width, i.height,
i.original_filename, i.is_album, i.album_count,
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
from "items" i
left join "tags_assign" ta on ta.item_id = i.id
left join "tags" t on t.id = ta.tag_id
where i.active = false and i.is_deleted = false
group by i.id
order by i.id desc
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
limit ${limit} offset ${(page - 1) * limit}
`;
// Album pictures for the albums on this page (cover = order_index 0 uses the item's own files)
const albumIds = pending.filter(p => p.is_album).map(p => p.id);
const albumRows = albumIds.length ? await db`
select item_id, id, dest, mime, width, height, order_index
from album_items where item_id = any(${albumIds}::int[])
order by item_id, order_index asc
` : [];
const albumsById = new Map();
for (const r of albumRows) {
if (!albumsById.has(r.item_id)) albumsById.set(r.item_id, []);
albumsById.get(r.item_id).push(r);
}
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const mediaKind = (mime) => mime === 'video/youtube' ? 'youtube'
: mime?.startsWith('video') ? 'video'
: mime?.startsWith('image') ? 'image'
: mime?.startsWith('audio') ? 'audio'
: mime === 'application/pdf' ? 'pdf' : 'file';
const cards = processQueueItems(pending).map(p => {
const names = p.tags.map(t => t.normalized);
const rating = names.includes('nsfl') ? 'nsfl' : names.includes('nsfw') ? 'nsfw' : names.includes('sfw') ? 'sfw' : 'untagged';
// Main media (cover for albums)
const albumRows = p.is_album ? (albumsById.get(p.id) || []) : [];
const coverRow = albumRows.find(r => r.dest === p.dest) || albumRows.find(r => r.order_index === 0);
const media = [{
index: 1, aid: coverRow ? coverRow.id : null, kind: mediaKind(p.mime), mime: p.mime,
src: `/mod/pending/b/${p.dest}`, thumb: `/mod/pending/t/${p.id}.webp`,
width: p.width, height: p.height
}];
if (p.is_album) {
const subs = albumRows.filter(r => r !== coverRow);
for (const r of subs) {
media.push({
index: media.length + 1, aid: r.id, kind: mediaKind(r.mime), mime: r.mime,
src: `/mod/pending/b/${r.dest}`, thumb: `/mod/pending/t/${r.dest.replace(/\.[^.]+$/, '')}.webp`,
width: r.width, height: r.height
});
}
}
return {
...p,
rating,
kind: mediaKind(p.mime),
media,
media_json: JSON.stringify(media),
is_album_view: media.length > 1,
time_ago: p.stamp ? lib.timeAgo(new Date(p.stamp * 1000), req.lang) : '',
time_full: p.stamp ? new Date(p.stamp * 1000).toISOString() : '',
size_fmt: p.size ? lib.formatSize(p.size) : '',
dims: (p.width && p.height) ? `${p.width}×${p.height}` : ''
};
});
res.reply({
body: tpl.render('mod/approve', {
pending: cards,
total,
page,
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
enable_nsfl: !!cfg.enable_nsfl,
nsfl_tag_id: nsflId,
session: req.session,
tmp: null
}, req)
});
});
// Soft deleted: removed items that still exist on disk and can be restored or purged
router.get(/^\/mod\/trash\/?$/, lib.modAuth, async (req, res) => {
const page = queuePage(req);
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = true and is_purged = false`;
// Fetch Trash (deleted)
const trash = await db`
select i.id, i.mime, i.username, i.dest,
select i.id, i.mime, i.username, i.dest,
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
(select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason
from "items" i
@@ -166,121 +66,45 @@ export default (router, tpl) => {
where i.active = false and i.is_deleted = true and i.is_purged = false
group by i.id
order by i.id desc
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
limit 20
`;
const processItems = (items) => {
return items.map(p => {
const tags = (p.tags || [])
.filter(t => t.tag !== null)
.map(t => {
let badge = "badge-light";
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
else if (t.normalized === "german") badge = "badge-german badge-light";
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
else if (t.normalized === "sfw") badge = "badge-success";
else if (t.normalized === "nsfw") badge = "badge-danger";
return { ...t, badge };
});
return {
...p,
tags
};
});
};
res.reply({
body: tpl.render('mod/trash', {
trash: processQueueItems(trash),
total,
body: tpl.render('mod/approve', {
pending: processItems(pending),
trash: processItems(trash),
page,
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
stats: { total: pending.length + trash.length },
session: req.session,
tmp: null
}, req)
});
});
const jsonReply = (res, code, obj) => {
const body = JSON.stringify(obj);
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
// Move an item's media (and album sub-items) from the pending or deleted folder back to the public folders.
const moveItemFilesToPublic = async (item, id) => {
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.deleted, 'b', item.dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
];
const isYouTube = item.mime === 'video/youtube';
for (const p of movePaths) {
try {
if (isYouTube) {
await fs.access(p.t);
} else {
await fs.access(p.b);
}
console.log(`[MOD MOVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.warn(`[MOD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
}
};
const bDst = path.join(cfg.paths.b, item.dest);
const tDst = path.join(cfg.paths.t, `${id}.webp`);
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
if (!isYouTube) {
await moveSafe(p.b, bDst);
}
await moveSafe(p.t, tDst);
const blurSrc = p.t.replace('.webp', '_blur.webp');
await moveSafe(blurSrc, blurDst);
if (item.mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
if (item.is_album) {
try {
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
for (const sub of subItems) {
const subBase = sub.dest.replace(/\.[^.]+$/, '');
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
}
} catch (_) {}
}
break;
} catch (e) { }
}
};
// Permanently remove an item's files and comments and flag it purged (admin action).
const purgeItem = async (item, id) => {
await safeDeleteMediaFile(item.dest, id);
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.t, `${id}_blur.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}_blur.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${id}`;
await db`delete from comments where item_id = ${id}`;
};
const uploaderInfoFor = async (username) => {
try {
const u = await db`select id, "user" as username from "user" where login = ${username} or "user" = ${username} limit 1`;
return u.length ? { uploader_id: u[0].id, uploader_name: u[0].username } : {};
} catch { return {}; }
};
// F-005 Security: Approve action — POST with CSRF protection
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
const id = +(req.post?.id || 0);
@@ -290,18 +114,15 @@ export default (router, tpl) => {
}
const f0ck = await db`
select i.dest, i.mime, i.username, i.id, i.visibility, i.is_album, i.album_count,
(select ta2.tag_id from tags_assign ta2
join tags t2 on t2.id = ta2.tag_id
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl')
limit 1) as tag_id
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
from "items" i
where i.id = ${id} and i.active = false and i.is_deleted = false
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
limit 1
`;
if (f0ck.length === 0) {
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: not in the approval queue` });
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` });
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
@@ -318,12 +139,8 @@ export default (router, tpl) => {
// We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
// This prevents duplicate webhooks from double-clicks or race conditions.
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
invalidateHiddenItems();
if (result.count === 1) {
// Mark pending upload notifications as read for staff
await db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id = ${id}`.catch(() => {});
await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
// Notify User (WebSocket/Internal)
@@ -397,9 +214,7 @@ export default (router, tpl) => {
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc,
is_album: !!f0ck[0].is_album,
album_count: f0ck[0].album_count || 0
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
@@ -407,7 +222,61 @@ export default (router, tpl) => {
}
}
await moveItemFilesToPublic(f0ck[0], id);
// Move files to public location
const movePaths = [
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
];
const isYouTube = f0ck[0].mime === 'video/youtube';
for (const p of movePaths) {
try {
if (isYouTube) {
await fs.access(p.t);
} else {
await fs.access(p.b);
}
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
const moveSafe = async (src, dst) => {
try {
const lstat = await fs.lstat(src);
if (lstat.isSymbolicLink()) {
const target = await fs.readlink(src);
const absTarget = path.resolve(path.dirname(src), target);
const relTarget = path.relative(path.dirname(dst), absTarget);
await fs.symlink(relTarget, dst);
await fs.unlink(src).catch(() => {});
} else {
await fs.copyFile(src, dst);
await fs.unlink(src).catch(() => {});
}
} catch (e) {
if (e.code !== 'ENOENT') {
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
}
}
};
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
const tDst = path.join(cfg.paths.t, `${id}.webp`);
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
if (!isYouTube) {
await moveSafe(p.b, bDst);
}
await moveSafe(p.t, tDst);
const blurSrc = p.t.replace('.webp', '_blur.webp');
await moveSafe(blurSrc, blurDst);
if (f0ck[0].mime.startsWith('audio')) {
await moveSafe(p.ca, caDst);
}
break;
} catch (e) { }
}
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
@@ -432,11 +301,26 @@ export default (router, tpl) => {
if (item.is_deleted) {
// PURGE LOGIC (Strict Admin)
if (!req.session.admin) {
const body = JSON.stringify({ success: false, msg: "Only admins can purge items permanently." });
return res.writeHead(403, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
return res.reply({ success: false, msg: "Only admins can purge items permanently." });
}
await purgeItem(item, id);
// Delete files — respect symlink ownership
await safeDeleteMediaFile(item.dest, id);
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
}
// DB Flag instead of delete
await db`update "items" set is_purged = true where id = ${id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${id}`;
// Fetch uploader details for audit log
let uploaderInfo = {};
@@ -480,7 +364,6 @@ export default (router, tpl) => {
const reason = req.post?.reason || "Denied by moderator";
await db`update "items" set is_deleted = true, active = false where id = ${id}`;
await db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id = ${id}`.catch(() => {});
// Fetch uploader details for audit log and notification
let uploaderId = null;
@@ -519,28 +402,16 @@ export default (router, tpl) => {
const page = +(req.url.qs?.page || 1);
const limit = 50;
const offset = (page - 1) * limit;
const filterAction = req.url.qs?.action?.trim() || '';
const filterUser = req.url.qs?.user?.trim() || '';
const logs = await db`
SELECT al.*, u.user as username
FROM audit_log al
LEFT JOIN "user" u ON al.user_id = u.id
WHERE true
${filterAction ? db`AND al.action = ${filterAction}` : db``}
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
ORDER BY al.created_at DESC
LIMIT ${limit} OFFSET ${offset}
`;
const totalResult = await db`
SELECT count(*) as c
FROM audit_log al
LEFT JOIN "user" u ON al.user_id = u.id
WHERE true
${filterAction ? db`AND al.action = ${filterAction}` : db``}
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
`;
const totalResult = await db`SELECT count(*) as c FROM audit_log`;
const total = totalResult[0].c;
const pages = Math.ceil(total / limit);
@@ -602,9 +473,7 @@ export default (router, tpl) => {
logs: processed,
page,
pages,
hasMore: page < pages,
filterAction,
filterUser
hasMore: page < pages
});
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
@@ -617,8 +486,6 @@ export default (router, tpl) => {
logs: processedLogs,
page,
pages,
filterAction,
filterUser,
tmp: null
}, req)
});
@@ -672,7 +539,6 @@ export default (router, tpl) => {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
}
await db`update "items" set is_deleted = true, active = false where id = ${+id}`;
await db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id = ${+id}`.catch(() => {});
// Fetch uploader details for audit log
let uploaderInfo = {};
try {
@@ -798,138 +664,26 @@ export default (router, tpl) => {
}
});
// Deny a single picture of a pending album. The rest stays in the queue; if the cover is denied the next
// picture becomes the cover, and with one picture left the album is degraded to a normal single post.
router.post(/^\/mod\/album-item\/deny\/?$/, lib.modAuth, async (req, res) => {
const itemId = +(req.post?.item_id || 0);
const subId = +(req.post?.album_item_id || 0);
const reason = (req.post?.reason || '').toString().trim();
if (!itemId || !subId) return jsonReply(res, 400, { success: false, msg: 'Missing item_id or album_item_id' });
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
const [item] = await db`select id, dest, mime, username, is_album from "items" where id = ${itemId} and active = false and is_deleted = false limit 1`;
if (!item || !item.is_album) return jsonReply(res, 404, { success: false, msg: 'Not a pending album' });
const subs = await db`
select id, dest, mime, size, checksum, phash, width, height, order_index
from album_items where item_id = ${itemId} order by order_index asc, id asc
`;
const target = subs.find(r => r.id === subId);
if (!target) return jsonReply(res, 404, { success: false, msg: 'Album item not found' });
if (subs.length <= 1) return jsonReply(res, 400, { success: false, msg: 'Only one item left, deny the whole post instead' });
const remaining = subs.filter(r => r.id !== subId);
const isCover = target.dest === item.dest;
const degraded = remaining.length === 1;
await db.begin(async sql => {
await sql`delete from album_items where id = ${subId}`;
for (let i = 0; i < remaining.length; i++) {
await sql`update album_items set order_index = ${i} where id = ${remaining[i].id}`;
}
if (isCover) {
const c = remaining[0];
await sql`
update "items" set dest = ${c.dest}, mime = ${c.mime}, size = ${c.size},
checksum = ${c.checksum}, phash = ${c.phash}, width = ${c.width}, height = ${c.height}
where id = ${itemId}
`;
}
if (degraded) {
await sql`delete from album_items where item_id = ${itemId}`;
await sql`update "items" set is_album = false, album_count = 0 where id = ${itemId}`;
} else {
await sql`update "items" set album_count = ${remaining.length} where id = ${itemId}`;
}
});
// Remove the denied picture's files from the pending folders
const base = target.dest.replace(/\.[^.]+$/, '');
await fs.unlink(path.join(cfg.paths.pending, 'b', target.dest)).catch(() => {});
await fs.unlink(path.join(cfg.paths.pending, 't', `${base}.webp`)).catch(() => {});
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${base}.webp`)).catch(() => {});
// New cover: regenerate the item's main + blurred thumbnail from it
if (isCover) {
const c = remaining[0];
try {
await queue.genThumbnail(c.dest, c.mime, itemId, null, true);
await queue.genBlurredThumbnail(itemId, true);
} catch (err) {
console.error('[MOD ALBUM DENY] Thumbnail regeneration failed:', err);
}
}
await audit.log(req.session.id, 'deny_album_item', 'item', itemId, {
filename: target.dest, album_item_id: subId, reason,
remaining: remaining.length, cover_changed: isCover, degraded,
...(await uploaderInfoFor(item.username))
});
return jsonReply(res, 200, { success: true, remaining: remaining.length, cover_changed: isCover, degraded });
});
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
router.post(/^\/mod\/trash\/restore\/?$/, lib.modAuth, async (req, res) => {
const id = +(req.post?.id || 0);
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
const rows = await db`
select i.id, i.dest, i.mime, i.username, i.visibility, i.is_album, i.album_count, i.is_oc,
(select ta2.tag_id from tags_assign ta2 join tags t2 on t2.id = ta2.tag_id
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') limit 1) as tag_id
from "items" i
where i.id = ${id} and i.is_deleted = true and i.is_purged = false
limit 1
`;
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
const item = rows[0];
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`;
invalidateHiddenItems();
if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' });
await moveItemFilesToPublic(item, id);
await audit.log(req.session.id, 'restore_item', 'item', id, { filename: item.dest, ...(await uploaderInfoFor(item.username)) });
// Live grid update so the item reappears for open tabs
if ((item.visibility || 0) === 0) {
db`SELECT pg_notify('new_item', ${JSON.stringify({
id, dest: item.dest, mime: item.mime, username: item.username, tag_id: item.tag_id,
is_oc: !!item.is_oc, is_album: !!item.is_album, album_count: item.album_count || 0
})})`.catch(err => console.error('[MOD RESTORE] new_item notify failed:', err));
}
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item restored' });
});
// Permanently purge one soft-deleted item (admins only)
router.post(/^\/mod\/trash\/purge\/?$/, lib.auth, async (req, res) => {
const id = +(req.post?.id || 0);
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
const reason = (req.post?.reason || '').toString().trim();
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
const rows = await db`select id, dest, mime, username from "items" where id = ${id} and is_deleted = true and is_purged = false limit 1`;
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
const item = rows[0];
await purgeItem(item, id);
await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, reason, ...(await uploaderInfoFor(item.username)) });
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item purged' });
});
// Purge Trash (POST) - Strict Admin
router.post(/^\/mod\/(?:purge-trash-all|trash\/purge-all)\/?$/, lib.auth, async (req, res) => {
router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => {
try {
// lib.auth already ensures session.admin
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
let count = 0;
for (const item of trash) {
try {
await purgeItem(item, item.id);
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.deleted, 'b', item.dest)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
if (item.mime?.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
}
await db`update "items" set is_purged = true where id = ${item.id}`;
// Delete comments permanently on purge
await db`delete from comments where item_id = ${item.id}`;
count++;
} catch (e) { }
}
+19 -210
View File
@@ -1,67 +1,12 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs, isAnonymizeSession, getHwFingerprintEnabled } from "../settings.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { setMotd } from "../motd.mjs";
import security from "../security.mjs";
export const clients = new Set();
const activeTabs = new Map(); // sessionId -> tabId
export function broadcastBan(data) {
if (!data) return;
const targetUserIds = new Set();
if (data.userId) targetUserIds.add(+data.userId);
if (Array.isArray(data.userIds)) data.userIds.forEach(id => targetUserIds.add(+id));
const targetFps = new Set();
if (data.fingerprint) targetFps.add(data.fingerprint);
if (Array.isArray(data.fingerprints)) data.fingerprints.forEach(fp => targetFps.add(fp));
const targetHws = new Set();
if (data.hwFingerprint) targetHws.add(data.hwFingerprint);
if (Array.isArray(data.hwFingerprints)) data.hwFingerprints.forEach(hw => targetHws.add(hw));
const targetIps = new Set();
if (data.ip) targetIps.add(data.ip);
if (Array.isArray(data.ips)) data.ips.forEach(ip => targetIps.add(ip));
const targetIpHashes = new Set();
if (data.ipHash) targetIpHashes.add(data.ipHash);
if (Array.isArray(data.ipHashes)) data.ipHashes.forEach(h => targetIpHashes.add(h));
for (const client of clients) {
let isMatch = false;
if (client.userId && targetUserIds.has(+client.userId)) {
isMatch = true;
} else if (client.fingerprint && targetFps.has(client.fingerprint)) {
isMatch = true;
} else if (client.hwFingerprint && targetHws.has(client.hwFingerprint)) {
isMatch = true;
} else if (client.ip && targetIps.has(client.ip)) {
isMatch = true;
} else if (client.ipHash && (targetIpHashes.has(client.ipHash) || targetIps.has(client.ipHash))) {
isMatch = true;
}
if (isMatch) {
console.log(`[SSE] Delivering instant ban to client (userId: ${client.userId}, ip: ${client.ip}, tab: ${client.tabId})`);
client.send({
type: 'banned',
data: {
reason: data.reason || 'Violation of community rules',
expires: data.expires ? (isNaN(new Date(data.expires).getTime()) ? data.expires : new Date(data.expires).toLocaleString()) : 'Permanent',
redirect: '/banned'
}
});
setTimeout(() => {
client.close();
}, 1000);
}
}
}
// Broadcast the deduplicated online-user list to all connected clients
function broadcastChatPresence() {
const seen = new Set();
@@ -138,17 +83,6 @@ db.listen('warnings', (payload) => {
}
}).catch(err => console.error('DB Listen Warning error:', err));
// Global listener for bans
db.listen('bans', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Received ban event via database notify:`, data);
broadcastBan(data);
} catch (e) {
console.error('[SSE] Ban broadcast error:', e);
}
}).catch(err => console.error('[SSE] DB Listen Ban error:', err));
// Global listener for profile updates (display name changes etc.)
db.listen('profile_update', (payload) => {
try {
@@ -268,37 +202,9 @@ db.listen('favorites', async (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting favorite update for item ${data.item_id} to ${clients.size} clients`);
// Broadcast to ALL connected clients with per-client anonymization
// Broadcast to ALL connected clients
for (const client of clients) {
const isAnonClient = client.session ? isAnonymizeSession(client.session) : (client.isAnonymized ?? isAnonymizeSession(null));
if (isAnonClient) {
const anonymizedFavs = Array.isArray(data.favs) ? data.favs.map(f => {
const isSelf = client.userId && f.user_id && Number(f.user_id) === Number(client.userId);
if (isSelf) return f;
return {
user_id: null,
user: 'anonymous',
login: 'anonymous',
display_name: 'Anonymous',
avatar: null,
avatar_file: null,
username_color: null,
hide_fav_badge: f.hide_fav_badge,
is_anon: true
};
}) : [];
client.send({
type: 'favorites',
data: {
item_id: data.item_id,
user_id: (client.userId && data.user_id && Number(client.userId) === Number(data.user_id)) ? data.user_id : null,
favorited: data.favorited,
favs: anonymizedFavs
}
});
} else {
client.send({ type: 'favorites', data });
}
client.send({ type: 'favorites', data });
}
} catch (e) {
console.error('Favorite broadcast error:', e);
@@ -318,20 +224,6 @@ db.listen('motd', (payload) => {
}
}).catch(err => console.error('DB Listen MOTD error:', err));
// Global listener for brand image updates
db.listen('brand_image', (payload) => {
try {
const data = JSON.parse(payload);
console.log(`[SSE] Broadcasting brand_image update to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'brand_image', data: { url: data.url || null } });
}
} catch (e) {
console.error('Brand image broadcast error:', e);
}
}).catch(err => console.error('DB Listen brand_image error:', err));
// Global listener for new items (live grid updates)
db.listen('new_item', (payload) => {
try {
@@ -487,19 +379,14 @@ db.listen('global_chat_topic', (payload) => {
export default (router, tpl) => {
// Cleanup any orphaned or obsolete unread notifications on startup
db`UPDATE notifications SET is_read = true WHERE type = 'report' AND reference_id IN (SELECT id FROM reports WHERE status != 'pending') AND is_read = false`.catch(err => console.error('[NOTIF CLEANUP] Failed to cleanup report notifications:', err));
db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id IN (SELECT id FROM items WHERE active = true OR is_deleted = true) AND is_read = false`.catch(err => console.error('[NOTIF CLEANUP] Failed to cleanup admin_pending notifications:', err));
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning', 'invite_approved', 'invite_denied'];
const ADMIN_TYPES = ['invite_request'];
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
const nsflTagId = cfg.nsfl_tag_id || 3;
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
const offset = (page - 1) * limit;
const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
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,
@@ -595,9 +482,8 @@ export default (router, tpl) => {
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON n.item_id = i.id
LEFT JOIN reports r ON n.type = 'report' AND n.reference_id = r.id
WHERE n.user_id = ${req.session.id} AND n.is_read = false
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning', 'invite_request', 'invite_approved', 'invite_denied')
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning')
OR (
${req.session.do_not_disturb !== true} AND (
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
@@ -605,9 +491,7 @@ export default (router, tpl) => {
)
)
)
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning', 'invite_request', 'invite_approved', 'invite_denied'))
AND (n.type != 'report' OR r.status = 'pending')
AND (n.type != 'admin_pending' OR (i.active = false AND i.is_deleted = false))
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning'))
ORDER BY n.created_at DESC
LIMIT 1000
`;
@@ -631,21 +515,15 @@ export default (router, tpl) => {
}
});
// Mark all as read (optionally filtered by tab)
// Mark all as read
router.post('/api/notifications/read', async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
try {
const tab = req.url.qs?.tab || null;
const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
if (typeFilter) {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id} AND type = ANY(${typeFilter})`;
} else {
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
}
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ success: true, tab })
body: JSON.stringify({ success: true })
});
} catch (err) {
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
@@ -672,19 +550,17 @@ export default (router, tpl) => {
// Used when the user receives a live notification while already viewing that item.
// System-type notifications (item_deleted, deny, report, admin_pending) are excluded —
// they require explicit user acknowledgment.
router.post(/\/api\/notifications\/item\/(?<itemId>[a-zA-Z0-9_-]{11}|\d+)\/read/, async (req, res) => {
if (!req.session?.id) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const rawItemId = req.params.itemId;
const numericId = await f0cklib.resolveNumericItemId(rawItemId);
if (!numericId) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const itemId = req.params.itemId;
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report', 'warning'];
console.log(`[NotificationRoute] Marking notifications for item ${numericId} (${rawItemId}) as read for user ${req.session.id}`);
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
try {
await db`
UPDATE notifications
SET is_read = true
WHERE user_id = ${req.session.id}
AND item_id = ${numericId}
AND item_id = ${+itemId}
AND NOT (type = ANY(${SYSTEM_TYPES}))
`;
return res.reply({
@@ -727,18 +603,8 @@ export default (router, tpl) => {
res.writeHead(200, headers);
res.write(': ok\n\n'); // Warmup
const clientIp = security.getRealIP(req);
const clientIpHash = security.hashIP(clientIp);
const clientFp = req.session?.fingerprint || req.session?.anon_fingerprint || req.url.qs?.fp || null;
const clientHw = getHwFingerprintEnabled() ? (req.session?.hw_fingerprint || req.url.qs?.hw || null) : null;
const clientUserId = (req.session && typeof req.session === 'object') ? req.session.id : null;
const client = {
userId: clientUserId,
session: req.session || null,
isAnonymized: isAnonymizeSession(req.session),
fingerprint: clientFp,
hwFingerprint: clientHw,
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
username: req.session?.user || null,
display_name: req.session?.display_name || null,
avatar_file: req.session?.avatar_file || null,
@@ -751,8 +617,7 @@ export default (router, tpl) => {
do_not_disturb: req.session?.do_not_disturb === true,
sessionId,
tabId,
ip: clientIp,
ipHash: clientIpHash,
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
send: (data) => {
try {
res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -767,62 +632,6 @@ export default (router, tpl) => {
}
};
// Check if connecting client is already banned
(async () => {
let isBanned = false;
let banReason = 'Violation of community rules';
let banExpires = null;
if (client.userId) {
const u = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${client.userId} LIMIT 1`;
if (u[0]?.banned) {
isBanned = true;
banReason = u[0].ban_reason || banReason;
banExpires = u[0].ban_expires;
}
}
if (!isBanned && client.ip) {
const ipBan = await security.isIpBanned(client.ip);
if (ipBan) {
isBanned = true;
banReason = ipBan.reason || banReason;
banExpires = ipBan.expires;
}
}
if (!isBanned && client.fingerprint) {
const fpBan = await security.isFingerprintBanned(client.fingerprint);
if (fpBan) {
isBanned = true;
banReason = fpBan.reason || banReason;
banExpires = fpBan.expires;
}
}
if (!isBanned && client.hwFingerprint) {
const hwBan = await security.isHardwareBanned(client.hwFingerprint);
if (hwBan) {
isBanned = true;
banReason = hwBan.reason || banReason;
banExpires = hwBan.expires;
}
}
if (isBanned) {
console.log(`[SSE] Connecting client is already banned, pushing instant redirect to /banned`);
client.send({
type: 'banned',
data: {
reason: banReason,
expires: banExpires ? (isNaN(new Date(banExpires).getTime()) ? banExpires : new Date(banExpires).toLocaleString()) : 'Permanent',
redirect: '/banned'
}
});
setTimeout(() => client.close(), 1000);
}
})().catch(err => console.error('[SSE] Initial ban check error:', err));
// Send any unacknowledged warnings on connection
if (!isGuest && req.session?.id) {
db`
@@ -887,7 +696,7 @@ export default (router, tpl) => {
// Notification History Page
router.get('/notifications', async (req, res) => {
if (!req.session) return res.redirect('/login');
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs?.tab || 'user');
const tab = req.url.qs?.tab || 'user';
const data = await getNotificationHistory(req.session.id, 1, 50, tab);
data.session = req.session;
data.hidePagination = true;
@@ -908,7 +717,7 @@ export default (router, tpl) => {
success: false
}, 401);
const page = parseInt(req.url.qs.page) || 1;
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs.tab || null);
const tab = req.url.qs.tab || null;
const data = await getNotificationHistory(req.session.id, page, 50, tab);
const html = tpl.render('snippets/notifications-list', { ...data, active_mode: req.session?.mode ?? 0 }, req);
-192
View File
@@ -1,192 +0,0 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import cfg from "../config.mjs";
import path from "path";
import { promises as fs, createReadStream } from "fs";
import audit from "../audit.mjs";
import { getManualApproval, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
import { invalidateHiddenItems } from "../hidden_items.mjs";
/**
* pending.mjs — upload status for the uploader
* GET /pending own recent uploads with status (pending / live / denied / removed)
* GET /pending/t/:id.webp thumbnail of an own pending upload (pending files are not public)
* POST /pending/delete withdraw an own upload that is still pending (files + row are removed)
* GET /api/v2/uploads/:id/status JSON status for API clients (session or X-Api-Key)
*/
const RECENT_LIMIT = 50;
const statusOf = (row) => {
if (row.is_purged) return 'removed';
if (row.is_deleted) return 'denied';
if (row.active) return 'live';
return 'pending';
};
const itemPath = (row) => (getEnableItemSlugs() && row.slug) ? row.slug : row.id;
// Latest moderator reason for denied/removed items (deny, delete or purge)
const reasonsFor = async (ids) => {
if (!ids.length) return new Map();
const rows = await db`
select distinct on (target_id) target_id, details->>'reason' as reason
from audit_log
where target_id = any(${ids.map(String)}::text[])
and action in ('deny_item', 'delete_item', 'purge_item')
order by target_id, created_at desc
`.catch(() => []);
return new Map(rows.map(r => [Number(r.target_id), r.reason]));
};
// Session user, or the account behind an X-Api-Key header (for API clients)
const resolveUser = async (req) => {
if (req.session?.id) return req.session;
const key = req.headers['x-api-key'];
if (!key || cfg.websrv.enable_user_api_keys === false) return null;
const rows = await db`
select u.id, u.user, u.login, u.admin, u.is_moderator, u.banned
from user_api_keys k join "user" u on u.id = k.user_id
where k.api_key = ${key} limit 1
`.catch(() => []);
if (!rows.length || rows[0].banned) return null;
return rows[0];
};
const isOwnerOf = (row, session) => {
const owner = getSessionOwnerName(session);
return !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase());
};
// Every file a pending upload keeps in the pending folder (main file, thumbs, cover art, album sub-items)
const pendingFilesOf = async (row) => {
const p = (...parts) => path.join(cfg.paths.pending, ...parts);
const files = [p('t', `${row.id}.webp`), p('t', `${row.id}_blur.webp`), p('ca', `${row.id}.webp`)];
if (row.dest && row.mime !== 'video/youtube') files.push(p('b', row.dest));
if (row.is_album) {
const subs = await db`select dest from album_items where item_id = ${row.id}`.catch(() => []);
for (const sub of subs) {
files.push(p('b', sub.dest), p('t', `${sub.dest.replace(/\.[^.]+$/, '')}.webp`));
}
}
return files;
};
const json = (res, code, obj) => {
const body = JSON.stringify(obj);
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
};
export default (router, tpl) => {
router.get(/^\/pending\/?$/, async (req, res) => {
if (!req.session) return res.redirect('/login');
const owner = getSessionOwnerName(req.session);
const rows = owner ? await db`
select id, slug, mime, title, original_filename, stamp, size, active, is_deleted, is_purged, is_album, album_count, visibility
from items
where lower(username) = ${owner.toLowerCase()}
order by id desc
limit ${RECENT_LIMIT}
` : [];
const reasons = await reasonsFor(rows.filter(r => r.is_deleted).map(r => r.id));
const items = rows.map(r => {
const status = statusOf(r);
return {
id: r.id,
path: itemPath(r),
status,
title: r.title || r.original_filename || '',
mime: r.mime,
is_album: !!r.is_album,
album_count: r.album_count || 0,
size_fmt: r.size ? lib.formatSize(r.size) : '',
time_ago: r.stamp ? lib.timeAgo(new Date(r.stamp * 1000), req.lang) : '',
time_full: r.stamp ? new Date(r.stamp * 1000).toISOString() : '',
thumb: status === 'live' ? `/t/${r.id}.webp` : (status === 'pending' ? `/pending/t/${r.id}.webp` : ''),
reason: status === 'denied' || status === 'removed' ? (reasons.get(r.id) || '') : ''
};
});
const counts = { pending: 0, live: 0, denied: 0, removed: 0 };
items.forEach(i => { counts[i.status]++; });
res.reply({
body: tpl.render('pending', {
items,
counts,
manual_approval_on: getManualApproval(),
session: req.session,
tmp: null
}, req)
});
});
// Thumbnail of an own pending upload (moderators may view any)
router.get(/^\/pending\/t\/(?<id>\d+)\.webp$/, async (req, res) => {
if (!req.session) return res.writeHead(401).end();
const id = +req.params.id;
const [row] = await db`select username, active, is_deleted from items where id = ${id} limit 1`;
const isStaff = !!(req.session.admin || req.session.is_moderator);
const isOwner = isOwnerOf(row, req.session);
if (!row || row.active || row.is_deleted || !(isOwner || isStaff)) return res.writeHead(404).end();
const file = path.join(cfg.paths.pending, 't', `${id}.webp`);
try {
const stat = await fs.stat(file);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Content-Length': stat.size, 'Cache-Control': 'private, max-age=60' });
createReadStream(file).pipe(res);
} catch {
res.writeHead(404).end();
}
});
// Withdraw an own upload before a moderator has looked at it. Only the uploader, only while pending.
router.post(/^\/pending\/delete\/?$/, async (req, res) => {
if (!req.session) return json(res, 401, { success: false, msg: 'Login required' });
const id = +(req.post?.id || req.body?.id || 0);
if (!id) return json(res, 400, { success: false, msg: 'No ID provided' });
const [row] = await db`select id, username, dest, mime, is_album, active, is_deleted, is_purged from items where id = ${id} limit 1`;
if (!row || !isOwnerOf(row, req.session)) return json(res, 404, { success: false, msg: 'Upload not found' });
if (row.active || row.is_deleted || row.is_purged) return json(res, 409, { success: false, msg: 'Only pending uploads can be deleted' });
// Collect files before the row goes (album_items cascade). The active = false guard lets a concurrent
// approval win; files are only touched once the row is gone. Cascades clear tags, notifications, reports.
const files = await pendingFilesOf(row);
const deleted = await db`delete from items where id = ${id} and active = false and is_deleted = false returning id`;
if (!deleted.length) return json(res, 409, { success: false, msg: 'Upload was already reviewed' });
invalidateHiddenItems();
await Promise.all(files.map(f => fs.unlink(f).catch(() => {})));
await audit.log(req.session.id, 'withdraw_item', 'item', id, { filename: row.dest, uploader_name: row.username });
return json(res, 200, { success: true, id });
});
// JSON status for API clients (e.g. f0ckm-uploader polling after an upload went to manual approval)
router.get(/^\/api\/v2\/uploads\/(?<id>\d+)\/status\/?$/, async (req, res) => {
const user = await resolveUser(req);
if (!user) return json(res, 401, { success: false, msg: 'Login or X-Api-Key required' });
const id = +req.params.id;
const [row] = await db`select id, slug, username, active, is_deleted, is_purged from items where id = ${id} limit 1`;
const owner = getSessionOwnerName(user.is_anon !== undefined ? user : { ...user, is_anon: false });
const isStaff = !!(user.admin || user.is_moderator);
const isOwner = !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase());
if (!row || !(isOwner || isStaff)) return json(res, 404, { success: false, msg: 'Upload not found' });
const status = statusOf(row);
const reason = (status === 'denied' || status === 'removed') ? ((await reasonsFor([row.id])).get(row.id) || null) : null;
return json(res, 200, {
success: true,
itemid: row.id,
slug: row.slug || null,
status,
reason,
url: status === 'live' ? `${cfg.main.url.full}/${itemPath(row)}` : `${cfg.main.url.full}/pending#i${row.id}`,
status_url: `${cfg.main.url.full}/pending#i${row.id}`
});
});
};
+4 -12
View File
@@ -43,21 +43,17 @@ export default (router, tpl) => {
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr);
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
const reqQueryMime = req.url?.searchParams?.get('mime') || req.url?.qs?.mime;
const effectiveMime = (reqQueryMime !== undefined && reqQueryMime !== null) ? reqQueryMime : (cookieMime || opts.mime || null);
const data = await f0cklib.getRandom({
user: opts.user,
tag: opts.tag,
hall: opts.hall,
mime: effectiveMime,
mime: opts.mime || (req.cookies.mime || null),
page: opts.page,
fav: opts.mode === 'favs',
mode: req.mode,
ratings: ratingsArr,
strict: opts.strict,
session: req.session
session: !!req.session
});
console.log("data", data);
@@ -67,16 +63,12 @@ export default (router, tpl) => {
code: 404,
body: tpl.render('error', {
message: data.message,
tmp: null,
session: req.session ? { ...req.session } : false,
error_filter_hint: null,
error_filter_hint_link: null,
error_see_anyways: null
tmp: null
}, req)
});
}
res.redirect(encodeURI(`${data.link.main}${data.link.path}${data.itemid}${data.link.suffix || ''}`) + '?random=1');
res.redirect(encodeURI(`${data.link.main}${data.link.path}${data.itemid}${data.link.suffix || ''}`));
});
return router;
-15
View File
@@ -59,11 +59,6 @@ export default (router, tpl) => {
from "tags_assign"
left join "user" on "user".id = "tags_assign".user_id
left join "user_options" on "user_options".user_id = "user".id
left join "anon_identities" on "anon_identities".user_id = "user".id
where "user".id is not null
and "anon_identities".id is null
and "user".login not like 'anon_%'
and "user".user != 'anonymous'
group by "user".user, "user_options".avatar, "user_options".avatar_file, "user".admin, "user_options".display_name
order by count desc
`;
@@ -86,15 +81,6 @@ export default (router, tpl) => {
const totalUsers = +(await db`
select count(*) as total
from "user"
left join "anon_identities" on "anon_identities".user_id = "user".id
where "anon_identities".id is null
and "user".login not like 'anon_%'
and "user".user != 'anonymous'
`)[0].total;
const totalAnonUsers = +(await db`
select count(distinct pubkey) as total
from anon_identities
`)[0].total;
const hoster = await db`
@@ -155,7 +141,6 @@ export default (router, tpl) => {
totalComments,
totalFavs,
totalUsers,
totalAnonUsers,
enable_nsfl: config.enable_nsfl,
diskSize: cachedDiskSize,
tmp: null,
+18 -62
View File
@@ -80,11 +80,6 @@ export default (router, tpl) => {
return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.");
}
// anon_* logins are reserved for anonymous shadow users (treated as anon throughout the app)
if (/^anon_/i.test(username) || username.toLowerCase() === 'anonymous') {
return renderError("Username taken");
}
if (!password || password.length < 20) {
return renderError("Password must be at least 20 characters long.");
}
@@ -95,7 +90,7 @@ export default (router, tpl) => {
// 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?.site_key && cfg.recaptcha?.secret_key) {
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try {
@@ -159,55 +154,23 @@ export default (router, tpl) => {
const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3);
// Anonymous passkey identity registering: upgrade the existing shadow user in place
// instead of creating a new one. Favs, comments, uploads and passkeys stay attached;
// dropping the anon_identities row is what turns the account into a regular one.
const upgradeAnonId = req.session?.is_anon ? req.session.id : null;
let userId;
try {
if (upgradeAnonId) {
await db.begin(async sql => {
// Uploads reference their owner by name (items.username = the anon shadow login),
// so they move to the new username, the name regular uploads are stored under
const [old] = await sql`select login from "user" where id = ${upgradeAnonId} for update`;
if (old?.login) {
await sql`update items set username = ${username} where lower(username) = lower(${old.login})`;
}
await sql`
update "user"
set "login" = ${username.toLowerCase()}, "password" = ${hash}, "user" = ${username},
"email" = ${email || null}, "activated" = ${activated}, "activation_token" = ${activationToken}
where id = ${upgradeAnonId}
`;
await sql`
update user_options
set display_name = null, avatar_file = coalesce(avatar_file, 'default.png')
where user_id = ${upgradeAnonId}
`;
await sql`delete from anon_identities where user_id = ${upgradeAnonId}`;
});
userId = upgradeAnonId;
if (global._invalidateSessionCache && req.cookies?.session) {
global._invalidateSessionCache(lib.sha256(req.cookies.session));
}
} else {
const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
returning id
`;
userId = newUser[0].id;
const newUser = await db`
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
returning id
`;
userId = newUser[0].id;
// Assign default avatar file
const avatarId = null;
const avatarFile = 'default.png';
// Assign default avatar file
const avatarId = null;
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})
`;
}
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})
`;
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);
if (err.code === '23505') { // Unique constraint violation
@@ -237,11 +200,6 @@ export default (router, tpl) => {
// In production they should see an error, but let's keep it simple for now.
}
if (upgradeAnonId) {
// Unactivated accounts may not stay logged in — end the anon sessions until the email link is used
await db`delete from user_sessions where user_id = ${upgradeAnonId}`;
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
}
await renderSuccess("Registration successful! Please check your email to activate your account.");
return;
}
@@ -259,15 +217,13 @@ export default (router, tpl) => {
await security.recordAttempt(ip, username, 'register', true);
const successMsg = upgradeAnonId
? "Username claimed, you can still use your passkey."
: "Registration successful! You can now login.";
const successMsg = "Registration successful! You can now login.";
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg, upgraded: !!upgradeAnonId }));
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg }));
}
// Upgraded accounts keep their current session; otherwise redirect home with login success message
return res.writeHead(302, { "Location": upgradeAnonId ? "/settings" : "/?login=success" }).end();
// Redirect to home with login success message
return res.writeHead(302, { "Location": "/?login=success" }).end();
});
return router;
+8 -63
View File
@@ -1,30 +1,16 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import cfg from "../config.mjs";
import security from "../security.mjs";
export default (router, tpl) => {
// Submit a new report (Users & Guests)
router.post(/^\/api\/v2\/report\/?$/, async (req, res) => {
// User: Submit a new report
router.post(/^\/api\/v2\/report\/?$/, lib.loggedin, async (req, res) => {
try {
const { item_id, comment_id, reported_user_id, reason } = req.post;
// The framework's readBody uses Object.fromEntries which loses duplicate keys
// and decodeURIComponent on an array joins it as comma-separated string.
// So categories[]= ends up as a single comma-joined string — split it back.
const VALID_CATEGORIES = ['wrong_rating', 'spam', 'duplicate', 'copyright', 'illegal', 'other'];
let rawCats = req.post['categories[]'] || req.post['categories'] || '';
let categories = [];
if (Array.isArray(rawCats)) {
categories = rawCats;
} else if (typeof rawCats === 'string' && rawCats.length > 0) {
categories = rawCats.split(',').map(s => s.trim());
}
categories = categories.filter(c => VALID_CATEGORIES.includes(c));
if ((!reason || reason.trim().length === 0) && categories.length === 0) {
return res.json({ success: false, msg: "Please select at least one reason or provide a description." }, 400);
if (!reason || reason.trim().length === 0) {
return res.json({ success: false, msg: "Reason is required." }, 400);
}
// At least one target must be specified
@@ -32,45 +18,14 @@ export default (router, tpl) => {
return res.json({ success: false, msg: "Must specify an item, comment, or user to report." }, 400);
}
const ip = security.getRealIP(req);
const isGuest = !req.session;
if (isGuest) {
// CAPTCHA verification for guest reports (bypassed for .onion and localhost)
const isOnion = lib.isOnionRequest(req);
const isLocalhost = lib.isLocalhostRequest(req);
if (!isOnion && !isLocalhost && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) {
return res.json({ success: false, msg: "Please complete the CAPTCHA." }, 400);
}
try {
const verifyRes = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret: cfg.recaptcha.secret_key, response: rcToken, remoteip: ip })
});
const { success } = await verifyRes.json();
if (!success) {
return res.json({ success: false, msg: "CAPTCHA verification failed. Please try again." }, 400);
}
} catch (e) {
console.error('[REPORT] reCAPTCHA error:', e.message);
return res.json({ success: false, msg: "CAPTCHA verification error. Please try again." }, 500);
}
}
}
const reportRes = await db`
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories)
INSERT INTO reports (reporter_id, item_id, comment_id, user_id, reason)
VALUES (
${req.session ? req.session.id : null},
${security.storableIP(ip)},
${req.session.id},
${item_id ? +item_id : null},
${comment_id ? +comment_id : null},
${reported_user_id ? +reported_user_id : null},
${reason ? reason.trim() : ''},
${db.array(categories)}
${reason.trim()}
)
RETURNING id
`;
@@ -121,7 +76,6 @@ export default (router, tpl) => {
COALESCE(tgt_u.user, tgt_auth.user, comm_auth.user) AS reported_user_name,
COALESCE(NULLIF(r.user_id, 0), tgt_auth.id, comm_auth.id) AS reported_user_id,
COALESCE(tgt_u.admin, tgt_auth.admin, comm_auth.admin) AS reported_user_is_admin,
resolver.user AS resolver_name,
i.dest AS item_dest,
tgt_auth.id AS item_user_id,
tgt_auth.user AS item_user_name,
@@ -132,12 +86,10 @@ export default (router, tpl) => {
c.content AS comment_body,
COALESCE(r.item_id, c.item_id) AS resolved_item_id,
COALESCE(i.dest, ci.dest) AS resolved_item_dest,
COALESCE(i.mime, ci.mime) AS resolved_item_mime,
COALESCE(i.visibility, ci.visibility, 0) AS resolved_item_visibility
COALESCE(i.mime, ci.mime) AS resolved_item_mime
FROM reports r
LEFT JOIN "user" rep ON r.reporter_id = rep.id
LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id
LEFT JOIN "user" resolver ON r.resolved_by = resolver.id
LEFT JOIN items i ON r.item_id = i.id
LEFT JOIN "user" tgt_auth ON i.username = tgt_auth.user
LEFT JOIN comments c ON r.comment_id = c.id
@@ -203,13 +155,6 @@ export default (router, tpl) => {
return res.json({ success: false, msg: "Report not found." }, 404);
}
// Mark all notifications for this report as read across all moderators
await db`
UPDATE notifications
SET is_read = true
WHERE type = 'report' AND reference_id = ${id}
`;
await audit.log(req.session.id, 'resolve_report', 'report', id, { status: action });
return res.json({ success: true, msg: `Report marked as ${action}.` });
+4 -29
View File
@@ -2,15 +2,8 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { isAnonymizeSession, isAnonSession } from "../settings.mjs";
export default (router, tpl) => {
// Redirect /scroller to /abyss (preserving ID or fragment)
router.get(/^\/scroller(?:\/(?<id>[a-zA-Z0-9_\/-]+))?\/?$/, (req, res) => {
const id = req.params?.id || req.params?.[0];
return res.redirect(id ? `/abyss/${id}` : '/abyss');
});
// Serve the scroller page
router.get(/^\/abyss(?:\/(?<id>[a-zA-Z0-9_\/-]+))?\/?$/, async (req, res) => {
if (cfg.websrv.abyss_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
@@ -54,12 +47,6 @@ export default (router, tpl) => {
} catch (e) {
console.error('[SCROLLER] Failed to fetch meta for ID:', id, e);
}
} else if (id && /^([a-z0-9]+)\/(\d+)$/i.test(id.trim())) {
const chanMatch = id.trim().match(/^([a-z0-9]+)\/(\d+)$/i);
const [, board, tid] = chanMatch;
page_meta.title = `/${board}/${tid} — Abyss`;
page_meta.description = `Watch /${board}/ thread ${tid} in Abyss`;
page_meta.url = `https://${cfg.main.url.domain}/abyss/${board}/${tid}`;
}
return res.reply({
@@ -277,7 +264,7 @@ export default (router, tpl) => {
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}
${(!req.session || isAnonSession(req.session)) && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
`;
// If the anchor item doesn't pass the rating filter, it's inaccessible to this user.
// Return empty so the frontend shows "This post is currently unavailable".
@@ -300,7 +287,7 @@ export default (router, tpl) => {
${excludeSQL}
${mimeSQL}
${tagSQL}
${(!req.session || isAnonSession(req.session)) && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
ORDER BY random()
LIMIT ${limit - 1}
@@ -325,7 +312,7 @@ export default (router, tpl) => {
${excludeSQL}
${mimeSQL}
${tagSQL}
${(!req.session || isAnonSession(req.session)) && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : 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``}
${orderSQL}
LIMIT ${limit}
@@ -390,21 +377,9 @@ export default (router, tpl) => {
const lastItem = items[items.length - 1];
const nextCursor = lastItem ? lastItem.id : null;
const isAnonymized = isAnonymizeSession(req.session);
const outItems = isAnonymized
? items.map(item => ({
...item,
username: 'anonymous',
display_name: 'anonymous',
avatar: '/a/default.png',
username_color: null,
src_host: ''
}))
: items;
return res.reply({
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store, no-cache' },
body: JSON.stringify({ success: true, items: outItems, nextCursor })
body: JSON.stringify({ success: true, items, nextCursor })
});
} catch (e) {
console.error('[SCROLLER] Feed error:', e);
-4
View File
@@ -1,7 +1,6 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import search from "../routeinc/search.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
const _eps = 20;
@@ -15,9 +14,6 @@ export default (router, tpl) => {
let pagination, link;
if (tag.length > 0) {
if (req.session?.id && typeof tag === 'string' && !tag.startsWith('src:') && !tag.startsWith('title:')) {
f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag, scoreDelta: 2.0 }).catch(() => {});
}
if (tag.startsWith('src:')) {
total = (await db`
select count(*) as total
+4 -10
View File
@@ -37,7 +37,7 @@ export default (router, tpl) => {
// Get full user info
const user = (await db`
select email, created_at, (password = '!') as password_disabled from "user" where id = ${+req.session.id}
select email, created_at from "user" where id = ${+req.session.id}
`)[0];
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
@@ -58,13 +58,11 @@ export default (router, tpl) => {
banner_size: userOptions?.banner_size || 'cover',
email: user?.email || '',
joined: user?.created_at || null,
// Passkey-only account: password login switched off (password = '!')
password_disabled: !!user?.password_disabled,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
enable_swf: cfg.enable_swf,
enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export,
enable_user_api_keys: !req.session?.is_anon && cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: !req.session?.is_anon && cfg.websrv.enable_user_invites !== false,
enable_data_export: cfg.websrv.enable_data_export,
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: cfg.websrv.enable_user_invites !== false,
site_domain: cfg.main.url.domain,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
@@ -76,10 +74,6 @@ export default (router, tpl) => {
});
});
group.get('/export-data', auth, async (req, res) => {
if (req.session?.is_anon) {
res.status(403).reply({ body: 'Export requires a registered account' });
return;
}
if (!cfg.websrv.enable_data_export) {
res.status(403).reply({ body: 'Export disabled' });
return;
+6 -105
View File
@@ -1,9 +1,6 @@
import cfg from "../config.mjs";
import fs from "fs/promises";
import path from "path";
import db from "../sql.mjs";
import queue from "../queue.mjs";
import { isHiddenItem } from "../hidden_items.mjs";
export default (router, tpl) => {
router.static({
@@ -41,110 +38,14 @@ export default (router, tpl) => {
route: /^\/s\//
});
const getImgMime = (filename) => {
if (filename.endsWith('.webp')) return 'image/webp';
if (filename.endsWith('.png')) return 'image/png';
if (filename.endsWith('.gif')) return 'image/gif';
if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) return 'image/jpeg';
return 'application/octet-stream';
};
// <id>.webp / <id>_blur.webp belonging to an item that isn't live (pending, deleted) is never public,
// even if a stale file with that name exists. Uploaders use /pending/t/, staff /mod/pending/t/.
const isHiddenMedia = async (file) => {
const m = /^(\d+)(?:_blur)?\.webp$/.exec(file);
return !!m && await isHiddenItem(m[1]);
};
const notFound = (res) => {
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('404 - file not found.');
};
router.get(/^\/t\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file;
if (await isHiddenMedia(file)) return notFound(res);
const filePath = path.join(cfg.paths.t, file);
try {
const stat = await fs.stat(filePath);
if (stat.isFile()) {
const content = await fs.readFile(filePath);
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': getImgMime(file),
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
} catch (_) {}
// Dynamic generation or fallback for subf0cks / albums
const base = file.replace(/\.[^.]+$/, '').replace(/_blur$/, '');
try {
const subRow = await db`SELECT id, item_id, dest, mime FROM album_items WHERE dest LIKE ${base + '.%'} LIMIT 1`;
if (subRow.length > 0) {
const sub = subRow[0];
const srcFile = path.join(cfg.paths.b, sub.dest);
const thumbDest = path.join(cfg.paths.t, `${base}.webp`);
const srcStat = await fs.stat(srcFile).catch(() => null);
if (srcStat && srcStat.size > 0) {
if (sub.mime.startsWith('image/')) {
await queue.spawn('magick', [srcFile + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', thumbDest]);
} else if (sub.mime.startsWith('video/')) {
await queue.spawn('ffmpegthumbnailer', ['-i', srcFile, '-s', '256', '-o', thumbDest]).catch(async () => {
await queue.spawn('ffmpeg', ['-y', '-ss', '00:00:01', '-i', srcFile, '-vframes', '1', '-vf', 'scale=256:256:force_original_aspect_ratio=increase,crop=256:256', thumbDest]);
});
}
const genStat = await fs.stat(thumbDest).catch(() => null);
if (genStat && genStat.size > 0) {
const content = await fs.readFile(thumbDest);
res.writeHead(200, {
'Content-Length': genStat.size,
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
}
// Fallback to parent album thumbnail if subf0ck media file is missing
const parentThumb = path.join(cfg.paths.t, `${sub.item_id}.webp`);
const pStat = await fs.stat(parentThumb).catch(() => null);
if (pStat && pStat.size > 0) {
const content = await fs.readFile(parentThumb);
res.writeHead(200, {
'Content-Length': pStat.size,
'Content-Type': 'image/webp',
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
}
} catch (e) {
console.warn('[STATIC /t/] Dynamic subf0ck thumbnail generation error:', e.message);
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('404 - file not found.');
router.static({
dir: cfg.paths.t,
route: /^\/t\//
});
router.get(/^\/ca\/(?<file>.+)$/, async (req, res) => {
const file = req.params.file;
if (await isHiddenMedia(file)) return notFound(res);
const filePath = path.join(cfg.paths.ca, file);
try {
const stat = await fs.stat(filePath);
if (stat.isFile()) {
const content = await fs.readFile(filePath);
res.writeHead(200, {
'Content-Length': stat.size,
'Content-Type': getImgMime(file),
'Cache-Control': 'public, max-age=3600'
});
return res.end(content);
}
} catch (_) {}
res.writeHead(404, { 'Content-Type': 'text/plain' });
return res.end('404 - file not found.');
router.static({
dir: cfg.paths.ca,
route: /^\/ca\//
});
router.static({
+4 -15
View File
@@ -40,22 +40,11 @@ export async function regenerateTagImage(tag, mode) {
`;
if (items.length > 0) {
const inputs = [];
for (const item of items) {
const filePath = path.join(cfg.paths.t, `${item.id}.webp`);
try {
await fs.access(filePath);
inputs.push(`${filePath}[0]`);
} catch {
// Ignore missing thumbnail
}
}
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
if (inputs.length > 0) {
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
return cachePath;
}
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
return cachePath;
}
} catch (err) {
console.error(`[TAG_IMAGE] Failed to generate image for tag "${tag}" (mode ${mode}):`, err);
+12 -24
View File
@@ -1,7 +1,6 @@
import db from "../../inc/sql.mjs";
import lib from "../../inc/lib.mjs";
import cfg from "../../inc/config.mjs";
import { isAnonSession, getAnonAllowedModes } from "../settings.mjs";
import url from "url";
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
@@ -10,7 +9,6 @@ export default (router, tpl) => {
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
const isGuest = !sessionObj;
const isPublicVisitor = !sessionObj || isAnonSession(sessionObj);
let baseMode = lib.getMode(mode);
if (isGuest) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
@@ -31,26 +29,15 @@ export default (router, tpl) => {
const modequery = baseMode;
let restrictedFilter = db``;
if (isPublicVisitor && cfg.nsfp && cfg.nsfp.length > 0) {
let effectiveNsfp = [...cfg.nsfp];
if (sessionObj && isAnonSession(sessionObj)) {
const allowedModes = getAnonAllowedModes();
if (allowedModes.includes('nsfw')) effectiveNsfp = effectiveNsfp.filter(id => id !== 2);
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
if (allowedModes.includes('nsfl')) effectiveNsfp = effectiveNsfp.filter(id => id !== nsflId);
} else if (cfg.websrv.public_nsfw) {
effectiveNsfp = effectiveNsfp.filter(id => id !== 2);
}
if (effectiveNsfp.length > 0) {
restrictedFilter = db`
AND t.id NOT IN ${db(effectiveNsfp)}
AND NOT EXISTS (
SELECT 1 FROM tags_assign ta_res
WHERE ta_res.item_id = items.id
AND ta_res.tag_id IN ${db(effectiveNsfp)}
)
`;
}
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
restrictedFilter = db`
AND t.id NOT IN ${db(cfg.nsfp)}
AND NOT EXISTS (
SELECT 1 FROM tags_assign ta_res
WHERE ta_res.item_id = items.id
AND ta_res.tag_id IN ${db(cfg.nsfp)}
)
`;
}
const userExcludeFilter = excludedTags.length > 0
@@ -129,7 +116,7 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
return res.json({
success: true,
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE
});
@@ -144,7 +131,7 @@ export default (router, tpl) => {
});
// Main tags page
router.get(/^\/tags?\/?$/, async (req, res) => {
router.get(/^\/tags$/, async (req, res) => {
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
const mode = req.mode ?? 0;
const query = req.url.qs || {};
@@ -162,6 +149,7 @@ export default (router, tpl) => {
phrase,
tmp: null,
hidePagination: true,
user: req.session?.user ? { user: req.session.user } : null,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'Tags',

Some files were not shown because too many files have changed in this diff Show More