1 Commits
Author SHA1 Message Date
kibi f803ec27a5 WIP Chat overhaul 2026-07-26 21:31:28 +02:00
192 changed files with 8183 additions and 63356 deletions
+3 -6
View File
@@ -1,12 +1,9 @@
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
# --- Nginx & Let's Encrypt Configuration (Optional) ---
# Set to 'f0ckm-nginx' to enable the Nginx & Let's Encrypt proxy stack
# COMPOSE_PROFILES=f0ckm-nginx
#
# VIRTUAL_HOST=yourdomain.com
# LETSENCRYPT_HOST=yourdomain.com
+1 -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 -56
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,56 +74,7 @@ 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
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
## Tor Hidden Service (.onion)
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
```bash
cat ./f0ckm-data/tor/f0ckm_hs/hostname
```
### Automatic Onion-Location Header
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
```json
"main": {
"onion": "http://yourgeneratedaddress.onion"
}
```
## 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.
+4 -7
View File
@@ -16,7 +16,7 @@ fi
# Parse arguments
TARGET="master"
CACHE_FLAG=""
CUSTOM_BRANCH=""
BRANCH_TARGET=""
for arg in "$@"; do
case $arg in
@@ -27,8 +27,8 @@ for arg in "$@"; do
CACHE_FLAG="--no-cache"
;;
*)
if [ -z "$CUSTOM_BRANCH" ]; then
CUSTOM_BRANCH=$arg
if [ -z "$BRANCH_TARGET" ]; then
BRANCH_TARGET=$arg
fi
;;
esac
@@ -43,10 +43,7 @@ fi
# Set Image name and branch defaults
IMAGE_NAME="f0ckm"
if [ -n "$CUSTOM_BRANCH" ]; then
BRANCH_TARGET="$CUSTOM_BRANCH"
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
elif [ "$TARGET" == "master" ]; then
if [ "$TARGET" == "master" ]; then
echo "--- PRODUCTION DEPLOYMENT (master) ---"
BRANCH_TARGET="master"
elif [ "$TARGET" == "stg" ]; then
-1
View File
@@ -1,4 +1,3 @@
client_max_body_size 10000M;
client_body_timeout 120s;
client_header_timeout 120s;
-9
View File
@@ -1,9 +0,0 @@
# Tor configuration for f0ckm
# Enable SOCKS proxy for internal container & external connections
SocksPort 0.0.0.0:9050
# Tor Hidden Service (v3 Onion Service) configuration
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
HiddenServiceDir /var/lib/tor/f0ckm_hs/
HiddenServicePort 80 f0ckm:1337
+3 -89
View File
@@ -5,7 +5,6 @@
"domain": "example.com",
"regex": "example\\.com"
},
"onion": "http://your-onion-address.onion",
"socks": "socks5://127.0.0.1:9050",
"mail": "admin@example.com",
"maxfilesize": 104857600,
@@ -16,11 +15,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 +28,11 @@
],
"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 +45,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"
@@ -124,24 +61,15 @@
"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,
"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 +108,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": [
@@ -197,10 +121,7 @@
"enable_profile_description": true,
"user_alternative_infobox": false,
"user_banner_enabled": true,
"comment_banner_enabled": true,
"comment_banner_max_height": 300,
"user_alternative_steuerung": false,
"expose_repost_links_to_guests": false,
"enable_swf": false,
"swf_thumb": "/s/img/swf.png",
"enable_archive": true,
@@ -211,10 +132,7 @@
"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",
@@ -290,7 +208,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 +215,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 +237,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
+7 -20
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
@@ -32,10 +31,7 @@ services:
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
- ./f0ckm-data/s/img/navbar/:/opt/f0ckm/public/s/img/navbar/:Z
environment:
- GIT_HASH=${f0ckm_TAG:-unknown}
@@ -43,14 +39,6 @@ services:
- VIRTUAL_PORT=1337
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
- DB_HOST=f0ckm-db
- DB_PORT=5432
- DB_USER=${POSTGRES_USER:-f0ckm}
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
- DB_NAME=${POSTGRES_DB:-f0ckm}
- NODE_ENV=production
# 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
@@ -61,14 +49,9 @@ services:
tor:
image: dockurr/tor
container_name: tor
profiles:
- tor
ports:
- "127.0.0.1:9050:9050"
- "127.0.0.1:8118:8118"
volumes:
- ./config/tor:/etc/tor:ro
- ./f0ckm-data/tor:/var/lib/tor:Z
- "9050:9050"
- "8118:8118"
networks:
- f0ckm-net
restart: always
@@ -114,17 +97,21 @@ services:
f0ckm-nginx:
image: nginxproxy/nginx-proxy:latest
container_name: f0ckm-nginx
network_mode: "host"
profiles:
- f0ckm-nginx
environment:
- ENABLE_IPV6=true
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
- certs:/etc/nginx/certs:rw
- vhost:/etc/nginx/vhost.d:rw
- html:/usr/share/nginx/html:rw
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
networks:
- f0ckm-net
restart: unless-stopped
acme-companion:
View File
-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;
-3
View File
@@ -1,3 +0,0 @@
-- Migration: Add expires_at column to items table for expiring uploads
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;
-5
View File
@@ -1,5 +0,0 @@
-- Add favorites_private column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';
@@ -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);
-5
View File
@@ -1,5 +0,0 @@
-- Add hide_fav_badge column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';
-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,38 +0,0 @@
-- Add visibility and slug columns to items table
ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0;
ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE;
ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0;
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug);
COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private';
COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access';
COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
-- Populate missing slugs for existing items
DO $$
DECLARE
r RECORD;
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
new_slug text;
i integer;
BEGIN
FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP
LOOP
new_slug := '';
FOR i IN 1..11 LOOP
new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1);
END LOOP;
BEGIN
UPDATE items SET slug = new_slug WHERE id = r.id;
EXIT;
EXCEPTION WHEN unique_violation THEN
END;
END LOOP;
END LOOP;
END $$;
-- Fix any items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
ALTER TABLE items ALTER COLUMN visibility SET NOT NULL;
ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0;
-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);
+651 -1448
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -1,2 +0,0 @@
-- Fix items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
+7 -28
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": {
@@ -76,9 +75,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -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": {
@@ -508,9 +492,9 @@
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
},
"brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"requires": {
"balanced-match": "^1.0.0"
}
@@ -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=="
}
}
}
+3 -11
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",
@@ -22,8 +16,7 @@
"build": "node scripts/build-css.mjs",
"copy-vendor": "node scripts/copy-vendor.mjs",
"seed": "node scripts/seed.mjs",
"create-admin": "node scripts/create-admin.mjs",
"import": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node scripts/import.mjs"
"create-admin": "node scripts/create-admin.mjs"
},
"author": "Kibi Kelburton",
"license": "MIT",
@@ -36,7 +29,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"
}
}
+1029 -7476
View File
File diff suppressed because it is too large Load Diff
+10 -438
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;
}
@@ -374,32 +362,11 @@
}
.upload-form.shitpost-mode-active .global-rating-section,
.upload-form.shitpost-mode-active .global-visibility-section,
.upload-form.shitpost-mode-active .global-expiry-section,
.upload-form.shitpost-mode-active .global-comment-section,
.upload-form.shitpost-mode-active .global-tag-section {
display: none !important;
}
.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,43 +402,23 @@
}
.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 */
.item-visibility-container .item-rating-option input:checked + .item-rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
color: #51cf66;
border-color: rgba(81, 207, 102, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
color: #ffbb33;
border-color: rgba(255, 187, 51, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
border-color: rgba(255, 68, 68, 0.5);
}
.item-rating-label:hover {
@@ -652,6 +599,7 @@
.rating-label {
display: block;
padding: 0.1rem 2rem;
border-radius: 0;
border: 2px solid transparent;
transition: all 0.2s;
@@ -714,32 +662,6 @@
opacity: 1;
}
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
border-color: #51cf66;
color: #51cf66;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
border-color: #ffbb33;
color: #ffbb33;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
border-color: #ff4444;
color: #ff4444;
box-shadow: none;
opacity: 1;
}
/* Tags */
.tag-input-container {
background: rgba(255, 255, 255, 0.05);
@@ -990,51 +912,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 +1658,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;
}
}
-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="#242526"/>
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
</svg>

Before

Width:  |  Height:  |  Size: 443 B

+72 -592
View File
@@ -1,30 +1,18 @@
(async () => {
// Helper to get dynamic context
const getContext = () => {
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const usernameEl = document.querySelector("a#a_username");
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,
postid: +idLink.innerText,
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
poster: (usernameEl?.dataset?.username || '').trim() || null,
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
};
};
@@ -56,13 +44,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 +53,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 +115,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 +129,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 +138,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 +147,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 +156,78 @@
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
});
if (res.success) {
// New state is the logical opposite of what it was before the API call
const isNowFav = !wasAlreadyFav;
// 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 (favoBtn) {
favoBtn.classList.toggle('fa-solid', isNowFav);
favoBtn.classList.toggle('fa-regular', !isNowFav);
}
}
const mySeq = ++favSeq;
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');
// 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 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;
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'));
}
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 +258,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 +324,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();
}
})();
+79 -554
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
@@ -513,15 +468,9 @@ class CommentSystem {
// 2. Check for duplicates (if we just posted it ourselves via optimistic insert).
// Even on early return, ensure the button is present if body was truncated.
if (document.getElementById('c' + data.id)) {
const el = document.getElementById('c' + data.id);
if (el && data.banner_file && data.banner_file !== 'null') {
el.style.setProperty('--author-banner', `url('/a/${data.banner_file}')`);
el.style.setProperty('--author-banner-position', data.banner_position === 'center' ? 'center top' : (data.banner_position || 'center top'));
el.style.setProperty('--author-banner-size', (data.banner_size && data.banner_size !== 'cover') ? data.banner_size : '100% auto');
el.style.setProperty('--author-banner-repeat', 'no-repeat');
}
if (data.body && data.body.endsWith('\u2026')) {
console.log('[handleLiveComment] duplicate+truncated, ensuring button for', data.id);
const el = document.getElementById('c' + data.id);
const contentEl = el?.querySelector('.comment-content');
if (contentEl && !contentEl.querySelector('.load-full-comment-btn')) {
const btnLabel = (window.f0ckI18n?.sidebar_show_full_comment) || 'show full comment';
@@ -558,10 +507,6 @@ class CommentSystem {
avatar: data.avatar,
avatar_file: data.avatar_file,
username_color: data.username_color,
banner_file: data.banner_file || null,
banner_position: data.banner_position || null,
banner_size: data.banner_size || null,
banner_repeat: data.banner_repeat || null,
video_time: data.video_time ?? null,
is_new: true,
is_deleted: false,
@@ -570,20 +515,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
@@ -654,20 +593,7 @@ class CommentSystem {
// Update in-memory data so future reconciles use the full content
if (this.lastData) {
const cached = this.lastData.find(c => String(c.id) === String(commentId));
if (cached) {
cached.content = fullContent;
if (json.comment.banner_file) cached.banner_file = json.comment.banner_file;
if (json.comment.banner_position) cached.banner_position = json.comment.banner_position;
if (json.comment.banner_size) cached.banner_size = json.comment.banner_size;
if (json.comment.banner_repeat) cached.banner_repeat = json.comment.banner_repeat;
}
}
if (json.comment && json.comment.banner_file) {
el.style.setProperty('--author-banner', `url('/a/${json.comment.banner_file}')`);
el.style.setProperty('--author-banner-position', json.comment.banner_position === 'center' ? 'center top' : (json.comment.banner_position || 'center top'));
el.style.setProperty('--author-banner-size', (json.comment.banner_size && json.comment.banner_size !== 'cover') ? json.comment.banner_size : '100% auto');
el.style.setProperty('--author-banner-repeat', 'no-repeat');
if (cached) cached.content = fullContent;
}
contentEl.dataset.raw = fullContent;
@@ -793,25 +719,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 +841,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.
@@ -1123,6 +1039,10 @@ class CommentSystem {
preview.style.position = 'fixed';
preview.style.zIndex = (100000 + level).toString();
// Try to place it to the right of the link, or top/bottom if needed
let left = rect.right + 10;
let top = rect.top;
document.body.appendChild(preview);
CommentSystem.playEmojiVideos(preview);
@@ -1147,28 +1067,16 @@ class CommentSystem {
const previewRect = preview.getBoundingClientRect();
// Default: position preview to the right of the link
let left = rect.right + 10;
let top = rect.top;
// If there's NOT enough space on the right, open to the left
if (left + previewRect.width > window.innerWidth - 10) {
// Boundary checks
if (left + previewRect.width > window.innerWidth) {
left = rect.left - previewRect.width - 10;
}
if (left < 0) left = 10;
// Clamp to screen boundaries if left/right are both tight
if (left < 10) {
left = 10;
}
if (left + previewRect.width > window.innerWidth - 10) {
left = Math.max(10, window.innerWidth - previewRect.width - 10);
}
// Clamp top & bottom boundary
if (top + previewRect.height > window.innerHeight - 10) {
if (top + previewRect.height > window.innerHeight) {
top = window.innerHeight - previewRect.height - 10;
}
if (top < 10) top = 10;
if (top < 0) top = 10;
preview.style.left = left + 'px';
preview.style.top = top + 'px';
@@ -1232,7 +1140,6 @@ class CommentSystem {
}
quoteComment(id, openerEl, body) {
if (!window.f0ckSession || !window.f0ckSession.logged_in) return;
// If no reply input open anywhere, open the local one
let textarea = document.querySelector('.comment-input.reply-input textarea');
let isNew = false;
@@ -1253,13 +1160,17 @@ class CommentSystem {
const author = openerEl.dataset.display || openerEl.dataset.username || 'System';
const contentEl = body.querySelector('.comment-content');
if (contentEl) {
const LINE_MAX = 200;
let rawText = (contentEl.dataset.raw || '').trim();
if (rawText.includes('&gt;') || rawText.includes('&lt;') || rawText.includes('&amp;')) {
const txt = document.createElement('textarea');
txt.innerHTML = rawText;
rawText = txt.value;
}
const lines = rawText.split('\n');
// Preserve all lines but cap any single line exceeding LINE_MAX chars
const lines = rawText.split('\n').map(line =>
line.length > LINE_MAX ? line.substring(0, LINE_MAX) + '\u2026' : line
);
const quote = `>>${id} \n>${author}\n${lines.map(line => `>${line}`).join('\n')}\n`;
if (isNew) {
@@ -1400,8 +1311,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 +1398,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.
@@ -1595,13 +1505,6 @@ class CommentSystem {
el.classList.toggle('pinned', !!incoming.is_pinned);
const pinIcon = el.querySelector('.pin-icon');
if (pinIcon) pinIcon.style.display = incoming.is_pinned ? 'block' : 'none';
if (incoming && incoming.banner_file && incoming.banner_file !== 'null') {
el.style.setProperty('--author-banner', `url('/a/${incoming.banner_file}')`);
el.style.setProperty('--author-banner-position', incoming.banner_position === 'center' ? 'center top' : (incoming.banner_position || 'center top'));
el.style.setProperty('--author-banner-size', (incoming.banner_size && incoming.banner_size !== 'cover') ? incoming.banner_size : '100% auto');
el.style.setProperty('--author-banner-repeat', 'no-repeat');
}
}
});
@@ -1722,25 +1625,17 @@ class CommentSystem {
syncLevel(roots, list, false);
}
// Maximum characters & lines to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 500; }
static get ITEM_VIEW_MAX_LINES() { return 6; }
// Maximum characters to fully render in the item view per comment
static get ITEM_VIEW_MAX_CHARS() { return 2000; }
renderCommentContent(content, commentId = null, bypassTruncation = false) {
if (!content) return '';
// Truncate long comments before any processing
// Truncate extremely long comments before any processing
let truncated = false;
if (!bypassTruncation) {
const lines = content.split('\n');
if (lines.length > CommentSystem.ITEM_VIEW_MAX_LINES) {
content = lines.slice(0, CommentSystem.ITEM_VIEW_MAX_LINES).join('\n');
truncated = true;
}
if (content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS);
truncated = true;
}
if (!bypassTruncation && content.length > CommentSystem.ITEM_VIEW_MAX_CHARS) {
content = content.substring(0, CommentSystem.ITEM_VIEW_MAX_CHARS) + '\u2026';
truncated = true;
}
if (typeof marked === 'undefined') {
@@ -1889,11 +1784,10 @@ class CommentSystem {
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const renderedContent = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${renderedContent}</span>`;
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: quoteContent;
return `<span class="greentext">&gt;${renderedContent.replace(/>/g, '&gt;')}</span>`;
}
// 2. Per-line limit to prevent marked.parse recursion on single giant lines
@@ -2270,31 +2164,7 @@ class CommentSystem {
}
}
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
let 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}"><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${comment.user_id}" ${comment.username_color ? `style="color: ${comment.username_color}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
}
timeAgo(date) {
@@ -2395,24 +2265,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 +2278,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;">`
@@ -2549,50 +2400,10 @@ class CommentSystem {
}
}, { passive: true });
// Global click listener for comment interaction (popups & expanding truncated comments in previews)
// Global click listener to close popups (useful for mobile dismissal)
document.addEventListener('click', (e) => {
const target = e.target;
// Load full comment (expand truncated)
const loadFullBtn = target.closest('.load-full-comment-btn');
if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.collapse-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
const seeLessLabel = (window.f0ckI18n?.sidebar_see_less) || 'see less';
contentEl.insertAdjacentHTML('beforeend',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
}
// Collapse full comment back to truncated view
const collapseBtn = target.closest('.collapse-comment-btn');
if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.load-full-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
}
const isLink = target.closest('.comment-context-link');
const isPopup = target.closest('.comment-preview-popup');
const isLink = e.target.closest('.comment-context-link');
const isPopup = e.target.closest('.comment-preview-popup');
if (!isLink && !isPopup) {
this.closePreviewsAboveLevel(-1);
@@ -2772,7 +2583,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 {
@@ -3007,10 +2818,7 @@ class CommentSystem {
if (loadFullBtn) {
const contentEl = loadFullBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.collapse-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
const fullContent = contentEl.dataset.raw;
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, true);
// Append "see less" button after full content
@@ -3018,7 +2826,6 @@ class CommentSystem {
contentEl.insertAdjacentHTML('beforeend',
`<span class="item-comment-truncated-notice"><button class="collapse-comment-btn" type="button">${seeLessLabel}</button></span>`
);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
@@ -3029,13 +2836,9 @@ class CommentSystem {
if (collapseBtn) {
const contentEl = collapseBtn.closest('.comment-content');
if (contentEl) {
if (contentEl.querySelector('.load-full-comment-btn')) return;
const commentEl = contentEl.closest('.comment');
const commentId = commentEl ? (commentEl.dataset.id || (commentEl.id ? commentEl.id.replace(/^c/, '') : null)) : null;
const fullContent = contentEl.dataset.raw || (commentId && this.commentCache ? this.commentCache.get(commentId)?.content : null);
const fullContent = contentEl.dataset.raw;
if (fullContent) {
contentEl.innerHTML = this.renderCommentContent(fullContent, null, false);
CommentSystem.playEmojiVideos(contentEl);
}
}
return;
@@ -3356,9 +3159,6 @@ class CommentSystem {
if (target.classList.contains('comment-permalink') || target.closest('.comment-time')) {
const el = target.closest('.comment-permalink, .comment-time');
if (el) {
if (!window.f0ckSession || !window.f0ckSession.logged_in) {
return; // Let standard anchor link behavior scroll/jump to #c{id}
}
const id = el.dataset.id;
const commentEl = el.closest('[id^="c"]');
const body = commentEl ? commentEl.querySelector('.comment-body') : null;
@@ -3411,11 +3211,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 +3303,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 +3329,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 +3348,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;
}
@@ -3684,50 +3437,6 @@ class CommentSystem {
}
}
let resolvedBannerFile = (json.comment?.banner_file && json.comment.banner_file !== 'null') ? json.comment.banner_file : ((session.banner_file && session.banner_file !== 'null') ? session.banner_file : null);
let resolvedBannerPosition = (json.comment?.banner_position && json.comment.banner_position !== 'null') ? json.comment.banner_position : ((session.banner_position && session.banner_position !== 'null') ? session.banner_position : null);
let resolvedBannerSize = (json.comment?.banner_size && json.comment.banner_size !== 'null') ? json.comment.banner_size : ((session.banner_size && session.banner_size !== 'null') ? session.banner_size : null);
let resolvedBannerRepeat = (json.comment?.banner_repeat && json.comment.banner_repeat !== 'null') ? json.comment.banner_repeat : ((session.banner_repeat && session.banner_repeat !== 'null') ? session.banner_repeat : null);
if (!resolvedBannerFile && this.lastData && currentUsername) {
const existingByMe = this.lastData.find(c =>
c.username && c.username.toLowerCase() === currentUsername.toLowerCase() && c.banner_file && c.banner_file !== 'null'
);
if (existingByMe) {
resolvedBannerFile = existingByMe.banner_file || null;
resolvedBannerPosition = existingByMe.banner_position || null;
resolvedBannerSize = existingByMe.banner_size || null;
resolvedBannerRepeat = existingByMe.banner_repeat || null;
}
}
if (!resolvedBannerFile && currentUsername) {
const existingCommentEl = document.querySelector(`.comment-author[href="/user/${currentUsername}"], .user-infobox-block[style*="--author-banner"]`);
if (existingCommentEl) {
const commentDiv = existingCommentEl.closest('.comment') || existingCommentEl.closest('.user-infobox-block');
if (commentDiv) {
const style = commentDiv.getAttribute('style') || '';
const bannerMatch = style.match(/--author-banner:\s*url\(['"]?\/a\/([^'"]+)['"]?\)/);
if (bannerMatch) {
resolvedBannerFile = bannerMatch[1];
const posMatch = style.match(/--author-banner-position:\s*([^;]+)/);
if (posMatch) resolvedBannerPosition = posMatch[1].trim();
const sizeMatch = style.match(/--author-banner-size:\s*([^;]+)/);
if (sizeMatch) resolvedBannerSize = sizeMatch[1].trim();
const repeatMatch = style.match(/--author-banner-repeat:\s*([^;]+)/);
if (repeatMatch) resolvedBannerRepeat = repeatMatch[1].trim();
}
}
}
}
if (resolvedBannerFile && window.f0ckSession) {
window.f0ckSession.banner_file = resolvedBannerFile;
if (resolvedBannerPosition) window.f0ckSession.banner_position = resolvedBannerPosition;
if (resolvedBannerSize) window.f0ckSession.banner_size = resolvedBannerSize;
if (resolvedBannerRepeat) window.f0ckSession.banner_repeat = resolvedBannerRepeat;
}
const newComment = {
id: json.comment.id,
item_id: this.itemId,
@@ -3736,15 +3445,11 @@ class CommentSystem {
files: files,
created_at: json.comment.created_at || new Date().toISOString(),
username: currentUsername,
user_id: (json.comment && json.comment.user_id) || session.id || session.user_id || null,
user_id: session.id || null,
display_name: session.display_name || null,
avatar: resolvedAvatar,
avatar_file: resolvedAvatarFile,
username_color: (json.comment && json.comment.username_color) || session.username_color || null,
banner_file: resolvedBannerFile,
banner_position: resolvedBannerPosition,
banner_size: resolvedBannerSize,
banner_repeat: resolvedBannerRepeat,
username_color: session.username_color || null,
is_deleted: false,
is_pinned: false,
video_time: json.comment.video_time ?? null,
@@ -3753,11 +3458,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 +3466,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 || '?',
@@ -3777,7 +3476,7 @@ class CommentSystem {
if (!this.lastData) this.lastData = [];
const existingInLastData = this.lastData.find(c => c.id === newComment.id);
if (existingInLastData) {
Object.assign(existingInLastData, newComment);
existingInLastData.files = files;
} else {
if (this.sort === 'new') this.lastData.unshift(newComment);
else this.lastData.push(newComment);
@@ -3877,10 +3576,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 +3591,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);
}
}
};
@@ -3904,9 +3603,9 @@ class CommentSystem {
// element whenever the raw content exceeds ITEM_VIEW_MAX_CHARS. Called after every
// optimistic DOM insert to guarantee the button regardless of rendering path.
_ensureTruncationButton(commentEl, rawContent) {
if (!rawContent) return;
const lineCount = rawContent.split('\n').length;
if (rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS && lineCount <= CommentSystem.ITEM_VIEW_MAX_LINES) {
console.log('[ensureBtn] called, rawContent.length:', rawContent?.length, 'threshold:', CommentSystem.ITEM_VIEW_MAX_CHARS);
if (!rawContent || rawContent.length <= CommentSystem.ITEM_VIEW_MAX_CHARS) {
console.log('[ensureBtn] below threshold, skipping');
return;
}
const contentEl = commentEl.querySelector('.comment-content');
@@ -3924,10 +3623,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 +3726,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 +3807,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 +3822,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 +4592,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 +4615,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 +4680,9 @@ class CommentSystem {
}
container.appendChild(picker);
revealPicker();
requestAnimationFrame(() => requestAnimationFrame(() =>
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
));
});
}
@@ -5136,44 +4702,3 @@ document.addEventListener('f0ck:contentLoaded', () => {
// If f0ck.js uses custom navigation without valid events, we might need MutationObserver or hook into `getContent`
// Looking at f0ck.js, it seems to just replace innerHTML.
(function initCommentHeightObserver() {
if (typeof window === 'undefined') return;
if (window.ResizeObserver) {
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
const height = entry.contentRect ? entry.contentRect.height : entry.target.offsetHeight;
if (height > 90) {
entry.target.classList.add('tall-comment');
} else {
entry.target.classList.remove('tall-comment');
}
}
});
const observeAll = (root = document) => {
root.querySelectorAll('.comment').forEach(el => observer.observe(el));
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => observeAll());
} else {
observeAll();
}
if (window.MutationObserver) {
const mutObs = new MutationObserver(mutations => {
for (const m of mutations) {
m.addedNodes.forEach(node => {
if (node.nodeType === 1) {
if (node.classList && node.classList.contains('comment')) observer.observe(node);
if (node.querySelectorAll) node.querySelectorAll('.comment').forEach(el => observer.observe(el));
}
});
}
});
mutObs.observe(document.body, { childList: true, subtree: true });
}
}
})();
+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();
});
}
+1211 -15562
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) {
+905 -302
View File
File diff suppressed because it is too large Load Diff
-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();
+24 -347
View File
@@ -137,13 +137,10 @@
const statusDiv = document.getElementById('avatar-upload-status');
const preview = document.getElementById('avatar-preview');
const showStatus = (msg, type = 'info') => {
if (msg && window.flashMessage) {
window.flashMessage(msg, 2500, type);
}
const showStatus = (msg, type) => {
if (statusDiv) {
statusDiv.textContent = msg;
statusDiv.className = 'avatar-status ' + (type || '');
statusDiv.className = 'avatar-status ' + type;
}
};
@@ -151,10 +148,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];
@@ -183,7 +177,6 @@
filenameSpan.textContent = file.name;
uploadBtn.disabled = false;
showStatus('', '');
uploadBtn.click();
});
}
@@ -197,24 +190,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 +201,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 +232,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 +254,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 +650,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 +667,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 +675,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 +706,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 +758,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');
@@ -1200,97 +1119,6 @@
});
}
const favsPrivateToggle = document.getElementById('favorites_private_toggle');
if (favsPrivateToggle) {
favsPrivateToggle.addEventListener('change', async () => {
const favorites_private = favsPrivateToggle.checked;
try {
const res = await fetch('/api/v2/settings/favorites_private', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ favorites_private })
});
const data = await res.json();
if (data.success) {
showStatus('Favorites privacy preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.favorites_private = favorites_private;
}
} else {
alert(data.msg || 'Error saving preference');
favsPrivateToggle.checked = !favorites_private; // Revert
}
} catch (err) {
console.error('Update Favorites Privacy error:', err);
alert('Connection error');
favsPrivateToggle.checked = !favorites_private; // Revert
}
});
}
const hideFavBadgeToggle = document.getElementById('hide_fav_badge_toggle');
if (hideFavBadgeToggle) {
hideFavBadgeToggle.addEventListener('change', async function() {
const hide_fav_badge = hideFavBadgeToggle.checked;
try {
const res = await fetch('/api/v2/settings/hide_fav_badge', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ hide_fav_badge })
});
const data = await res.json();
if (data.success) {
showStatus('Hide favorite badge preference updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.hide_fav_badge = hide_fav_badge;
}
} else {
alert(data.msg || 'Error saving preference');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
} catch (err) {
console.error('Update Hide Fav Badge error:', err);
alert('Connection error');
hideFavBadgeToggle.checked = !hide_fav_badge;
}
});
}
const defaultUploadVisSelect = document.getElementById('default_upload_visibility_select');
if (defaultUploadVisSelect) {
defaultUploadVisSelect.addEventListener('change', async function() {
const vis = parseInt(defaultUploadVisSelect.value, 10);
try {
const res = await fetch('/api/v2/settings/default_upload_visibility', {
method: 'PUT',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRF-Token': window.f0ckSession ? window.f0ckSession.csrf_token : ''
},
body: new URLSearchParams({ default_upload_visibility: vis })
});
const data = await res.json();
if (data.success) {
showStatus('Default upload visibility updated!', 'success');
if (window.f0ckSession) {
window.f0ckSession.default_upload_visibility = vis;
}
} else {
alert(data.msg || 'Error saving preference');
}
} catch (err) {
console.error('Update Default Upload Visibility error:', err);
alert('Connection error');
}
});
}
// New Dual Column Layout Toggle
const layoutToggle = document.getElementById('use_new_layout_toggle');
if (layoutToggle) {
@@ -1575,7 +1403,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 +1460,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) {
@@ -1823,11 +1583,8 @@
// Two-way sync: swatch → text input
if (colorPicker && colorHex) {
const colorBadge = document.getElementById('color-picker-badge');
colorPicker.addEventListener('input', () => {
colorHex.value = colorPicker.value;
if (colorBadge) colorBadge.style.background = colorPicker.value;
const liveBox = document.getElementById('live-profile-preview-box');
if (liveBox) {
liveBox.style.setProperty('--author-accent', colorPicker.value);
@@ -1835,11 +1592,6 @@
}
});
colorPicker.addEventListener('change', () => {
if (colorBadge) colorBadge.style.background = colorPicker.value;
if (saveColorBtn) saveColorBtn.click();
});
// Text input → swatch (validate on each keystroke)
colorHex.addEventListener('input', () => {
const val = colorHex.value.trim();
@@ -2009,9 +1761,7 @@
const data = await res.json();
if (data.success) {
await applyFontLive(font);
if (window.flashMessage) {
window.flashMessage('Website font updated!', 2500, 'success');
}
showStatus('Website font updated!', 'success');
} else {
alert(data.msg || 'Error saving font preference');
}
@@ -2027,46 +1777,6 @@
const saveDescriptionBtn = document.getElementById('btn-save-description');
const descriptionStatus = document.getElementById('description-status');
const liveDesc = document.getElementById('live-preview-description');
const descControls = document.getElementById('desc-edit-controls');
// 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';
});
liveDesc.addEventListener('input', () => {
descriptionTextarea.value = liveDesc.innerText.trim();
});
liveDesc.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
liveDesc.blur();
if (saveDescriptionBtn) saveDescriptionBtn.click();
}
});
}
if (descriptionTextarea) {
descriptionTextarea.addEventListener('input', () => {
if (liveDesc && document.activeElement !== liveDesc) {
liveDesc.textContent = descriptionTextarea.value || '';
}
});
}
if (saveDescriptionBtn && descriptionTextarea) {
saveDescriptionBtn.addEventListener('click', async () => {
const description = descriptionTextarea.value;
@@ -2147,13 +1857,10 @@
const emailStatus = document.getElementById('email-status');
const displayEmail = document.getElementById('display-email');
const showAccountStatus = (el, msg, type = 'info') => {
if (msg && window.flashMessage) {
window.flashMessage(msg, 2500, type);
}
const showAccountStatus = (el, msg, type) => {
if (el) {
el.textContent = msg;
el.className = 'avatar-status ' + (type || '');
el.className = 'avatar-status ' + type;
if (type === 'success') {
setTimeout(() => { el.textContent = ''; el.className = 'avatar-status'; }, 5000);
}
@@ -2163,9 +1870,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 +1880,6 @@
}
const btn = passwordForm.querySelector('button');
const btnLabel = btn.textContent;
btn.disabled = true;
btn.textContent = 'Updating...';
@@ -2192,8 +1896,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 +1903,7 @@
showAccountStatus(passwordStatus, 'Request failed', 'error');
} finally {
btn.disabled = false;
btn.textContent = btnLabel;
btn.textContent = 'Update Password';
}
});
}
@@ -2244,36 +1946,11 @@
const displayNameBtn = document.getElementById('btn-update-display-name');
const displayNameInput = document.getElementById('display_name_input');
const displayNameStatus = document.getElementById('display-name-status');
const liveUsernameEl = document.getElementById('live-preview-username');
if (liveUsernameEl && displayNameInput && displayNameBtn) {
liveUsernameEl.addEventListener('input', () => {
displayNameInput.value = liveUsernameEl.textContent.trim();
});
liveUsernameEl.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
liveUsernameEl.blur();
}
});
liveUsernameEl.addEventListener('blur', () => {
const val = liveUsernameEl.textContent.trim();
if (!val) {
liveUsernameEl.textContent = window.f0ckSession?.user || 'Username';
displayNameInput.value = window.f0ckSession?.user || '';
} else {
displayNameInput.value = val;
}
displayNameBtn.click();
});
}
if (displayNameBtn && displayNameInput) {
displayNameInput.addEventListener('input', () => {
if (liveUsernameEl && document.activeElement !== liveUsernameEl) {
liveUsernameEl.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
const liveName = document.getElementById('live-preview-username');
if (liveName) {
liveName.textContent = displayNameInput.value.trim() || window.f0ckSession?.user || 'Username';
}
});
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 };
})();
+78 -777
View File
File diff suppressed because it is too large Load Diff
+77 -410
View File
@@ -1,26 +1,14 @@
(async () => {
// Helper to get dynamic context from the DOM
const getContext = () => {
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const 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);
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
return {
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
subf0ck_id,
postid: +idLink.innerText,
poster: document.querySelector("a#a_username")?.innerText,
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
};
};
@@ -52,16 +40,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 +49,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 +77,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 +112,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 +121,92 @@
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
});
if (res.success) {
// New state is the logical opposite of what it was before the API call
const isNowFav = !wasAlreadyFav;
// 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 (favoBtn) {
favoBtn.classList.toggle('fa-solid', isNowFav);
favoBtn.classList.toggle('fa-regular', !isNowFav);
}
}
const mySeq = ++favSeq;
// 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');
// 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 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;
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'));
}
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
+3 -13
View File
@@ -306,12 +306,7 @@ if (!window.UserCommentSystem) {
const trimmed = line.trimStart();
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`;
return `<span class="greentext">&gt;${quoteContent.replace(/>/g, '&gt;')}</span>`;
}
// Per-line limit
@@ -410,16 +405,11 @@ if (!window.UserCommentSystem) {
}
renderComment(c) {
const itemKey = c.item_slug || c.slug || c.item_id;
const timeAgo = this.timeAgo(c.created_at);
const fullDate = new Date(c.created_at).toISOString();
const content = this.renderCommentContent(c.content, itemKey);
const content = this.renderCommentContent(c.content, c.item_id);
const bannerStyle = (c.banner_file && c.banner_file !== 'null')
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
: '';
return `<div class="comment" id="c${c.id}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions"><button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button></div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${c.item_id}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
}
startLiveTimestamps() {
+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);
}
});
}
-529
View File
@@ -1,529 +0,0 @@
import fs from "fs";
import path from "path";
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
import cfg from "../src/inc/config.mjs";
import queue from "../src/inc/queue.mjs";
import f0cklib from "../src/inc/routeinc/f0cklib.mjs";
import { getBypassDuplicateCheck } from "../src/inc/settings.mjs";
// Helper for parsing CLI flags
function parseArgs() {
const args = process.argv.slice(2);
const options = {
user: null,
rating: null,
tags: [],
dir: cfg.paths.import || path.resolve("f0ckm-data/import"),
keep: false,
bypass: null, // null means use config default, true = force bypass, false = force check
is_oc: false,
title: null,
help: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--user' || arg === '-u') {
options.user = args[++i];
} else if (arg === '--rating' || arg === '-r') {
options.rating = args[++i]?.toLowerCase();
} else if (arg === '--tags' || arg === '-t') {
const rawTags = args[++i];
if (rawTags) {
options.tags = rawTags.split(',').map(t => t.trim()).filter(Boolean);
}
} else if (arg === '--dir' || arg === '-d') {
options.dir = path.resolve(args[++i]);
} else if (arg === '--keep' || arg === '-k') {
options.keep = true;
} else if (arg === '--bypass' || arg === '-b') {
options.bypass = true;
} else if (arg === '--check') {
options.bypass = false;
} else if (arg === '--oc') {
options.is_oc = true;
} else if (arg === '--title') {
options.title = args[++i];
}
}
return options;
}
function showHelp() {
console.log(`
f0ckm Bulk Import Script
========================
Imports all media files in f0ckm-data/import (or custom directory) into f0ckm.
Usage:
node scripts/import.mjs [options]
Options:
--user, -u <username> User to attribute uploads to (defaults to first admin)
--rating, -r <sfw|nsfw|nsfl|untagged> Default rating for imported items
--tags, -t <tag1,tag2> Global tags to assign to imported items
--dir, -d <path> Directory to scan (default: f0ckm-data/import)
--keep, -k Keep source files in import dir after import (default: delete)
--bypass, -b Force bypass of duplicate checksum/pHash checks
--check Force duplicate checking even if config enables bypass
--oc Mark all imported files as Original Content (OC)
--title <title> Set title for imported files
--help, -h Show this help screen
Folder & Sidecar JSON Features:
- Rating Subfolders: Files in 'import/sfw/', 'import/nsfw/', or 'import/nsfl/' automatically receive that rating.
- Sidecar Metadata: Place a file.jpg.json or file.json alongside file.jpg to supply metadata:
{ "title": "My Post", "rating": "sfw", "tags": ["funny", "cat"], "comment": "First comment", "is_oc": true }
`);
}
// Derive archive MIME types from cfg.mimes
const ARCHIVE_MIMES = new Set(
Object.entries(cfg.mimes || {})
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
.map(([mime]) => mime)
);
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
// Recursively collect all target files in directory (follows symlinked dirs and files)
async function collectFiles(dir, visitedDirs = new Set()) {
const results = [];
let canonicalDir;
try {
canonicalDir = await fs.promises.realpath(dir);
} catch (_) {
canonicalDir = dir;
}
if (visitedDirs.has(canonicalDir)) {
return results; // Avoid infinite loops on circular symlink references
}
visitedDirs.add(canonicalDir);
let entries;
try {
entries = await fs.promises.readdir(dir);
} catch (e) {
console.warn(`[IMPORT] Warning: unable to read directory '${dir}':`, e.message);
return results;
}
for (const name of entries) {
if (name.startsWith('.')) continue; // skip hidden files & .gitkeep
const fullPath = path.join(dir, name);
try {
// fs.promises.stat follows symlinks to inspect the underlying file/dir
const st = await fs.promises.stat(fullPath);
if (st.isDirectory()) {
const subResults = await collectFiles(fullPath, visitedDirs);
results.push(...subResults);
} else if (st.isFile()) {
if (name.endsWith('.json')) continue; // skip sidecar metadata files
results.push(fullPath);
}
} catch (err) {
console.warn(`[IMPORT] Warning: unable to stat '${fullPath}' (broken symlink?):`, err.message);
}
}
return results;
}
// Read optional sidecar JSON metadata
async function readSidecarMetadata(filePath) {
const candidates = [
`${filePath}.json`,
path.join(path.dirname(filePath), `${path.parse(filePath).name}.json`)
];
for (const cand of candidates) {
try {
if (fs.existsSync(cand)) {
const raw = await fs.promises.readFile(cand, 'utf-8');
return { meta: JSON.parse(raw), sidecarPath: cand };
}
} catch (e) {
console.warn(`[IMPORT] Warning: failed to parse sidecar JSON at ${cand}:`, e.message);
}
}
return { meta: {}, sidecarPath: null };
}
async function runImport() {
const opts = parseArgs();
if (opts.help) {
showHelp();
process.exit(0);
}
console.log(`--- f0ckm Bulk Import Starting ---`);
console.log(`Scan Directory: ${opts.dir}`);
if (!fs.existsSync(opts.dir)) {
console.error(`[IMPORT ERROR] Directory does not exist: ${opts.dir}`);
process.exit(1);
}
// 1. Resolve Target User
let targetUser = null;
if (opts.user) {
const userRows = await db`
SELECT id, "user", login, admin, is_moderator
FROM "user"
WHERE LOWER(login) = ${opts.user.toLowerCase()} OR LOWER("user") = ${opts.user.toLowerCase()}
LIMIT 1
`;
if (userRows.length > 0) {
targetUser = userRows[0];
} else {
console.error(`[IMPORT ERROR] Specified user '${opts.user}' not found in database.`);
process.exit(1);
}
} else {
const adminRows = await db`
SELECT id, "user", login, admin, is_moderator
FROM "user"
WHERE admin = true
ORDER BY id ASC
LIMIT 1
`;
if (adminRows.length > 0) {
targetUser = adminRows[0];
} else {
const anyUser = await db`SELECT id, "user", login, admin, is_moderator FROM "user" ORDER BY id ASC LIMIT 1`;
if (anyUser.length > 0) {
targetUser = anyUser[0];
} else {
console.error(`[IMPORT ERROR] No users found in database. Please run npm run create-admin first.`);
process.exit(1);
}
}
}
console.log(`Attributing uploads to user: ${targetUser.user} (ID: ${targetUser.id})`);
// Ensure target directories exist
await fs.promises.mkdir(cfg.paths.b, { recursive: true });
await fs.promises.mkdir(cfg.paths.t, { recursive: true });
await fs.promises.mkdir(cfg.paths.ca, { recursive: true });
// 2. Discover files
const fileList = await collectFiles(opts.dir);
if (fileList.length === 0) {
console.log(`[IMPORT] No files found in ${opts.dir}. Place files in '${opts.dir}' and re-run.`);
process.exit(0);
}
console.log(`Found ${fileList.length} file(s) to process.\n`);
const allowedCats = Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.map(c => c.toLowerCase()) : null;
const allowedMimes = allowedCats
? Object.keys(cfg.mimes).filter(m => allowedCats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)))
: Object.keys(cfg.mimes);
let successCount = 0;
let skippedCount = 0;
let failedCount = 0;
// 3. Process each file sequentially
for (let index = 0; index < fileList.length; index++) {
const filePath = fileList[index];
const baseName = path.basename(filePath);
const relPath = path.relative(opts.dir, filePath);
const prefix = `[${index + 1}/${fileList.length}]`;
try {
// Check sidecar JSON metadata
const { meta: sidecarMeta, sidecarPath } = await readSidecarMetadata(filePath);
// Determine Rating: Sidecar -> CLI option -> Folder name (import/sfw/ etc.) -> Default
let effectiveRating = sidecarMeta.rating || opts.rating;
if (!effectiveRating) {
const parentDirName = path.basename(path.dirname(filePath)).toLowerCase();
if (['sfw', 'nsfw', 'nsfl', 'untagged'].includes(parentDirName)) {
effectiveRating = parentDirName;
}
}
if (effectiveRating === 'untagged') effectiveRating = null;
if (!effectiveRating && !cfg.websrv.shitpost_mode) {
effectiveRating = 'sfw'; // Default fallback if rating required
}
// Determine Title & Comments & OC
const effectiveTitle = sidecarMeta.title || opts.title || null;
const effectiveComment = sidecarMeta.comment || null;
const effectiveIsOc = (sidecarMeta.is_oc !== undefined) ? !!sidecarMeta.is_oc : opts.is_oc;
const effectiveVisibility = (typeof sidecarMeta.visibility === 'number') ? sidecarMeta.visibility : 0;
// Collect tags
let mergedTags = [...opts.tags];
if (Array.isArray(sidecarMeta.tags)) {
mergedTags.push(...sidecarMeta.tags);
} else if (typeof sidecarMeta.tags === 'string') {
mergedTags.push(...sidecarMeta.tags.split(',').map(t => t.trim()));
}
mergedTags = mergedTags.filter(t => t && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
// Verify file stats & MIME
const stat = await fs.promises.stat(filePath);
if (stat.size === 0) {
console.warn(`${prefix} [SKIP] ${relPath} (0 bytes)`);
skippedCount++;
continue;
}
// Detect actual MIME via file command (with -L to follow symlinks)
let actualMime = (await queue.spawn('file', ['-L', '--mime-type', '-b', filePath])).stdout.trim();
if ((actualMime === 'application/octet-stream' || !actualMime) && baseName.toLowerCase().endsWith('.swf')) {
actualMime = 'application/x-shockwave-flash';
}
if (!allowedMimes.includes(actualMime)) {
console.warn(`${prefix} [SKIP] ${relPath} - Unsupported MIME type: '${actualMime}'`);
skippedCount++;
continue;
}
// Phase A: SHA-256, PHash, Dimensions, Audio-MP4 probe
const needsPHash = !isArchiveMime(actualMime);
const needsDims = !isArchiveMime(actualMime);
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
const [checksum, phash, dimResult, audioMp4Result] = await Promise.all([
queue.spawn('sha256sum', [filePath]).then(r => r.stdout.trim().split(' ')[0]),
needsPHash ? queue.generatePHash(filePath).catch(e => { console.error(`[IMPORT] PHash error:`, e.message); return null; }) : Promise.resolve(null),
needsDims ? (async () => {
try {
if (actualMime.startsWith('image/')) {
const { stdout: magickOut } = await queue.spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { quiet: true, ignoreExitCode: true });
const match = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
const { stdout: probeOut } = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath], { quiet: true, ignoreExitCode: true });
const dimParts = probeOut.trim().split(',');
if (dimParts.length >= 2) {
const w = parseInt(dimParts[0], 10);
const h = parseInt(dimParts[1], 10);
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
}
}
} catch (_) {}
return null;
})() : Promise.resolve(null),
needsAudioMP4 ? (async () => {
const ext = baseName.split('.').pop().toLowerCase();
if (['m4a', 'aac'].includes(ext)) return 'audio/mp4';
try {
const probe = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath]);
if (!probe.stdout.trim()) return 'audio/mp4';
} catch (_) {}
return null;
})() : Promise.resolve(null)
]);
if (audioMp4Result) {
actualMime = audioMp4Result;
}
// Phase B: Duplicate check
const bypassDupes = (opts.bypass !== null) ? opts.bypass : (getBypassDuplicateCheck() || cfg.websrv.bypass_duplicate_check === true);
if (!bypassDupes) {
const [repostBySum, repostByPhash] = await Promise.all([
queue.checkrepostsum(checksum),
phash ? queue.checkrepostphash(phash) : Promise.resolve(null)
]);
if (repostBySum) {
console.warn(`${prefix} [SKIP] ${relPath} - Duplicate file (matches existing Item #${repostBySum})`);
skippedCount++;
continue;
}
if (repostByPhash) {
console.warn(`${prefix} [SKIP] ${relPath} - Visual duplicate (matches existing Item #${repostByPhash})`);
skippedCount++;
continue;
}
}
// Generate target filename & UUID
const uuid = await queue.genuuid();
const ext = cfg.mimes[actualMime] || 'bin';
const filename = `${uuid}.${ext}`;
const destPath = path.join(cfg.paths.b, filename);
const nowStamp = ~~(Date.now() / 1000);
const itemSlug = lib.generateSlug(11);
// Copy file to public/b
await fs.promises.copyFile(filePath, destPath);
const insertChecksum = bypassDupes ? `${checksum}_bypass_${Date.now()}` : checksum;
// Insert into items table
await db`
INSERT INTO items ${db({
src: '',
dest: filename,
mime: actualMime,
size: stat.size,
checksum: insertChecksum,
phash: phash,
username: targetUser.user,
userchannel: 'import_script',
usernetwork: 'local',
stamp: nowStamp,
active: true,
is_oc: effectiveIsOc,
original_filename: baseName,
title: effectiveTitle,
width: dimResult?.width ?? null,
height: dimResult?.height ?? null,
visibility: effectiveVisibility,
slug: itemSlug,
expires_at: null
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
`;
const itemid = await queue.getItemID(filename);
// Subscribe uploader to comments
try {
await db`
INSERT INTO comment_subscriptions (user_id, item_id)
VALUES (${targetUser.id}, ${itemid})
ON CONFLICT DO NOTHING
`;
} catch (_) {}
// Generate thumbnails
try {
await queue.genThumbnail(filename, actualMime, itemid, '', false, 512);
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
}
} catch (err) {
console.warn(`${prefix} [WARN] Thumbnail generation warning for #${itemid}:`, err.message);
}
// Blurred thumbnail
await queue.genBlurredThumbnail(itemid, false);
// Assign Rating Tag
if (effectiveRating) {
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: targetUser.id })}
`;
}
// Assign Custom Tags
for (const tagName of mergedTags) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
}
const tagId = tagRow[0].id;
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: targetUser.id })}
ON CONFLICT DO NOTHING
`;
}
// Auto OC tags
if (effectiveIsOc) {
for (const tagname of ['oc', 'original content']) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
}
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
ON CONFLICT DO NOTHING
`;
}
}
// Auto Flash tags
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
for (const tagname of ['Flash', 'SWF']) {
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
if (tagRow.length === 0) {
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
}
await db`
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
ON CONFLICT DO NOTHING
`;
}
}
// Optional first comment
if (effectiveComment && effectiveComment.trim().length > 0) {
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: targetUser.id,
content: effectiveComment.trim()
})}
`;
}
// PostgreSQL Live Notification
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: actualMime,
username: targetUser.user,
display_name: targetUser.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!effectiveIsOc
})})`;
} catch (_) {}
// Clear cache
f0cklib.clearCountCache();
// Cleanup imported source file unless --keep was passed
if (!opts.keep) {
await fs.promises.unlink(filePath).catch(() => {});
if (sidecarPath) {
await fs.promises.unlink(sidecarPath).catch(() => {});
}
}
console.log(`${prefix} [SUCCESS] Imported '${relPath}' -> Item #${itemid} (Rating: ${effectiveRating || 'untagged'}, MIME: ${actualMime})`);
successCount++;
} catch (err) {
console.error(`${prefix} [ERROR] Failed to import '${relPath}':`, err.message);
failedCount++;
}
}
console.log(`\n========================================`);
console.log(`Import Complete!`);
console.log(`Total Scanned : ${fileList.length}`);
console.log(`Successful : ${successCount}`);
console.log(`Skipped : ${skippedCount}`);
console.log(`Failed : ${failedCount}`);
console.log(`========================================`);
process.exit(0);
}
runImport().catch(err => {
console.error(`[IMPORT FATAL ERROR]`, err);
process.exit(1);
});
+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
+2 -18
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);
@@ -194,20 +190,15 @@ export const handleBannerUpload = async (req, res) => {
update user_options
set banner_file = ${finalFilename},
banner_position = ${parts.banner_position || 'center'},
banner_size = ${parts.banner_size || 'cover'},
banner_repeat = ${parts.banner_repeat || 'repeat'}
banner_size = ${parts.banner_size || 'cover'}
where user_id = ${+req.session.id}
`;
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
global._invalidateSessionCache(lib.sha256(req.cookies.session));
}
const payloadStr = JSON.stringify({
user_id: +req.session.id,
user: req.session.user,
banner_file: finalFilename,
banner_position: parts.banner_position || 'center',
banner_size: parts.banner_size || 'cover',
banner_repeat: parts.banner_repeat || 'repeat'
banner_size: parts.banner_size || 'cover'
});
await db`select pg_notify('profile_update', ${payloadStr})`;
} catch (dbErr) {
@@ -254,10 +245,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
@@ -286,9 +273,6 @@ export const handleBannerDelete = async (req, res) => {
set banner_file = null
where user_id = ${+req.session.id}
`;
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
global._invalidateSessionCache(lib.sha256(req.cookies.session));
}
const payloadStr = JSON.stringify({
user_id: +req.session.id,
user: req.session.user,
-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 };
+9 -79
View File
@@ -12,23 +12,10 @@ config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.en
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
const isDocker = process.env.DB_HOST === 'f0ckm-db' || config.sql.host === 'f0ckm-db' || process.env.CONTAINER === 'true';
if (process.env.NODE_ENV === 'production' || isDocker) {
if (process.env.NODE_ENV === 'production') {
config.main.development = false;
}
if (config.main.development) {
if (!process.env.DB_HOST && (config.sql.host === 'f0ckm-db' || !config.sql.host)) {
config.sql.host = 'localhost';
}
if (!process.env.DB_PORT && config.sql.port === 5432) {
config.sql.port = 5454;
}
if (!process.env.STORAGE_DIR) {
process.env.STORAGE_DIR = 'f0ckm-data';
}
}
// Set timezone from config if not already set via environment variable
if (!process.env.TZ && config.main.timezone) {
process.env.TZ = config.main.timezone;
@@ -39,42 +26,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 +48,16 @@ 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'),
e: resolvePath('e'),
pending: resolvePath('pending'),
deleted: resolvePath('deleted'),
logs: resolvePath('logs'),
tmp: resolvePath('tmp'),
import: resolveImportPath()
tmp: resolvePath('tmp')
};
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; };
+21 -235
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";
@@ -39,28 +38,7 @@ export default new class {
.replace(/'/g, "&#039;");
}
generateSlug(length = 11) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
let slug = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
slug += chars[bytes[i] % chars.length];
}
return slug;
}
formatSize(size) {
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) {
@@ -75,29 +53,6 @@ export default new class {
const timeStr = t(unitKey, { n: interval });
return t('timeago.ago', { t: timeStr });
};
expiresIn(expiresAt) {
if (!expiresAt) return null;
const expiresNum = parseInt(expiresAt, 10);
if (isNaN(expiresNum)) return null;
const now = ~~(Date.now() / 1000);
const diff = expiresNum - now;
if (diff <= 0) return "expiring now";
if (diff < 60) return `in ${diff}s`;
if (diff < 3600) {
const mins = Math.floor(diff / 60);
return `in ${mins} minute${mins === 1 ? '' : 's'}`;
}
if (diff < 86400) {
const hours = Math.floor(diff / 3600);
const mins = Math.floor((diff % 3600) / 60);
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
}
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
};
md5(str) {
return crypto.createHash('md5').update(str).digest("hex");
};
@@ -107,22 +62,21 @@ export default new class {
getMode(mode) {
let tmp;
mode = Number(mode);
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
switch (mode) {
case 1: // nsfw
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
break;
case 2: // untagged
tmp = `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
break;
case 3: // all
tmp = "1 = 1";
break;
case 4: // nsfl
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${nsflId})` : "1 = 0";
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0";
break;
default: // sfw
tmp = `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`;
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)";
break;
}
return tmp;
@@ -153,8 +107,7 @@ export default new class {
parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`);
}
if (filtered.includes('untagged')) {
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
parts.push(`not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`);
parts.push('not exists (select 1 from tags_assign where item_id = items.id)');
}
if (parts.length === 0) return null;
return '(' + parts.join(' OR ') + ')';
@@ -170,7 +123,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('/'))
@@ -215,28 +167,27 @@ export default new class {
const tagged = +(await db`
select count(*) as total
from "items"
where id in (select item_id from tags_assign group by item_id) and active = true and is_deleted = false
where id in (select item_id from tags_assign group by item_id) and active = true
`)[0].total;
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
const untagged = +(await db`
select count(*) as total
from "items"
where not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})) and active = true and is_deleted = false
where not exists (select 1 from tags_assign where item_id = items.id) and active = true
`)[0].total;
const sfw = +(await db`
select count(*) as total
from "items"
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true and is_deleted = false
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true
`)[0].total;
const nsfw = +(await db`
select count(*) as total
from "items"
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true and is_deleted = false
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true
`)[0].total;
const nsfl = cfg.enable_nsfl ? +(await db`
select count(*) as total
from "items"
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true and is_deleted = false
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true
`)[0].total : 0;
const deleted = +(await db`
select count(*) as total
@@ -295,110 +246,20 @@ 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) {
const tags = await db`
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name
from "tags_assign"
left join "tags" on "tags".id = "tags_assign".tag_id
left join "user" on "user".id = "tags_assign".user_id
left join user_options uo on uo.user_id = "user".id
where "tags_assign".item_id = ${+itemid}
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
`;
for (let t = 0; t < tags.length; t++) {
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 +327,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 +372,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'];
@@ -653,25 +460,4 @@ export default new class {
console.error(`[ERROR REF ${errId}] ${context}:`, err);
return `Internal Error. Reference: ${errId}`;
}
isOnionRequest(req) {
if (!req || !req.headers) return false;
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'] || req.headers['x-forwarded-server'] || '';
if (!rawHost) return false;
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
const firstHost = hostStr.split(',')[0].trim();
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
return hostNoPort.endsWith('.onion');
}
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';
}
};
-66
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.
@@ -205,68 +204,3 @@ export async function moveToDeleted(dest, deletedId) {
await fs.unlink(srcPath).catch(() => {});
}
}
/**
* Periodically scan for and purge expired uploads (expires_at <= now).
* Unlinks media files (thumbnails, coverarts, main image) via safeDeleteMediaFile,
* and sets is_deleted = true, is_purged = true, active = false in DB.
*/
export async function purgeExpiredUploads() {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return;
}
try {
const now = ~~(Date.now() / 1000);
const expiredItems = await db`
SELECT id, dest, mime
FROM items
WHERE expires_at IS NOT NULL
AND expires_at <= ${now}
AND is_purged = false
`;
if (expiredItems.length > 0) {
console.log(`[EXPIRING UPLOADS] Found ${expiredItems.length} expired item(s) to purge.`);
for (const item of expiredItems) {
try {
if (item.dest) {
await safeDeleteMediaFile(item.dest, item.id);
}
await 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);
}
}
}
} catch (err) {
console.error('[EXPIRING UPLOADS] Failed running purgeExpiredUploads check:', err);
}
}
/**
* 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);
}
}
+7 -68
View File
@@ -4,6 +4,7 @@
"meme": "Meme",
"halls": "Hallen",
"tags": "Tags",
"chat": "Chat",
"search": "Suchen",
"random": "Zufall",
"profile": "Profil",
@@ -59,7 +60,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,19 +159,13 @@
"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",
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
"favorites_private": "Private Favoriten",
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
"default_upload_visibility": "Standard Upload-Sichtbarkeit",
"default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.",
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -271,12 +265,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 +272,7 @@
"video": "Video",
"audio": "Audio",
"image": "Bild",
"flash": "Flash",
"grid_mode": "Thumbnailgröße"
"flash": "Flash"
},
"shortcuts": {
"title": "Tastaturkürzel",
@@ -299,8 +286,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 +336,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 +388,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 +455,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 +463,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 +501,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,10 +536,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",
"ban_modal_reason": "Grund:",
@@ -668,8 +637,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 +709,6 @@
"settings": "Einstellungen",
"filters": "Filter",
"volume": "Lautstärke",
"clear_filter": "Filter löschen",
"reset_all": "Alles zurücksetzen",
"rating": "Bewertung",
"all": "Alle",
@@ -829,33 +795,6 @@
"delete_confirm": "Diesen Einladungstoken löschen?",
"slot_refreshes_on": "Slot erneuert sich am {date}",
"slot_refreshed": "Slot erneuert",
"admin_desc": "Du bist Admin, leg los.",
"visibility": {
"public": "Öffentlich",
"unlisted": "Nicht gelistet",
"private": "Privat",
"change_visibility": "Sichtbarkeit ändern"
}
},
"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)"
"admin_desc": "Du bist Admin, leg los."
}
}
+14 -73
View File
@@ -4,6 +4,7 @@
"meme": "Meme",
"halls": "Halls",
"tags": "Tags",
"chat": "Chat",
"search": "Search",
"random": "Random",
"profile": "Profile",
@@ -43,7 +44,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 +60,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,19 +159,13 @@
"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",
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
"disable_swiping": "Disable Swiping",
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
"favorites_private": "Private Favorites",
"favorites_private_hint": "Only you and administrators can view your favorites list.",
"hide_fav_badge": "Hide Favorite Badge Avatar",
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
"default_upload_visibility": "Default Upload Visibility",
"default_upload_visibility_hint": "Set the default visibility level for your new uploads.",
"image_expand_on_click": "Expand images inline on click",
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
"enable_bg_blur": "Enable Background blur",
@@ -182,8 +176,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 +265,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 +272,7 @@
"video": "Video",
"audio": "Audio",
"image": "Image",
"flash": "Flash",
"grid_mode": "Thumbnail size"
"flash": "Flash"
},
"shortcuts": {
"title": "Keyboard Shortcuts",
@@ -299,8 +286,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 +336,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 +391,7 @@
"view": "View",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"dismiss": "Dismiss"
"edit": "Edit"
},
"mod": {
"confirm_action": "Confirm Action",
@@ -467,7 +450,7 @@
},
"report": {
"title": "Submit Report",
"placeholder": "",
"placeholder": "Please provide details for this report (required)...",
"submit": "Submit"
},
"account_warning": {
@@ -476,10 +459,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 +467,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 +496,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 +505,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 +515,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,10 +540,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",
"ban_modal_reason": "Reason:",
@@ -670,8 +641,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 +711,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",
@@ -829,33 +797,6 @@
"delete_confirm": "Delete this invite token?",
"slot_refreshes_on": "slot refreshes on {date}",
"slot_refreshed": "slot refreshed",
"admin_desc": "You are an admin, go ahead.",
"visibility": {
"public": "Public",
"unlisted": "Unlisted",
"private": "Private",
"change_visibility": "Change Visibility"
}
},
"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)"
"admin_desc": "You are an admin, go ahead."
}
}
+7 -60
View File
@@ -4,6 +4,7 @@
"meme": "meme",
"halls": "Hallen",
"tags": "Tags",
"chat": "Chat",
"search": "Zoeken",
"random": "Willekeurig",
"profile": "profiel",
@@ -59,7 +60,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,17 +159,13 @@
"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",
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
"disable_swiping": "Swipen uitschakelen",
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
"favorites_private": "Privé favorieten",
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
"hide_fav_badge": "Favoriet-badge avatar verbergen",
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
"enable_bg_blur": "Achtergrondvervaging inschakelen",
@@ -269,12 +265,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 +272,7 @@
"video": "Video",
"audio": "Audio",
"image": "Afbeelding",
"flash": "Flash",
"grid_mode": "Thumbnailgrootte"
"flash": "Flash"
},
"shortcuts": {
"title": "Sneltoetsen",
@@ -297,8 +286,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 +336,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 +388,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 +455,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 +463,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 +501,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 +511,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,10 +536,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",
"ban_modal_reason": "Reden:",
@@ -666,8 +637,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 +707,6 @@
"settings": "Instellingen",
"filters": "Filters",
"volume": "Volume",
"clear_filter": "Filter wissen",
"reset_all": "Alles resetten",
"rating": "Beoordeling",
"all": "Alles",
@@ -826,26 +794,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)"
}
}
+7 -57
View File
@@ -4,6 +4,7 @@
"meme": "Memen",
"halls": "Hallen",
"tags": "Etiketten",
"chat": "Geschwafel",
"search": "Suche",
"random": "Zufall",
"profile": "Profil",
@@ -59,7 +60,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,17 +159,13 @@
"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",
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
"disable_swiping": "Wischen deaktivieren",
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
"favorites_private": "Private Favoriten",
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
@@ -267,12 +263,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 +270,7 @@
"video": "Video",
"audio": "Tondatei",
"image": "Bild",
"flash": "Blitz",
"grid_mode": "Größe der Daumennägel"
"flash": "Blitz"
},
"shortcuts": {
"title": "Tastaturkürzel",
@@ -295,8 +284,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 +334,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 +388,7 @@
"view": "Ansehen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Editieren",
"dismiss": "Weg damit"
"edit": "Editieren"
},
"mod": {
"confirm_action": "Aktion bestätigen",
@@ -471,9 +456,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 +464,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 +502,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 +512,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,10 +537,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",
"ban_modal_reason": "Grund:",
@@ -664,8 +638,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 +710,6 @@
"settings": "Einstellungen",
"filters": "Filter",
"volume": "Lautstärke",
"clear_filter": "Filter entfernen",
"reset_all": "Alles zurücksetzen",
"rating": "Bewertung",
"all": "Alle",
@@ -826,26 +797,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);
};
+282 -2472
View File
File diff suppressed because it is too large Load Diff
+93 -832
View File
File diff suppressed because it is too large Load Diff
+19 -437
View File
@@ -1,298 +1,10 @@
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) => {
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
const tAjaxStart = Date.now();
let query = {};
if (typeof req.url === 'string') {
@@ -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,20 +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 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 = 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 itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
const data = await f0cklib.getf0ck({
itemid: itemid,
mode: reqMode,
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
bypass_filter: bypassFilter,
session: req.session,
session: !!req.session,
url: contextUrl,
user: query.user,
tag: query.tag,
@@ -351,31 +49,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 +80,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 +136,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,65 +164,13 @@ 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({
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
id: itemid
})
});
});
@@ -595,7 +194,7 @@ export default (router, tpl) => {
// Infinite scroll endpoint for index thumbnails
router.get(/^\/ajax\/items/, async (req, res) => {
router.get(/\/ajax\/items/, async (req, res) => {
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
@@ -604,11 +203,10 @@ 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 ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const data = await f0cklib.getf0cks({
page: page,
@@ -618,15 +216,12 @@ 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,
ratings: ratingsArr,
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
session: !!req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
fav: query.fav === 'true',
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',
@@ -636,19 +231,6 @@ export default (router, tpl) => {
});
if (!data.success) {
if (data.is_private) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const privateHtml = `<div class="private-favs-msg" style="padding: 30px; text-align: center; color: var(--text-muted); font-size: 1.1em;">${tErr('profile.private_favorites')}</div>`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
is_private: true,
html: privateHtml,
hasMore: false
})
});
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
-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
+31 -555
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);
@@ -369,70 +315,8 @@ export default router => {
}
});
// Update Favorites Privacy preference
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
try {
await db`
update user_options
set favorites_private = ${favorites_private}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.favorites_private = favorites_private;
return res.json({ success: true, favorites_private }, 200);
} catch (e) {
console.error('Update Favorites Privacy pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Hide Fav Badge preference
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
try {
await db`
update user_options
set hide_fav_badge = ${hide_fav_badge}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
return res.json({ success: true, hide_fav_badge }, 200);
} catch (e) {
console.error('Update Hide Fav Badge pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.registeredUser, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
const vis = parseInt(req.post.default_upload_visibility, 10);
if (isNaN(vis) || ![0, 1, 2].includes(vis)) {
return res.json({ success: false, msg: 'Invalid visibility option' }, 400);
}
try {
await db`
update user_options
set default_upload_visibility = ${vis}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.default_upload_visibility = vis;
return res.json({ success: true, default_upload_visibility: vis }, 200);
} catch (e) {
console.error('Update Default Upload Visibility pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Username Color preference
group.put(/\/username_color/, lib.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 +346,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 +357,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 +388,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 +411,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 +444,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 +773,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 +803,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 +831,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 +855,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 +932,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 +1012,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 +1078,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 +1105,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)
});
});
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
});
})
});
});
+19 -169
View File
@@ -3,13 +3,9 @@ import { spawn as _spawnRaw } from 'child_process';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs, canAnonDo, isAnonSession } 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)
@@ -141,71 +137,6 @@ const parseMultipart = (buffer, boundary) => {
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
const getTargetVisibility = (req, postVis) => {
if (cfg.enable_private_uploads === false) return 0;
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
? cfg.default_upload_visibility
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
if (!allowUserOverride) {
return sysDefault;
}
const rawHeader = req.headers ? req.headers['x-upload-visibility'] : null;
const val = (rawHeader || postVis || '').toString().trim().toLowerCase();
if (val === 'private' || val === '2') return 2;
if (val === 'unlisted' || val === '1') return 1;
if (val === 'public' || val === '0') return 0;
return (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
? req.session.default_upload_visibility
: sysDefault;
};
export function calculateExpiresAt(expiryVal, baseStamp = ~~(Date.now() / 1000)) {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return null;
}
if (!expiryVal || expiryVal === 'permanent' || expiryVal === 'never' || expiryVal === '0') {
return null;
}
const val = expiryVal.toString().trim().toLowerCase();
switch (val) {
case '30minutes':
case '30m':
case '1800':
return baseStamp + 1800;
case '1hour':
case '1h':
case '3600':
return baseStamp + 3600;
case '24hours':
case '24h':
case '1day':
case '86400':
return baseStamp + 86400;
case '1week':
case '1w':
case '7days':
case '604800':
return baseStamp + 604800;
case '1month':
case '1m':
case '30days':
case '2592000':
return baseStamp + 2592000;
default:
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
return parsed > 2000000000 ? parsed : baseStamp + parsed;
}
return null;
}
}
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
@@ -233,21 +164,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 +239,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 +290,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);
@@ -450,11 +369,6 @@ export default router => {
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
const filename = `yt:${videoId}`;
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = lib.generateSlug(11);
const nowStamp = ~~(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db`
insert into items ${db({
src: ytUrl,
@@ -466,27 +380,18 @@ export default router => {
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: nowStamp,
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
RETURNING id
`;
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 {
@@ -524,28 +429,6 @@ export default router => {
}
}
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!isApprovalRequired) {
f0cklib.clearCountCache();
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
dest: filename,
mime: 'video/youtube',
username: req.session.user,
display_name: req.session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility,
is_album: false,
album_count: 0
})})`;
} catch (err) {
console.error('[UPLOAD-URL] YouTube new_item notify failed:', err);
}
}
return res.json({
success: true,
msg: isApprovalRequired ? 'YouTube video embedded! Pending admin approval.' : 'YouTube video embedded!',
@@ -554,9 +437,6 @@ export default router => {
});
} else {
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = lib.generateSlug(11);
const session = {
id: req.session.id,
user: req.session.user,
@@ -735,13 +615,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(() => {});
@@ -801,8 +674,6 @@ export default router => {
await fs.unlink(source).catch(() => { });
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
const nowStamp = ~~(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db`
insert into items ${db({
@@ -815,41 +686,24 @@ export default router => {
username: session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: nowStamp,
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
title: title
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title')}
RETURNING id
`;
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)
@@ -881,19 +735,15 @@ export default router => {
username: session.user,
display_name: session.display_name || null,
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
is_oc: !!is_oc,
slug: itemSlug,
visibility: targetVisibility,
is_album: false,
album_count: 0
is_oc: !!is_oc
})})`;
} catch (err) {
console.error('[UPLOAD-URL] new_item notify failed:', err);
}
}
// Push to Matrix Channel (only if auto-approved and public)
if (!isApprovalRequired && targetVisibility === 0) {
// Push to Matrix Channel (only if auto-approved)
if (!isApprovalRequired) {
try {
const self = router.self;
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
+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;
};
+20 -1
View File
@@ -47,6 +47,25 @@ function buildBgHostRegex() {
export default (router, tpl) => {
// GET /chat — Dedicated site chat page
router.get('/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: 'Not found' });
}
if (!req.session) {
return res.redirect('/login');
}
if (req.session.banned) {
return res.reply({ code: 403, body: 'Access denied: You are banned' });
}
return res.html(tpl.render('chat', {
session: req.session,
hidePagination: true,
link: { main: '/chat', path: '/chat' },
domain: cfg.main.url.domain
}, req));
});
// GET /api/chat — fetch recent messages
router.get('/api/chat', async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
@@ -208,7 +227,7 @@ export default (router, tpl) => {
});
// DELETE /api/chat/:id — admin: delete a single message
router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}
+25 -156
View File
@@ -1,18 +1,16 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { promises as fs } from "fs";
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,25 +62,14 @@ 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,
user_id: req.session ? req.session.user : null,
is_admin: req.session ? (req.session.admin || req.session.is_moderator) : false
})
})
@@ -134,9 +121,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,16 +155,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 globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
/* <mode-override> */
// prioritize query mode (from AJAX) over session default
@@ -196,16 +171,16 @@ export default (router, tpl) => {
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
// Build mode SQL — replace items.id alias with i.id used in the activity query
const modequery = f0cklib.computeBaseMode(mode, ratingsArr, req.session).replace(/items\.id/g, 'i.id');
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
const comments = await db`
SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug
SELECT c.*, i.mime, i.id as item_id
FROM comments c
LEFT JOIN items i ON c.item_id = i.id
WHERE c.user_id = ${userId} AND c.is_deleted = false
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}
@@ -233,7 +208,6 @@ export default (router, tpl) => {
let processedComments = mentionsProcessed.map(c => {
return {
...c,
item_slug: getEnableItemSlugs() ? c.item_slug : null,
content: c.content
};
});
@@ -398,14 +372,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 +418,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 +433,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 || '';
@@ -612,12 +570,7 @@ export default (router, tpl) => {
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
const itemQuery = await db`
SELECT
i.slug,
i.mime,
i.dest,
i.has_coverart,
i.xd_score,
COALESCE(i.visibility, 0) as visibility,
(SELECT ta.tag_id FROM tags_assign ta
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
@@ -635,40 +588,30 @@ export default (router, tpl) => {
// Large comments would silently drop the notification. The client fetches
// the full content via _silentSync; the NOTIFY only needs to trigger the update.
const notifyBody = content.length > 500 ? content.substring(0, 500) + '…' : content;
const uo = await db`SELECT banner_file, banner_position, banner_size, banner_repeat FROM user_options WHERE user_id = ${req.session.id}`;
const bannerOpt = uo[0] || {};
const livePayload = {
type: 'comment',
id: commentId,
item_id: item_id,
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
parent_id: parent_id || null,
body: notifyBody,
username: req.session.user,
user_id: req.session.id,
avatar: req.session.avatar,
avatar_file: req.session.avatar_file,
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
created_at: new Date().toISOString(),
username_color: req.session.username_color,
display_name: req.session.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
db.notify('comments', JSON.stringify(livePayload));
// 2. Sidebar activity update
const itemVisibility = itemQuery[0]?.visibility ?? 0;
// Compute is_long using the full content (not the truncated notifyBody) so the
// sidebar renders the correct clamped state immediately on first paint.
const activityIsLong = content.length > 120
|| content.split('\n').length > 2
|| activityFiles.length > 0
@@ -676,10 +619,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,
@@ -687,18 +626,11 @@ export default (router, tpl) => {
item_rating_label: ratingLabel,
avatar: req.session.avatar,
avatar_file: req.session.avatar_file,
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
username: req.session.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
@@ -716,14 +648,8 @@ export default (router, tpl) => {
success: true,
comment: {
...newComment[0],
user_id: req.session.id,
username_color: req.session.username_color || null,
content,
files: activityFiles,
banner_file: bannerOpt.banner_file || req.session.banner_file || null,
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null
files: activityFiles
},
xd_score: xdRow?.xd_score ?? null,
is_new_subscription
@@ -795,14 +721,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,49 +994,22 @@ 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 globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
const sessionUser = req.session ? (req.session.user || '').toLowerCase() : null;
const sessionUserId = req.session ? req.session.id : null;
const visibilityFilter = isOwnerOrAdmin
? db``
: (req.session
? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
: db`AND COALESCE(i.visibility, 0) = 0`);
const { mimeSQL: activityMimeSQL } = f0cklib.resolveMimeSQL(req.url.qs?.mime || (req.cookies?.mime || null), req.session, 'i');
const comments = await db`
SELECT
c.*,
i.mime,
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,
u.user as username,
uo.avatar,
uo.avatar_file,
uo.banner_file,
uo.banner_position,
uo.banner_size,
uo.banner_repeat,
uo.username_color,
uo.display_name
FROM comments c
@@ -1128,25 +1019,17 @@ 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
LIMIT ${limit} OFFSET ${offset}
`;
// Normalize item_slug based on getEnableItemSlugs()
const processedCommentsList = comments.map(c => ({
...c,
item_slug: getEnableItemSlugs() ? c.item_slug : null
}));
// Fetch comment file attachments
const filesMap = new Map();
if (processedCommentsList.length > 0) {
if (comments.length > 0) {
const commentIds = comments.map(c => c.id);
try {
const files = await db`
@@ -1207,7 +1090,6 @@ export default (router, tpl) => {
}
}
const isAnonymized = isAnonymizeSession(req.session);
const processedComments = comments.map(c => {
let ratingLabel = '?';
let ratingClass = 'untagged';
@@ -1217,6 +1099,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 +1109,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 +1323,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 +1361,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;
+81 -274
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,21 @@ 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')}
original_filename: original_filename || null
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename')}
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 +455,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 +476,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 +489,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 +520,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 +533,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);
+522 -849
View File
File diff suppressed because it is too large Load Diff

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