Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a6e0f44f3 |
@@ -1,9 +1,6 @@
|
||||
POSTGRES_USER=f0ckm
|
||||
POSTGRES_DB=f0ckm
|
||||
POSTGRES_PASSWORD=f0ckm
|
||||
# Secret for hashing user IPs (HMAC-SHA256). Generate once: openssl rand -hex 32
|
||||
# Kept here instead of config.json so a leaked config or DB dump can't be used to reverse IP hashes.
|
||||
IP_HASH_SECRET=
|
||||
# --- Nginx & Tor Profiles (Optional) ---
|
||||
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
|
||||
# COMPOSE_PROFILES=tor
|
||||
|
||||
+1
-25
@@ -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 . .
|
||||
|
||||
@@ -4,7 +4,7 @@ Happy to finally bring you f0ckm! The long awaited imageboard solution that you
|
||||
|
||||
It features extensive tagging, searching, filtering and a variety of options.
|
||||
|
||||
The software is mostly generic, it can be modified easily via `config.yaml` (or `config.json`) to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs.
|
||||
The software is mostly generic, it can be modified easily via config.json to suit your communities needs. Most things can be enabled/disabled very easily and modified to needs.
|
||||
|
||||
The software comes without any warranties or entitlements of any kind! The developer is not responsible for anything you do with the use of this software.
|
||||
|
||||
@@ -20,12 +20,7 @@ first things
|
||||
|
||||
fill with for example: f0ckm (prefilled)
|
||||
|
||||
Copy example configuration and customize:
|
||||
|
||||
`cp config_example.yaml config.yaml`
|
||||
`npm run config:gen`
|
||||
|
||||
(Or `cp config_example.json config.json` if configuring directly in JSON)
|
||||
`cp config_example.json config.json`
|
||||
|
||||
Edit to needs, for sql you can do this:
|
||||
|
||||
@@ -79,13 +74,6 @@ Create admin user in dev env
|
||||
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
|
||||
|
||||
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
|
||||
|
||||
## Configuration Management
|
||||
|
||||
You can manage configuration in YAML (`config.yaml`) or JSON (`config.json`):
|
||||
- `npm run config:gen`: Compiles `config.yaml` to `config.json`. Automatically runs before `npm run dev` and `npm start`.
|
||||
- `npm run config:watch`: Watches `config.yaml` for edits and recompiles `config.json` automatically on save.
|
||||
- `npm run config:to-yaml`: Converts existing `config.json` into `config.yaml`.
|
||||
|
||||
## NGINX
|
||||
|
||||
@@ -110,25 +98,3 @@ To advertise your `.onion` address to Tor Browser users visiting your clearnet s
|
||||
"onion": "http://yourgeneratedaddress.onion"
|
||||
}
|
||||
```
|
||||
|
||||
## Chat uploads (temporary file hosting for mumh5)
|
||||
|
||||
Upload-only API keys for external chat clients. Files are public, never appear on the imageboard, and expire (default 30 days, see `chat_upload_*` in `config.yaml`).
|
||||
|
||||
Manage keys (the key is shown once, only its hash is stored):
|
||||
|
||||
`node scripts/chat-upload-key.mjs create <name> [max_mb]`
|
||||
`node scripts/chat-upload-key.mjs list`
|
||||
`node scripts/chat-upload-key.mjs revoke <id>`
|
||||
|
||||
Upload (raw body, returns JSON with `url` and `delete_token`):
|
||||
|
||||
```bash
|
||||
curl -X POST https://your.host/api/chat/upload \
|
||||
-H "X-API-Key: cu_..." -H "X-Filename: clip.webm" -H "X-Upload-Expiry: 7d" \
|
||||
--data-binary @clip.webm
|
||||
```
|
||||
|
||||
Delete: `curl -X DELETE https://your.host/api/chat/upload/<slug> -H "X-Delete-Token: ..."`
|
||||
|
||||
Files are served at `/cu/<slug>/<name>` with Range support. Only sniffed images, video and audio are served inline; anything else is forced to download.
|
||||
|
||||
+3
-77
@@ -16,11 +16,7 @@
|
||||
"example.org"
|
||||
],
|
||||
"invite_secret": "YOUR_SECRET_HERE",
|
||||
"ip_hash_secret": "",
|
||||
"ip_hash_legacy_secrets": [],
|
||||
"hide_comments_from_public": false,
|
||||
"guest_anonymize": false,
|
||||
"anon_anonymize": false,
|
||||
"timezone": "UTC",
|
||||
"development": true
|
||||
},
|
||||
@@ -33,62 +29,16 @@
|
||||
],
|
||||
"enable_pdf": false,
|
||||
"enable_nsfl": false,
|
||||
"enable_comments": true,
|
||||
"enable_private_uploads": true,
|
||||
"enable_expiring_uploads": true,
|
||||
"default_upload_visibility": 0,
|
||||
"allow_user_upload_visibility": true,
|
||||
"enable_item_slugs": true,
|
||||
"enable_anonymous_access": true,
|
||||
"anonymous_permissions": {
|
||||
"upload": false,
|
||||
"comment": true,
|
||||
"comment_attachments": false,
|
||||
"comment_vote": true,
|
||||
"poll_vote": true,
|
||||
"tag": true,
|
||||
"tag_vote": true,
|
||||
"favorite": true,
|
||||
"rate_item": false,
|
||||
"filter": true,
|
||||
"exclude_tags": true,
|
||||
"chan": false,
|
||||
"anonymize_users": false,
|
||||
"allowed_modes": [
|
||||
"sfw",
|
||||
"nsfw",
|
||||
"untagged",
|
||||
"all",
|
||||
"nsfl"
|
||||
],
|
||||
"allowed_mimes": [
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"flash",
|
||||
"pdf"
|
||||
]
|
||||
},
|
||||
"onara": false,
|
||||
"nsfl_tag_id": 4,
|
||||
"allowedMimes": [
|
||||
"audio",
|
||||
"image",
|
||||
"video",
|
||||
"application/x-shockwave-flash",
|
||||
"application/vnd.adobe.flash.movie",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/x-rar",
|
||||
"application/rar",
|
||||
"application/x-rar-archive",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/x-bzip2",
|
||||
"application/x-xz"
|
||||
"video"
|
||||
],
|
||||
"nsfp": [
|
||||
2,
|
||||
@@ -101,16 +51,9 @@
|
||||
"allow_language_change": true,
|
||||
"cache": false,
|
||||
"eps": 155,
|
||||
"default_thumb_size": "m",
|
||||
"background": true,
|
||||
"log_user_ips": false,
|
||||
"hash_user_ips": true,
|
||||
"anon_hw_fingerprint": true,
|
||||
"retention_ip_days": 30,
|
||||
"retention_activity_log_days": 90,
|
||||
"retention_login_attempts_days": 30,
|
||||
"retention_sessions_days": 365,
|
||||
"retention_fingerprint_days": 365,
|
||||
"description": "Example Description",
|
||||
"themes": [
|
||||
"amoled"
|
||||
@@ -131,17 +74,9 @@
|
||||
"dm_attachments": true,
|
||||
"dm_unencrypted": false,
|
||||
"dm_attachment_expiry_days": 90,
|
||||
"chat_uploads": true,
|
||||
"chat_upload_max_bytes": 104857600,
|
||||
"chat_upload_expiry_days": 30,
|
||||
"chat_upload_max_expiry_days": 30,
|
||||
"chat_upload_rate_per_minute": 30,
|
||||
"chat_link_previews": true,
|
||||
"halls_enabled": true,
|
||||
"userhalls_enabled": true,
|
||||
"square_clicker_enabled": true,
|
||||
"enable_userhall_image_upload": true,
|
||||
"enable_oc": true,
|
||||
"abyss_enabled": true,
|
||||
"meme_creator": true,
|
||||
"enable_cleanup": false,
|
||||
@@ -180,11 +115,7 @@
|
||||
"fileupload_comments_size": 104857600,
|
||||
"fileupload_comments_max": 5,
|
||||
"fileupload_comments_mode": "attachment",
|
||||
"fileupload_comments_mimes": [
|
||||
"image",
|
||||
"video",
|
||||
"audio"
|
||||
],
|
||||
"fileupload_comments_mimes": ["image", "video", "audio"],
|
||||
"show_content_warning": true,
|
||||
"default_comment_display_mode": 1,
|
||||
"phrases": [
|
||||
@@ -214,7 +145,6 @@
|
||||
"private_society_gate_location": "Frankfurt",
|
||||
"private_society_gate_template": "_gate_template",
|
||||
"public_nsfw": false,
|
||||
"public_untagged": false,
|
||||
"paths": {
|
||||
"images": "/b",
|
||||
"thumbnails": "/t",
|
||||
@@ -290,7 +220,6 @@
|
||||
"audio/aac": "m4a",
|
||||
"video/x-m4v": "mp4",
|
||||
"video/x-matroska": "mkv",
|
||||
"video/mpeg": "mp4",
|
||||
"application/x-shockwave-flash": "swf",
|
||||
"application/vnd.adobe.flash.movie": "swf",
|
||||
"application/pdf": "pdf",
|
||||
@@ -298,9 +227,6 @@
|
||||
"application/x-zip-compressed": "zip",
|
||||
"application/x-rar-compressed": "rar",
|
||||
"application/vnd.rar": "rar",
|
||||
"application/x-rar": "rar",
|
||||
"application/rar": "rar",
|
||||
"application/x-rar-archive": "rar",
|
||||
"application/x-7z-compressed": "7z",
|
||||
"application/x-tar": "tar",
|
||||
"application/gzip": "gz",
|
||||
@@ -323,4 +249,4 @@
|
||||
"site_key": "YOUR_RECAPTCHA_V2_SITE_KEY",
|
||||
"secret_key": "YOUR_RECAPTCHA_V2_SECRET_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -19,7 +19,6 @@ services:
|
||||
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
|
||||
- ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z
|
||||
- ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z
|
||||
- ./f0ckm-data/cu/:/opt/f0ckm/public/cu/:Z
|
||||
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
|
||||
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
|
||||
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
|
||||
@@ -35,7 +34,6 @@ services:
|
||||
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
||||
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
|
||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||
- ./f0ckm-data/s/img/navbar/:/opt/f0ckm/public/s/img/navbar/:Z
|
||||
|
||||
environment:
|
||||
- GIT_HASH=${f0ckm_TAG:-unknown}
|
||||
@@ -49,8 +47,6 @@ services:
|
||||
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
|
||||
- DB_NAME=${POSTGRES_DB:-f0ckm}
|
||||
- NODE_ENV=production
|
||||
# Dedicated secret for IP hashing (HMAC). Set in .env, keep it out of config.json and DB backups.
|
||||
- IP_HASH_SECRET=${IP_HASH_SECRET:-}
|
||||
ports:
|
||||
- "1337:1337"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Migration: deleted_user ghost account
|
||||
-- Deleted users' comments, tags, halls, reports, etc. are reassigned to this system account
|
||||
-- (see /api/v2/admin/users/delete, which also creates it on first use).
|
||||
-- It can never log in: password '!' matches no hash, it is not activated and it is banned.
|
||||
|
||||
INSERT INTO public."user" (login, "user", password, admin, is_moderator, activated, banned, ban_reason, created_at)
|
||||
VALUES ('deleted_user', 'deleted_user', '!', false, false, false, true, 'System account', now())
|
||||
ON CONFLICT (login) DO NOTHING;
|
||||
|
||||
INSERT INTO public.user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, display_name)
|
||||
SELECT id, 0, 'amoled', 0, NULL, 'default.png', 'deleted user'
|
||||
FROM public."user" WHERE login = 'deleted_user'
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
@@ -1,26 +0,0 @@
|
||||
-- Migration: Add Hardware Fingerprint Banning
|
||||
-- Adds banned_hardware_fingerprints table and links hw_fingerprint to anon_identities and anon_activity_log.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS banned_hardware_fingerprints (
|
||||
id SERIAL PRIMARY KEY,
|
||||
hw_fingerprint VARCHAR(128) UNIQUE NOT NULL,
|
||||
banned_by INT REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
reason TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_banned_hw_fp ON banned_hardware_fingerprints(hw_fingerprint);
|
||||
CREATE INDEX IF NOT EXISTS idx_banned_hw_expires ON banned_hardware_fingerprints(expires_at);
|
||||
|
||||
-- Add hardware fingerprint column to anon_identities
|
||||
ALTER TABLE anon_identities
|
||||
ADD COLUMN IF NOT EXISTS hw_fingerprint VARCHAR(128);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_anon_identities_hw ON anon_identities(hw_fingerprint);
|
||||
|
||||
-- Add hardware fingerprint column to anon_activity_log
|
||||
ALTER TABLE anon_activity_log
|
||||
ADD COLUMN IF NOT EXISTS hw_fingerprint VARCHAR(128);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_anon_activity_hw ON anon_activity_log(hw_fingerprint);
|
||||
@@ -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');
|
||||
@@ -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 +0,0 @@
|
||||
ALTER TABLE public.reports ADD COLUMN IF NOT EXISTS categories text[] DEFAULT '{}';
|
||||
@@ -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;
|
||||
@@ -1,34 +0,0 @@
|
||||
-- Square Clicker: user-made beatmaps for audio and video items and their scores.
|
||||
-- notes: JSON array of { t: ms from song start, x: 0..1, y: 0..1 (viewport fractions), d: hold ms (0 = tap),
|
||||
-- p: optional slider path [[dt ms, x, y], ...] recorded while the hold was dragged }
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.sqc_maps (
|
||||
id serial PRIMARY KEY,
|
||||
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
|
||||
album_item_id integer REFERENCES album_items(id) ON DELETE CASCADE,
|
||||
user_id integer REFERENCES "user"(id) ON DELETE SET NULL,
|
||||
title text NOT NULL DEFAULT 'Untitled',
|
||||
notes jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
note_count integer NOT NULL DEFAULT 0,
|
||||
duration_ms integer NOT NULL DEFAULT 0,
|
||||
plays integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- album_item_id: set for maps of one entry of an album (each entry is its own track)
|
||||
ALTER TABLE public.sqc_maps ADD COLUMN IF NOT EXISTS album_item_id integer REFERENCES album_items(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sqc_maps_item ON public.sqc_maps(item_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.sqc_scores (
|
||||
id serial PRIMARY KEY,
|
||||
map_id integer NOT NULL REFERENCES sqc_maps(id) ON DELETE CASCADE,
|
||||
user_id integer REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
score integer NOT NULL DEFAULT 0,
|
||||
accuracy real NOT NULL DEFAULT 0,
|
||||
max_combo integer NOT NULL DEFAULT 0,
|
||||
hits jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at integer NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sqc_scores_map ON public.sqc_scores(map_id, score DESC);
|
||||
@@ -1,25 +0,0 @@
|
||||
-- User Interest & Behavior Recommendation Affinity Tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.user_tag_affinity (
|
||||
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
tag_id integer NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
score real DEFAULT 0.0,
|
||||
interaction_count integer DEFAULT 1,
|
||||
last_interacted timestamp with time zone DEFAULT now(),
|
||||
PRIMARY KEY (user_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_tag_affinity_user_score
|
||||
ON public.user_tag_affinity(user_id, score DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.user_creator_affinity (
|
||||
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
creator_username text NOT NULL,
|
||||
score real DEFAULT 0.0,
|
||||
interaction_count integer DEFAULT 1,
|
||||
last_interacted timestamp with time zone DEFAULT now(),
|
||||
PRIMARY KEY (user_id, creator_username)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_creator_affinity_user_score
|
||||
ON public.user_creator_affinity(user_id, score DESC);
|
||||
+3515
-3744
File diff suppressed because it is too large
Load Diff
Generated
+1
-22
@@ -17,8 +17,7 @@
|
||||
"jszip": "3.10.1",
|
||||
"marked": "18.0.2",
|
||||
"matrix-js-sdk": "^40.3.0-rc.0",
|
||||
"postgres": "^3.3.4",
|
||||
"yaml": "^2.9.1"
|
||||
"postgres": "^3.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -449,21 +448,6 @@
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -773,11 +757,6 @@
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-9
@@ -5,14 +5,8 @@
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prestart": "node scripts/generate-config.mjs",
|
||||
"start": "node --trace-uncaught src/index.mjs",
|
||||
"predev": "node scripts/generate-config.mjs",
|
||||
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch --watch-path src src/index.mjs",
|
||||
"devv2": "STORAGE_DIR=/home/kibi/Projects/f0ckmv2/f0ckm/f0ckm-data DB_HOST=localhost DB_PORT=5455 node --trace-uncaught --watch --watch-path src src/index.mjs",
|
||||
"config:gen": "node scripts/generate-config.mjs",
|
||||
"config:watch": "node scripts/generate-config.mjs --watch",
|
||||
"config:to-yaml": "node scripts/generate-config.mjs --to-yaml",
|
||||
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch src/index.mjs",
|
||||
"trigger": "node debug/trigger.mjs",
|
||||
"autotagger": "node debug/autotagger.mjs",
|
||||
"thumbnailer": "node debug/thumbnailer.mjs",
|
||||
@@ -36,7 +30,6 @@
|
||||
"jszip": "3.10.1",
|
||||
"marked": "18.0.2",
|
||||
"matrix-js-sdk": "^40.3.0-rc.0",
|
||||
"postgres": "^3.3.4",
|
||||
"yaml": "^2.9.1"
|
||||
"postgres": "^3.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
+1007
-6708
File diff suppressed because it is too large
Load Diff
+10
-390
@@ -5,7 +5,7 @@
|
||||
padding: 0;
|
||||
animation: uploadReveal 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -25,29 +25,17 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Title line: title left, upload limit right (wraps under it on narrow screens) */
|
||||
.upload-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
font-size: x-large;
|
||||
}
|
||||
|
||||
/* Upload Limit Info (inside the title line; keeps its own small, normal-case text) */
|
||||
/* Upload Limit Info */
|
||||
.upload-limit-info {
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.upload-title .upload-limit-info {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.upload-limit-info i {
|
||||
margin-right: 0.3rem;
|
||||
}
|
||||
@@ -381,25 +369,6 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.upload-form.shitpost-mode-active.album-mode-active .global-rating-section,
|
||||
.upload-form.shitpost-mode-active.album-mode-active .global-visibility-section,
|
||||
.upload-form.shitpost-mode-active.album-mode-active .global-expiry-section,
|
||||
.upload-form.shitpost-mode-active.album-mode-active .global-comment-section,
|
||||
.upload-form.shitpost-mode-active.album-mode-active .global-tag-section,
|
||||
.upload-form.shitpost-mode-active.album-mode-active .global-oc-section,
|
||||
.upload-form.shitpost-mode-active.album-mode-active .global-title-section {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.upload-form.album-mode-active .drop-zone {
|
||||
border-color: rgba(var(--accent-rgb), 0.45);
|
||||
background: rgba(var(--accent-rgb), 0.03);
|
||||
}
|
||||
|
||||
.upload-form.album-mode-active .drop-album-hint {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Per-item Rating Switch */
|
||||
.item-rating-container {
|
||||
display: flex;
|
||||
@@ -435,21 +404,21 @@
|
||||
}
|
||||
|
||||
.item-rating-option input:checked + .item-rating-label.sfw {
|
||||
background: var(--badge-sfw, #02500b);
|
||||
background: var(--badge-sfw);
|
||||
color: #fff;
|
||||
border-color: var(--badge-sfw, #02500b);
|
||||
border-color: var(--badge-sfw);
|
||||
}
|
||||
|
||||
.item-rating-option input:checked + .item-rating-label.nsfw {
|
||||
background: var(--badge-nsfw, #E10DC3);
|
||||
background: var(--badge-nsfw);
|
||||
color: #fff;
|
||||
border-color: var(--badge-nsfw, #E10DC3);
|
||||
border-color: var(--badge-nsfw);
|
||||
}
|
||||
|
||||
.item-rating-option input:checked + .item-rating-label.nsfl {
|
||||
background: var(--badge-nsfl, #660000);
|
||||
background: var(--badge-nsfl);
|
||||
color: #fff;
|
||||
border-color: var(--badge-nsfl, #660000);
|
||||
border-color: var(--badge-nsfl);
|
||||
}
|
||||
|
||||
/* Visibility Container - Only checked option is colored, unchecked options are gray */
|
||||
@@ -652,6 +621,7 @@
|
||||
|
||||
.rating-label {
|
||||
display: block;
|
||||
padding: 0.1rem 2rem;
|
||||
border-radius: 0;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
@@ -990,51 +960,12 @@
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.global-redirect-section {
|
||||
margin: 4px 0 2px 0;
|
||||
}
|
||||
|
||||
.global-redirect-section .upload-checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.global-redirect-section .upload-checkbox-label:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.global-redirect-section input[type="checkbox"] {
|
||||
accent-color: var(--accent);
|
||||
cursor: pointer;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-status {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Groups button + progress + status (pinned in the upload dropdown, see f0ckm.css); same spacing as the form */
|
||||
.upload-footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Only takes space when there is a message; the progress bar is display:none until an upload runs */
|
||||
.upload-status:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.upload-status.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
@@ -1775,314 +1706,3 @@
|
||||
}
|
||||
@keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
|
||||
|
||||
/* ==========================================================================
|
||||
ALBUM UPLOAD STYLES
|
||||
========================================================================== */
|
||||
|
||||
.album-choice-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--nav-border-color, rgba(255, 255, 255, 0.1));
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin-top: 12px;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.album-choice-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.album-choice-title i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.album-choice-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.album-choice-btn {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
color: #ccc;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.album-choice-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.album-choice-btn.active {
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
font-weight: 700;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(var(--accent-rgb, 31, 178, 176), 0.4);
|
||||
}
|
||||
|
||||
.album-staging-container {
|
||||
width: 100%;
|
||||
margin-top: 14px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.album-staging-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.album-staging-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.album-staging-title i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-add-album-pics {
|
||||
padding: 5px 12px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
color: #eee;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-add-album-pics:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(var(--accent-rgb, 31, 178, 176), 0.1);
|
||||
}
|
||||
|
||||
.album-staging-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(115px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.album-stage-card {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.album-stage-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.album-stage-card.is-cover {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 10px rgba(var(--accent-rgb, 31, 178, 176), 0.35);
|
||||
}
|
||||
|
||||
.album-stage-card img,
|
||||
.album-stage-card video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.album-stage-audio-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.album-stage-audio-preview i {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.album-stage-audio-name {
|
||||
font-size: 0.65rem;
|
||||
color: #ccc;
|
||||
max-width: 95px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.album-stage-mime-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.album-stage-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
z-index: 5;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.album-stage-card.is-cover .album-stage-badge {
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
border-color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.album-stage-actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85) 60%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
padding: 10px 4px 4px 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
.album-stage-card:hover .album-stage-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.album-action-btn {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
border: none;
|
||||
color: #fff;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.album-action-btn:hover {
|
||||
background: var(--accent);
|
||||
color: #111;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.album-action-btn.btn-album-remove:hover {
|
||||
background: #e74c3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.album-stage-card.album-stage-add-more {
|
||||
border-style: dashed;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.album-stage-card.album-stage-add-more:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: rgba(var(--accent-rgb, 31, 178, 176), 0.08);
|
||||
}
|
||||
|
||||
.album-add-icon {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.album-add-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.album-add-sub {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.album-stage-tags-wrap {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.album-stage-tags-input {
|
||||
width: 100%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 0.68rem;
|
||||
color: #fff;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
.album-stage-tags-input:focus {
|
||||
border-color: var(--accent);
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
|
||||
|
||||
+29
-339
@@ -6,7 +6,7 @@
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center center;
|
||||
touch-action: pan-y;
|
||||
touch-action: none; /* Prevent pull-to-refresh and scroll while interacting */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@@ -14,10 +14,15 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.v0ck:fullscreen,
|
||||
.v0ck.v0ck_fullscreen,
|
||||
#main:fullscreen .v0ck.v0ck_fullscreen,
|
||||
#onara-modal:fullscreen .v0ck.v0ck_fullscreen {
|
||||
.v0ck.v0ck_fullscreen {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#main:fullscreen .v0ck.v0ck_fullscreen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -34,10 +39,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.v0ck:fullscreen video,
|
||||
.v0ck.v0ck_fullscreen video,
|
||||
#main:fullscreen .v0ck.v0ck_fullscreen video,
|
||||
#onara-modal:fullscreen .v0ck.v0ck_fullscreen video {
|
||||
#main:fullscreen .v0ck.v0ck_fullscreen video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
@@ -45,25 +47,10 @@
|
||||
}
|
||||
|
||||
/* Audio in fullscreen: hide the invisible audio element, show cover art via background */
|
||||
.v0ck:fullscreen audio,
|
||||
.v0ck.v0ck_fullscreen audio,
|
||||
#main:fullscreen .v0ck.v0ck_fullscreen audio,
|
||||
#onara-modal:fullscreen .v0ck.v0ck_fullscreen audio {
|
||||
#main:fullscreen .v0ck.v0ck_fullscreen audio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v0ck canvas.audio-visualizer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1 !important;
|
||||
}
|
||||
|
||||
.v0ck_overlay {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
@@ -119,14 +106,14 @@
|
||||
}
|
||||
|
||||
.v0ck_player_button svg:hover {
|
||||
filter: drop-shadow(0 0 1px var(--accent));
|
||||
filter: drop-shadow(0 0 9px var(--accent));
|
||||
fill: #000;
|
||||
stroke: var(--accent);
|
||||
stroke-width: 30px;
|
||||
}
|
||||
|
||||
.v0ck_hidden {
|
||||
display: none !important;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.v0ck_player_controls svg {
|
||||
@@ -145,7 +132,7 @@
|
||||
padding: 0;
|
||||
align-items: center;
|
||||
z-index: 2;
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 1%, rgba(0, 0, 0, 0) 100%);
|
||||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8) 20%, rgba(0, 0, 0, 0) 100%);
|
||||
transition: opacity .3s, transform .3s;
|
||||
flex-wrap: wrap;
|
||||
transform: translateY(100%) translateY(-3px);
|
||||
@@ -326,144 +313,6 @@
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Comment markers (danmaku.js): one tick per timeline comment; click = jump there + show the pill.
|
||||
The layer passes clicks through (normal scrubbing); each tick has a 10px hit area around a 2px line. */
|
||||
.danmaku-markers {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.danmaku-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 10px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: none;
|
||||
transform: translateX(-50%);
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.danmaku-marker::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||||
transition: width 0.12s, background 0.12s;
|
||||
}
|
||||
|
||||
/* No own timestamp: placed at a random time on load */
|
||||
.danmaku-marker.is-random::before {
|
||||
background: rgba(255, 255, 255, 0.45);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.danmaku-marker:hover::before {
|
||||
width: 4px;
|
||||
background: #ffd844;
|
||||
}
|
||||
|
||||
/* Hover preview of a marker's comment (plain text), above the progress bar */
|
||||
.danmaku-marker-preview {
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
max-width: min(320px, 80%);
|
||||
padding: 6px 9px;
|
||||
background: rgba(0, 0, 0, 0.88);
|
||||
border-left: 2px solid var(--accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 0.12s, transform 0.12s;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview.is-visible {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-time {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-user {
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-text {
|
||||
overflow-wrap: anywhere;
|
||||
max-height: 140px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Rendered like the flying pills, sized for a small preview */
|
||||
.danmaku-marker-preview .dmp-text .dpill-line,
|
||||
.danmaku-marker-preview .dmp-text .dpill-greentext {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-text .dpill-greentext {
|
||||
color: #789922;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-text .dpill-emoji {
|
||||
display: inline-block;
|
||||
height: 1.6em;
|
||||
width: auto;
|
||||
max-width: 4em;
|
||||
vertical-align: middle;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-text :is(.dpill-img, .dpill-video) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 90px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
margin-top: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-text .dpill-spoiler:not(.revealed) {
|
||||
background: #000;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-text .dpill-spoiler:not(.revealed) * {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.danmaku-marker-preview .dmp-text .dpill-blur:not(.revealed) {
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
/* Seek Marker Ripple */
|
||||
.v0ck_seek_marker {
|
||||
position: absolute;
|
||||
@@ -531,14 +380,13 @@
|
||||
display: inline-flex;
|
||||
flex-direction: column; /* stack lines vertically */
|
||||
align-items: flex-start;
|
||||
padding: 2px 4px;
|
||||
padding: 2px 0;
|
||||
font-family: var(--font, inherit);
|
||||
font-size: var(--danmaku-font-size, 35px);
|
||||
font-weight: var(--danmaku-font-weight, 700);
|
||||
font-size: 35px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
gap: 0;
|
||||
color: var(--danmaku-color, #fff);
|
||||
opacity: var(--danmaku-opacity, 1);
|
||||
color: #fff;
|
||||
/* Multi-layer outline shadow for readability over any background */
|
||||
text-shadow:
|
||||
-1px -1px 0 #000,
|
||||
@@ -551,48 +399,12 @@
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Pill Background Variations */
|
||||
.danmaku-overlay.danmaku-bg-glass .danmaku-pill {
|
||||
background: rgba(10, 10, 14, 0.65);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.danmaku-overlay.danmaku-bg-capsule .danmaku-pill {
|
||||
background: rgba(18, 18, 24, 0.85);
|
||||
padding: 4px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(var(--accent-rgb, 153, 255, 0), 0.35);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
/* Shadow / Glow Variations */
|
||||
.danmaku-overlay.danmaku-glow-neon .danmaku-pill {
|
||||
text-shadow:
|
||||
0 0 8px var(--danmaku-color, #99ff00),
|
||||
0 0 18px var(--danmaku-color, #99ff00),
|
||||
-1px -1px 0 #000,
|
||||
1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.danmaku-overlay.danmaku-glow-none .danmaku-pill {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.danmaku-overlay.danmaku-glow-subtle .danmaku-pill {
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.danmaku-pill .dpill-text {
|
||||
font-family: var(--font, inherit);
|
||||
font-size: var(--danmaku-font-size, 35px);
|
||||
font-weight: var(--danmaku-font-weight, 700);
|
||||
font-size: 35px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -609,10 +421,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.danmaku-overlay.danmaku-no-greentext .danmaku-pill .dpill-greentext {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* Spoiler inside a flying pill — black-on-black, click to reveal */
|
||||
.danmaku-pill .dpill-spoiler {
|
||||
background: #000;
|
||||
@@ -671,8 +479,8 @@
|
||||
|
||||
/* Inline image URL embedded in pill */
|
||||
.danmaku-pill .dpill-img {
|
||||
max-height: var(--danmaku-media-max-h, 80px);
|
||||
max-width: 140px;
|
||||
max-height: 80px;
|
||||
max-width: 120px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
vertical-align: middle;
|
||||
@@ -684,8 +492,8 @@
|
||||
|
||||
/* Inline video (converted GIF) in pill */
|
||||
.danmaku-pill .dpill-video {
|
||||
max-height: var(--danmaku-media-max-h, 80px);
|
||||
max-width: 140px;
|
||||
max-height: 80px;
|
||||
max-width: 120px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
vertical-align: middle;
|
||||
@@ -701,105 +509,6 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* ── Danmaku Debug Overlays ────────────────────────────────── */
|
||||
.danmaku-lane-guides-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.danmaku-lane-guide {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-top: 1px dashed rgba(var(--accent-rgb, 153, 255, 0), 0.35);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 8px;
|
||||
font-family: monospace;
|
||||
font-size: 10px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.danmaku-lane-guide.occupied {
|
||||
background: rgba(255, 60, 60, 0.12);
|
||||
border-top: 1px dashed rgba(255, 60, 60, 0.65);
|
||||
color: #ff7070;
|
||||
}
|
||||
|
||||
.danmaku-lane-badge {
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.danmaku-lane-status {
|
||||
font-size: 9px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Diagnostic Live HUD */
|
||||
.danmaku-debug-hud {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 14px;
|
||||
z-index: 10;
|
||||
background: rgba(12, 12, 16, 0.88);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(var(--accent-rgb, 153, 255, 0), 0.45);
|
||||
border-radius: 8px;
|
||||
padding: 8px 12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: #e0e0e0;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
line-height: 1.3;
|
||||
min-width: 170px;
|
||||
}
|
||||
|
||||
.danmaku-debug-hud .hud-title {
|
||||
color: var(--accent, #99ff00);
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding-bottom: 3px;
|
||||
margin-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.danmaku-debug-hud .hud-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.danmaku-debug-hud .hud-val {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.danmaku-debug-hud .hud-val.accent {
|
||||
color: var(--accent, #99ff00);
|
||||
}
|
||||
|
||||
@keyframes danmaku-fly {
|
||||
from { transform: translateX(calc(100vw + 100%)); }
|
||||
to { transform: translateX(calc(-100% - 200px)); }
|
||||
@@ -834,27 +543,8 @@
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
/* Hide mouse cursor on inactivity when player controls auto-hide */
|
||||
.v0ck:not(.v0ck_hover),
|
||||
.v0ck:not(.v0ck_hover) * {
|
||||
/* Hide mouse cursor on inactivity in fullscreen */
|
||||
.v0ck.v0ck_fullscreen:not(.v0ck_hover),
|
||||
.v0ck.v0ck_fullscreen:not(.v0ck_hover) * {
|
||||
cursor: none !important;
|
||||
}
|
||||
/* Volume: pointer over the whole volume area, not only the 5px slider strip, including the slider's
|
||||
thumb/track parts (Firefox doesn't hand the input's cursor to them). Only while the controls are
|
||||
shown, so the idle cursor auto-hide above still applies. */
|
||||
.v0ck.v0ck_hover .v0ck_volume_group,
|
||||
.v0ck.v0ck_hover .v0ck_volume_group *,
|
||||
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"] {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
/* Vendor pseudo-elements in separate rules: one unknown pseudo-element drops a whole selector list */
|
||||
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-thumb,
|
||||
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-webkit-slider-runnable-track {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-moz-range-thumb,
|
||||
.v0ck.v0ck_hover .v0ck_volume_group input[type="range"][name="volume"]::-moz-range-track {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
}
|
||||
+71
-582
@@ -6,20 +6,14 @@
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const usernameEl = document.querySelector("a#a_username");
|
||||
const currentSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
|
||||
? window.albumGallery.getCurrentSubf0ck()
|
||||
: null;
|
||||
const subf0ck_id = currentSub ? (currentSub.slug || currentSub.id) : (tagsContainer?.dataset?.subf0ckSlug || tagsContainer?.dataset?.subf0ckId || null);
|
||||
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
subf0ck_id,
|
||||
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
|
||||
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
|
||||
poster: (usernameEl?.dataset?.username || '').trim() || null,
|
||||
@@ -56,13 +50,6 @@
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
if (!tagsContainer) return;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
|
||||
|
||||
const canManage = !!(
|
||||
document.querySelector('#tags[data-can-manage="true"]') ||
|
||||
(window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) ||
|
||||
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
|
||||
);
|
||||
|
||||
// Only remove existing dynamically generated tags
|
||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||
@@ -72,110 +59,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
|
||||
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
|
||||
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
|
||||
|
||||
const tagsCopy = [...cleanTags];
|
||||
tagsCopy.reverse().forEach(tag => {
|
||||
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized);
|
||||
let contentEl;
|
||||
if (isRating) {
|
||||
contentEl = document.createElement("span");
|
||||
contentEl.className = "rating-label";
|
||||
contentEl.textContent = tag.tag;
|
||||
} else {
|
||||
contentEl = document.createElement("a");
|
||||
contentEl.href = "/tag/" + tag.normalized;
|
||||
contentEl.style = "color: inherit !important";
|
||||
contentEl.textContent = tag.tag;
|
||||
}
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.textContent = tag.tag;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge");
|
||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||
span.classList.add('new-tag-glow');
|
||||
}
|
||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
if (isRating) {
|
||||
span.classList.add('rating-tag', `is-${tag.normalized}`);
|
||||
span.dataset.rating = tag.normalized;
|
||||
if (activePostId) span.dataset.itemId = activePostId;
|
||||
if (canManage) {
|
||||
span.classList.add('can-cycle');
|
||||
}
|
||||
} else {
|
||||
span.classList.add('tag-badge');
|
||||
span.setAttribute('data-tag-id', tag.id || '');
|
||||
span.setAttribute('data-tag', tag.tag);
|
||||
span.setAttribute('data-tag-normalized', tag.normalized);
|
||||
if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) {
|
||||
span.setAttribute("tooltip", tag.display_name || tag.user);
|
||||
}
|
||||
const isExcl = !!tag.is_excluded;
|
||||
if (isExcl) span.classList.add('tag-is-excluded');
|
||||
}
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
delbutton.href = "javascript:void(0)";
|
||||
// Class for delegation
|
||||
delbutton.classList.add("admin-deltag", "removetag");
|
||||
|
||||
span.appendChild(contentEl);
|
||||
|
||||
if (!isRating && tag.can_exclude) {
|
||||
const excludeBtn = document.createElement("button");
|
||||
excludeBtn.type = "button";
|
||||
excludeBtn.className = "tag-exclude-btn";
|
||||
excludeBtn.setAttribute("title", tag.is_excluded ? "Excluded (click to unexclude)" : "Exclude tag");
|
||||
excludeBtn.setAttribute("aria-label", "Exclude tag");
|
||||
excludeBtn.innerHTML = `<i class="fa-solid ${tag.is_excluded ? 'fa-circle-check' : 'fa-ban'}"></i>`;
|
||||
span.appendChild(excludeBtn);
|
||||
}
|
||||
|
||||
if (!isRating) {
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.href = "#";
|
||||
delbutton.classList.add("admin-deltag", "removetag");
|
||||
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
span.appendChild(document.createTextNode(" "));
|
||||
span.appendChild(delbutton);
|
||||
}
|
||||
span.appendChild(a);
|
||||
span.appendChild(document.createTextNode('\u00A0'));
|
||||
span.appendChild(delbutton);
|
||||
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
|
||||
const hasRating = !!lastRatingTag;
|
||||
if (!hasRating) {
|
||||
const untaggedSpan = document.createElement("span");
|
||||
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`;
|
||||
untaggedSpan.dataset.rating = 'untagged';
|
||||
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "rating-label";
|
||||
lbl.textContent = "unrated";
|
||||
untaggedSpan.appendChild(lbl);
|
||||
inner.insertAdjacentElement("afterbegin", untaggedSpan);
|
||||
}
|
||||
|
||||
// Safeguard: remove any extra rating tags in DOM
|
||||
const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]');
|
||||
if (allRatingEls.length > 1) {
|
||||
for (let i = 1; i < allRatingEls.length; i++) {
|
||||
allRatingEls[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
|
||||
if (activePostId) {
|
||||
const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
|
||||
document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
|
||||
th.setAttribute('data-mode', modeAttr);
|
||||
});
|
||||
if (document.body.classList.contains('onara-modal-open')) {
|
||||
const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
|
||||
if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle show more/less toggle visibility and count
|
||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||
@@ -210,7 +121,7 @@
|
||||
e.stopImmediatePropagation();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, subf0ck_id } = ctx;
|
||||
const { postid } = ctx;
|
||||
|
||||
let target = e.target;
|
||||
if (target.nodeType === 3) target = target.parentElement;
|
||||
@@ -224,12 +135,8 @@
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm((window.f0ckI18n && window.f0ckI18n.tag_delete_title) || 'Delete Tag', `${(window.f0ckI18n && window.f0ckI18n.tag_delete_confirm) || 'Are you sure you want to delete the tag'} <strong style="color:#d9534f">${tagname}</strong>?`, async (reason) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (reason) qs.set('reason', reason);
|
||||
if (subf0ck_id) qs.set('subf0ck_id', subf0ck_id);
|
||||
const qsStr = qs.toString();
|
||||
|
||||
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (qsStr ? "?" + qsStr : ""), {
|
||||
// Send reason via query param for DELETE request
|
||||
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (reason ? "?reason=" + encodeURIComponent(reason) : ""), {
|
||||
method: 'DELETE',
|
||||
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||
})).json();
|
||||
@@ -237,10 +144,6 @@
|
||||
if (!res.success) {
|
||||
throw new Error(res.msg || "Error deleting tag");
|
||||
}
|
||||
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
|
||||
const cur = window.albumGallery.getCurrentSubf0ck();
|
||||
if (cur && res.tags) cur.tags = res.tags;
|
||||
}
|
||||
renderTags(res.tags);
|
||||
if (window.flashMessage) window.flashMessage((window.f0ckI18n?.tag_deleted_success) || 'Tag deleted', 2500, 'success');
|
||||
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||
@@ -250,7 +153,7 @@
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, subf0ck_id, tags } = ctx;
|
||||
const { postid, tags } = ctx;
|
||||
const anchor = document.querySelector("a#a_addtag");
|
||||
if (!anchor) return;
|
||||
|
||||
@@ -259,363 +162,81 @@
|
||||
existingTags: tags,
|
||||
anchorEl: anchor,
|
||||
onSubmit: async (tag) => {
|
||||
const payload = { tagname: tag };
|
||||
if (subf0ck_id) payload.subf0ck_id = subf0ck_id;
|
||||
const res = await post("/api/v2/tags/" + postid, payload);
|
||||
if (res.success) {
|
||||
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
|
||||
const cur = window.albumGallery.getCurrentSubf0ck();
|
||||
if (cur && res.tags) cur.tags = res.tags;
|
||||
}
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
|
||||
if (res.success && window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
renderTags: (newTags, hl) => {
|
||||
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
|
||||
const cur = window.albumGallery.getCurrentSubf0ck();
|
||||
if (cur) cur.tags = newTags;
|
||||
}
|
||||
renderTags(newTags, hl);
|
||||
}
|
||||
renderTags
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
let favSeq = 0;
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
if (!favoBtns.length) return;
|
||||
const firstFavoBtn = favoBtns[0];
|
||||
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
|
||||
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
|
||||
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
|
||||
let localId = firstFavoBtn?.dataset?.localId;
|
||||
|
||||
const ctx = getContext();
|
||||
const postid = localId ? Number(localId) : ctx?.postid;
|
||||
if (!postid && !chanUrl) return;
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const wasAlreadyFav = favoBtns[0].classList.contains('fa-solid') && !favoBtns[0].classList.contains('fa-spinner');
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
// Read state BEFORE the API call so we know which direction to toggle
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const setFavoUi = (isFav) => {
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fa-spinner', 'fa-spin');
|
||||
btn.classList.add('iconset', 'fa-heart');
|
||||
btn.classList.toggle('fa-solid', isFav);
|
||||
btn.classList.toggle('fa-regular', !isFav);
|
||||
btn.title = isFav ? (window.f0ckI18n?.fav_remove || 'Remove from favorites') : (window.f0ckI18n?.fav_add || 'Favorite');
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Instantly apply client UI
|
||||
setFavoUi(isNowFav);
|
||||
|
||||
// Micro-animation for tactile pop
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fav-pop');
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add('fav-pop');
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
|
||||
// Instant flash message & vibration feedback
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
|
||||
if (postid && window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
|
||||
// Optimistically update #favs badge list
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
const prevFavsHtml = favcontainer ? favcontainer.innerHTML : '';
|
||||
const prevFavsHidden = favcontainer ? favcontainer.hidden : true;
|
||||
|
||||
if (favcontainer) {
|
||||
const currentUser = (window.f0ckSession?.user || '').toLowerCase();
|
||||
const isAnon = !!(window.f0ckSession?.is_anon || window.f0ckSession?.user === 'anonymous');
|
||||
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
|
||||
|
||||
const findSelfBadge = () => {
|
||||
const selfBadges = favcontainer.querySelectorAll('[data-self="true"]');
|
||||
if (selfBadges.length) return selfBadges[0];
|
||||
if (currentUser && currentUser !== 'anonymous') {
|
||||
const links = favcontainer.querySelectorAll('a[href]');
|
||||
for (const a of links) {
|
||||
const path = a.getAttribute('href') || '';
|
||||
if (path.toLowerCase().endsWith('/user/' + currentUser)) return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!isNowFav) {
|
||||
const selfBadge = findSelfBadge();
|
||||
if (selfBadge) selfBadge.remove();
|
||||
if (favcontainer.children.length === 0) {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
} else {
|
||||
let selfBadge = findSelfBadge();
|
||||
if (!selfBadge) {
|
||||
selfBadge = document.createElement('a');
|
||||
selfBadge.dataset.self = 'true';
|
||||
selfBadge.setAttribute('flow', 'up');
|
||||
if (isAnonymized || isAnon) {
|
||||
selfBadge.className = 'ghost-fav';
|
||||
selfBadge.setAttribute('tooltip', 'anonymous');
|
||||
selfBadge.style.cursor = 'default';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/a/default.png';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
selfBadge.appendChild(img);
|
||||
} else {
|
||||
selfBadge.href = `/user/${currentUser}`;
|
||||
selfBadge.setAttribute('tooltip', window.f0ckSession?.display_name || window.f0ckSession?.user || 'anonymous');
|
||||
const img = document.createElement('img');
|
||||
const avatarFile = window.f0ckSession?.avatar_file;
|
||||
const avatar = window.f0ckSession?.avatar;
|
||||
img.src = avatarFile ? `/a/${avatarFile}` : (avatar ? `/t/${avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (window.f0ckSession?.username_color) img.style.borderColor = window.f0ckSession.username_color;
|
||||
selfBadge.appendChild(img);
|
||||
}
|
||||
favcontainer.appendChild(selfBadge);
|
||||
}
|
||||
favcontainer.hidden = false;
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
const mySeq = ++favSeq;
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
|
||||
// 2. Run server operation in background — can take as long as it needs
|
||||
(async () => {
|
||||
try {
|
||||
// If viewing un-rehosted 4chan post, auto-rehost first
|
||||
if (isChan && !localId && chanUrl) {
|
||||
if (mySeq !== favSeq) return;
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const rehostResp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: chanUrl,
|
||||
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
|
||||
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
|
||||
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
|
||||
})
|
||||
});
|
||||
const rehostData = await rehostResp.json();
|
||||
if (rehostData.success && rehostData.item_id) {
|
||||
localId = rehostData.item_id;
|
||||
favoBtns.forEach(btn => {
|
||||
btn.dataset.itemId = localId;
|
||||
btn.dataset.localId = localId;
|
||||
});
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
if (commentsEl) commentsEl.dataset.itemId = localId;
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
if (infoEl) infoEl.dataset.itemId = localId;
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const timeEl = document.querySelector('time.timeago');
|
||||
if (timeEl) {
|
||||
const nowIso = new Date().toISOString();
|
||||
timeEl.dataset.iso = nowIso;
|
||||
const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
|
||||
timeEl.setAttribute('tooltip', fullDate);
|
||||
timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
if (chanRehostBtn) {
|
||||
chanRehostBtn.classList.add('rehosted');
|
||||
chanRehostBtn.outerHTML = `
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
throw new Error(rehostData.msg || 'Rehost failed');
|
||||
}
|
||||
}
|
||||
|
||||
const effectivePostId = localId ? Number(localId) : (postid || getContext()?.postid);
|
||||
if (!effectivePostId) {
|
||||
throw new Error('Missing post ID');
|
||||
}
|
||||
|
||||
if (mySeq !== favSeq) return;
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: effectivePostId,
|
||||
chan_url: chanUrl,
|
||||
action: isNowFav ? 'add' : 'delete',
|
||||
favorited: isNowFav
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
});
|
||||
|
||||
if (mySeq !== favSeq) return;
|
||||
|
||||
if (res && res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(effectivePostId);
|
||||
}
|
||||
const finalIsFav = (res.favorited !== undefined) ? !!res.favorited : isNowFav;
|
||||
setFavoUi(finalIsFav);
|
||||
|
||||
const curFavContainer = document.querySelector('#favs');
|
||||
if (curFavContainer && Array.isArray(res.favs)) {
|
||||
curFavContainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
|
||||
const fragment = document.createDocumentFragment();
|
||||
res.favs.forEach(f => {
|
||||
const isSelf = window.f0ckSession && (
|
||||
(window.f0ckSession.id && f.user_id && Number(window.f0ckSession.id) === Number(f.user_id)) ||
|
||||
(window.f0ckSession.user && f.user && window.f0ckSession.user.toLowerCase() === f.user.toLowerCase()) ||
|
||||
(window.f0ckSession.login && f.login && window.f0ckSession.login.toLowerCase() === f.login.toLowerCase())
|
||||
);
|
||||
const isFavAnon = f.is_anon || f.user === 'anonymous' || (typeof f.user === 'string' && f.user.startsWith('anon_'));
|
||||
|
||||
if (f.hide_fav_badge && !isSelf) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'ghost-fav';
|
||||
a.setAttribute('tooltip', '?');
|
||||
a.setAttribute('flow', 'up');
|
||||
a.style.cursor = 'default';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = '/s/img/ghost_fav.svg';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
|
||||
a.appendChild(img);
|
||||
fragment.appendChild(a);
|
||||
} else if (isAnonymized || isFavAnon) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'ghost-fav';
|
||||
if (isSelf) a.dataset.self = 'true';
|
||||
a.setAttribute('tooltip', 'anonymous');
|
||||
a.setAttribute('flow', 'up');
|
||||
a.style.cursor = 'default';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = '/a/default.png';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
|
||||
a.appendChild(img);
|
||||
fragment.appendChild(a);
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
if (isSelf) a.dataset.self = 'true';
|
||||
a.href = `/user/${(f.user || '').toLowerCase()}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user || 'anonymous');
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
a.appendChild(img);
|
||||
fragment.appendChild(a);
|
||||
}
|
||||
});
|
||||
curFavContainer.appendChild(fragment);
|
||||
curFavContainer.hidden = false;
|
||||
} else {
|
||||
curFavContainer.hidden = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mySeq !== favSeq) return;
|
||||
setFavoUi(wasAlreadyFav);
|
||||
const curFavContainer = document.querySelector('#favs');
|
||||
if (curFavContainer) {
|
||||
curFavContainer.innerHTML = prevFavsHtml;
|
||||
curFavContainer.hidden = prevFavsHidden;
|
||||
}
|
||||
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(errMsg, 3000, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN-FAV-ADMIN] Error during background favorite sync:', err);
|
||||
if (mySeq !== favSeq) return;
|
||||
setFavoUi(wasAlreadyFav);
|
||||
const curFavContainer = document.querySelector('#favs');
|
||||
if (curFavContainer) {
|
||||
curFavContainer.innerHTML = prevFavsHtml;
|
||||
curFavContainer.hidden = prevFavsHidden;
|
||||
}
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Failed to update favorite.', 3000, 'error');
|
||||
}
|
||||
favcontainer.hidden = false;
|
||||
} else {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
})();
|
||||
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSubItemEvent = async (subf0ck, postid) => {
|
||||
if (!subf0ck || !postid) return;
|
||||
const deleteButtonEvent = async e => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, poster, authorId } = ctx;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
const subDesc = subf0ck.display_index
|
||||
? `Slide #${subf0ck.display_index}`
|
||||
: `Slide (${subf0ck.slug || subf0ck.id})`;
|
||||
|
||||
ModAction.confirm(
|
||||
'Delete Slide from Album',
|
||||
`Are you sure you want to delete <strong style="color:#d9534f">${subDesc}</strong> from this album?<br><small style="opacity:0.8;">The rest of the album will remain intact.</small>`,
|
||||
async (reason) => {
|
||||
const res = await post("/api/v2/admin/delete-album-item", {
|
||||
postid: postid,
|
||||
sub_id: subf0ck.id,
|
||||
sub_slug: subf0ck.slug,
|
||||
order_index: subf0ck.order_index,
|
||||
reason: reason
|
||||
});
|
||||
if (!res.success) {
|
||||
throw new Error(res.msg || 'Failed to delete album item');
|
||||
}
|
||||
if (res.post_deleted) {
|
||||
if (window.flashMessage) window.flashMessage('Album deleted (no remaining items)', 2500, 'info');
|
||||
const mediaObj = document.querySelector('.media-object');
|
||||
if (mediaObj) {
|
||||
mediaObj.innerHTML = '<div style="padding: 100px; text-align: center; color: #d9534f;"><h1>Album Deleted</h1><p>The album has been removed.</p></div>';
|
||||
}
|
||||
} else {
|
||||
if (window.albumGallery && typeof window.albumGallery.removeSubf0ck === 'function') {
|
||||
window.albumGallery.removeSubf0ck(subf0ck.id || subf0ck.slug || subf0ck.order_index);
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
if (window.flashMessage) window.flashMessage('Slide deleted from album', 2500, 'success');
|
||||
}
|
||||
},
|
||||
{ allowEmpty: window.f0ckSession?.is_admin, confirmText: 'Delete Slide' }
|
||||
);
|
||||
};
|
||||
|
||||
const deleteEntirePost = (postid, poster, authorId) => {
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const confirmTitle = i18n.item_delete_title || 'Delete Item';
|
||||
const posterStr = poster
|
||||
@@ -646,68 +267,6 @@
|
||||
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||
};
|
||||
|
||||
const deleteButtonEvent = async e => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, poster, authorId } = ctx;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
const isAlbum = !!(window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function');
|
||||
const curSub = isAlbum ? window.albumGallery.getCurrentSubf0ck() : null;
|
||||
|
||||
if (isAlbum && curSub) {
|
||||
const subDesc = curSub.display_index ? `Slide ${curSub.display_index}` : 'Current Slide';
|
||||
const choiceHtml = `
|
||||
<div style="margin-bottom: 15px; font-size: 1.05rem;">
|
||||
This post is an album containing multiple slides. What would you like to delete?
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; justify-content: center; flex-wrap: wrap;">
|
||||
<button type="button" id="btn-choice-delete-sub" class="btn btn-warning" style="padding: 8px 16px; font-weight: 600; cursor: pointer;">
|
||||
<i class="fa-solid fa-trash-can"></i> Delete ${subDesc} Only
|
||||
</button>
|
||||
<button type="button" id="btn-choice-delete-album" class="btn btn-danger" style="padding: 8px 16px; font-weight: 600; cursor: pointer;">
|
||||
<i class="fa-solid fa-layer-group"></i> Delete Entire Album
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
ModAction.confirm('Delete Options', choiceHtml, () => {}, {
|
||||
hideReason: true,
|
||||
hideConfirm: true,
|
||||
unsafeContent: true,
|
||||
cancelText: 'Cancel'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
const modal = document.getElementById('mod-action-modal');
|
||||
if (!modal) return;
|
||||
const subBtn = modal.querySelector('#btn-choice-delete-sub');
|
||||
const albumBtn = modal.querySelector('#btn-choice-delete-album');
|
||||
if (subBtn) {
|
||||
subBtn.onclick = () => {
|
||||
modal.style.display = 'none';
|
||||
deleteSubItemEvent(curSub, postid);
|
||||
};
|
||||
}
|
||||
if (albumBtn) {
|
||||
albumBtn.onclick = () => {
|
||||
modal.style.display = 'none';
|
||||
deleteEntirePost(postid, poster, authorId);
|
||||
};
|
||||
}
|
||||
}, 50);
|
||||
return;
|
||||
}
|
||||
|
||||
deleteEntirePost(postid, poster, authorId);
|
||||
};
|
||||
|
||||
let tmptt = null;
|
||||
const editTagEvent = async e => {
|
||||
e.preventDefault();
|
||||
@@ -774,87 +333,17 @@
|
||||
addtagClick(e);
|
||||
} else if (target.closest("#a_delete")) {
|
||||
deleteButtonEvent(e);
|
||||
} else if (target.closest(".album-sub-delete-btn, #a_delete_sub")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
const ctx = getContext();
|
||||
const curSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
|
||||
? window.albumGallery.getCurrentSubf0ck()
|
||||
: null;
|
||||
if (ctx && curSub) {
|
||||
deleteSubItemEvent(curSub, ctx.postid);
|
||||
}
|
||||
} else if (target.matches('#tags .badge > a[href*="/tag/"]')) {
|
||||
editTagEvent(e);
|
||||
} else if (target.closest('.admin-deltag') || target.closest('.removetag')) {
|
||||
deleteEvent(e);
|
||||
} else if (target.closest("#a_pin")) {
|
||||
pinButtonEvent(e);
|
||||
} else if (target.closest("#a_unavailable")) {
|
||||
unavailableButtonEvent(e);
|
||||
} else if (target.closest("#a_favo")) {
|
||||
toggleFavEvent(e);
|
||||
}
|
||||
});
|
||||
|
||||
const unavailableButtonEvent = async e => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const unavBtn = document.querySelector('#a_unavailable');
|
||||
if (!unavBtn) return;
|
||||
|
||||
const currentVis = parseInt(unavBtn.getAttribute('data-visibility') || '0', 10);
|
||||
const willBeUnavailable = currentVis !== 3;
|
||||
const targetVis = willBeUnavailable ? 3 : 0;
|
||||
const actionText = willBeUnavailable ? 'Make Unavailable (serves HTTP 451 to non-logged in visitors)' : 'Make Available (Public)';
|
||||
|
||||
const proceed = async () => {
|
||||
try {
|
||||
const res = await (await fetch('/api/v2/item/visibility', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRF-Token': window.f0ckSession?.csrf_token
|
||||
},
|
||||
body: new URLSearchParams({ postid, id: postid, visibility: targetVis })
|
||||
})).json();
|
||||
|
||||
if (res.success) {
|
||||
const isNowUnav = res.visibility === 3;
|
||||
unavBtn.setAttribute('data-visibility', res.visibility);
|
||||
unavBtn.classList.toggle('active', isNowUnav);
|
||||
unavBtn.style.color = isNowUnav ? 'var(--danger, #ff4444)' : '';
|
||||
unavBtn.setAttribute('title', isNowUnav ? 'Make Available (Public)' : 'Make Unavailable (451)');
|
||||
|
||||
const infoVisLabel = document.getElementById('info-visibility-label');
|
||||
if (infoVisLabel) {
|
||||
infoVisLabel.innerHTML = isNowUnav
|
||||
? '<i class="fa-solid fa-ban" style="color: var(--color-danger, #ff4444);"></i> Unavailable (451)'
|
||||
: '<i class="fa-solid fa-globe" style="color: var(--color-success, #00C851);"></i> Public';
|
||||
}
|
||||
const infoVisBtn = document.getElementById('info-visibility-edit-btn');
|
||||
if (infoVisBtn) infoVisBtn.dataset.visibility = res.visibility;
|
||||
|
||||
window.flashMessage(isNowUnav ? 'ITEM MARKED UNAVAILABLE (451)' : 'ITEM RESTORED TO PUBLIC');
|
||||
} else {
|
||||
alert('Error: ' + (res.msg || 'Failed to update visibility'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unavailable error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof ModAction !== 'undefined' && ModAction.confirm) {
|
||||
ModAction.confirm('Item Visibility', `${actionText} for post <strong>#${postid}</strong>?`, proceed);
|
||||
} else if (confirm(`${actionText} for post #${postid}?`)) {
|
||||
proceed();
|
||||
}
|
||||
};
|
||||
|
||||
const pinButtonEvent = async e => {
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
|
||||
@@ -1,748 +0,0 @@
|
||||
/**
|
||||
* f0ckm Anonymous Passkey Identity Manager
|
||||
*
|
||||
* Replaces the old OpenSSH Ed25519 approach.
|
||||
* Private keys NEVER touch localStorage — they live in the OS / Bitwarden credential store.
|
||||
*
|
||||
* Flow:
|
||||
* First visit: "Login as Anonymous" → register/begin → browser passkey prompt → register/finish → session
|
||||
* Return visit: "Login as Anonymous" → auth/begin → browser passkey picker → auth/finish → session
|
||||
*
|
||||
* For registered users:
|
||||
* Settings page calls window.f0ckPasskeyManager.addPasskey() to add a passkey.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ─── base64url helpers (browser) ───────────────────────────────────────────
|
||||
|
||||
function b64urlToArr(b64) {
|
||||
const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/'));
|
||||
const arr = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
||||
return arr;
|
||||
}
|
||||
|
||||
function arrToB64url(buf) {
|
||||
const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : new Uint8Array(buf);
|
||||
let bin = '';
|
||||
arr.forEach(b => bin += String.fromCharCode(b));
|
||||
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// ─── Hardware fingerprint (kept for ban enforcement) ──────────────────────
|
||||
|
||||
async function getHardwareFingerprint() {
|
||||
// Disabled by the operator (websrv.anon_hw_fingerprint: false): compute nothing, forget any cached value
|
||||
if (window.f0ckHwFingerprint === false) {
|
||||
try { localStorage.removeItem('f0ck_anon_hw_fp'); } catch (e) {}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const cached = localStorage.getItem('f0ck_anon_hw_fp');
|
||||
if (cached) return cached;
|
||||
} catch (e) {}
|
||||
|
||||
try {
|
||||
let glVendor = '', glRenderer = '', glLimits = '';
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
|
||||
if (gl) {
|
||||
const ext = gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if (ext) {
|
||||
glVendor = gl.getParameter(ext.UNMASKED_VENDOR_WEBGL) || '';
|
||||
glRenderer = gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) || '';
|
||||
}
|
||||
glLimits = [
|
||||
gl.getParameter(gl.MAX_TEXTURE_SIZE) || 0,
|
||||
gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 0,
|
||||
gl.getParameter(gl.MAX_VERTEX_ATTRIBS) || 0,
|
||||
gl.getParameter(gl.MAX_VERTEX_UNIFORM_VECTORS) || 0,
|
||||
gl.getParameter(gl.MAX_VARYING_VECTORS) || 0,
|
||||
gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) || 0
|
||||
].join(',');
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let gpuArch = '';
|
||||
try {
|
||||
if (navigator.gpu) {
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
if (adapter && adapter.info) {
|
||||
gpuArch = [adapter.info.architecture, adapter.info.vendor, adapter.info.description].filter(Boolean).join(':');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const concurrency = navigator.hardwareConcurrency || 0;
|
||||
const memory = navigator.deviceMemory || 0;
|
||||
const platform = navigator.platform || '';
|
||||
const screenInfo = [
|
||||
window.screen ? window.screen.width : 0,
|
||||
window.screen ? window.screen.height : 0,
|
||||
window.screen ? window.screen.colorDepth : 0,
|
||||
window.devicePixelRatio || 1
|
||||
].join('x');
|
||||
const touchPoints = navigator.maxTouchPoints || 0;
|
||||
|
||||
let canvasFp = '';
|
||||
try {
|
||||
const c2d = document.createElement('canvas');
|
||||
c2d.width = 240; c2d.height = 60;
|
||||
const ctx = c2d.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.fillStyle = '#f60'; ctx.fillRect(10, 5, 60, 20);
|
||||
ctx.fillStyle = '#069'; ctx.font = '14pt Arial, sans-serif';
|
||||
ctx.fillText('f0ck.dev 😃', 4, 35);
|
||||
const imgData = ctx.getImageData(0, 0, 240, 60).data;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < imgData.length; i += 4) {
|
||||
sum = (sum * 31 + imgData[i] + imgData[i+1] + imgData[i+2] + imgData[i+3]) >>> 0;
|
||||
}
|
||||
canvasFp = sum.toString(16);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
let audioFp = '';
|
||||
try {
|
||||
const AudioCtx = window.OfflineAudioContext || window.webkitOfflineAudioContext;
|
||||
if (AudioCtx) {
|
||||
const actx = new AudioCtx(1, 44100, 44100);
|
||||
const osc = actx.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(10000, actx.currentTime);
|
||||
const comp = actx.createDynamicsCompressor();
|
||||
comp.threshold.setValueAtTime(-50, actx.currentTime);
|
||||
comp.knee.setValueAtTime(40, actx.currentTime);
|
||||
comp.ratio.setValueAtTime(12, actx.currentTime);
|
||||
comp.attack.setValueAtTime(0, actx.currentTime);
|
||||
comp.release.setValueAtTime(0.25, actx.currentTime);
|
||||
osc.connect(comp); comp.connect(actx.destination); osc.start(0);
|
||||
const buf = await actx.startRendering();
|
||||
const ch = buf.getChannelData(0);
|
||||
let sum = 0;
|
||||
for (let i = 4500; i < Math.min(ch.length, 5000); i++) sum += Math.abs(ch[i] || 0);
|
||||
audioFp = sum.toFixed(7);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const raw = [glVendor, glRenderer, glLimits, gpuArch, concurrency, memory, platform, screenInfo, touchPoints, canvasFp, audioFp].join('~~~');
|
||||
const hashBuf = await window.crypto.subtle.digest('SHA-256', new TextEncoder().encode(raw));
|
||||
const hashHex = Array.from(new Uint8Array(hashBuf)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const fp = `HW:${hashHex}`;
|
||||
try { localStorage.setItem('f0ck_anon_hw_fp', fp); } catch (e) {}
|
||||
return fp;
|
||||
} catch (err) {
|
||||
console.warn('[ANON_PASSKEY] Failed to compute hardware fingerprint:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Tombstone (ban state persisted client-side) ───────────────────────────
|
||||
|
||||
function getTombstone() {
|
||||
try { return JSON.parse(localStorage.getItem('f0ck_anon_tombstone') || 'null'); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function setTombstone(t) {
|
||||
try {
|
||||
localStorage.setItem('f0ck_anon_tombstone', JSON.stringify(t));
|
||||
if (t && t.banned) {
|
||||
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(t))}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ─── AnonPasskey class ────────────────────────────────────────────────────
|
||||
|
||||
class AnonPasskey {
|
||||
constructor() {
|
||||
this.isSessionReady = false;
|
||||
this._loginInProgress = false;
|
||||
}
|
||||
|
||||
// ── Check WebAuthn support ──────────────────────────────────────────────
|
||||
|
||||
get supported() {
|
||||
return !!(window.PublicKeyCredential && navigator.credentials && navigator.credentials.create);
|
||||
}
|
||||
|
||||
// ── UI helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
updateNavUI(isAnon) {
|
||||
const icon = document.getElementById('nav-visitor-icon');
|
||||
const label = document.getElementById('nav-anon-label');
|
||||
if (icon) {
|
||||
icon.classList.toggle('fa-user-secret', isAnon);
|
||||
icon.classList.toggle('fa-user', !isAnon);
|
||||
}
|
||||
if (label) label.textContent = isAnon ? 'anonymous' : 'guest';
|
||||
|
||||
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
|
||||
if (label) label.textContent = 'guest';
|
||||
['nav-login-anon-btn', 'nav-anon-identity-btn', 'nav-anon-settings-btn', 'nav-anon-logout-btn', 'nav-anon-divider'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
|
||||
if (modalAnonBtn) {
|
||||
const w = modalAnonBtn.closest('div');
|
||||
if (w) w.style.display = 'none'; else modalAnonBtn.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const loginAnonBtn = document.getElementById('nav-login-anon-btn');
|
||||
const anonIdentityBtn = document.getElementById('nav-anon-identity-btn');
|
||||
const anonSettingsBtn = document.getElementById('nav-anon-settings-btn');
|
||||
const anonLogoutBtn = document.getElementById('nav-anon-logout-btn');
|
||||
const anonDivider = document.getElementById('nav-anon-divider');
|
||||
const guestFavsNav = document.getElementById('nav-guest-favs');
|
||||
const guestFavsLink = document.getElementById('nav-guest-favs-link');
|
||||
|
||||
if (loginAnonBtn) loginAnonBtn.style.display = isAnon ? 'none' : '';
|
||||
if (anonIdentityBtn) anonIdentityBtn.style.display = isAnon ? '' : 'none';
|
||||
if (anonSettingsBtn) anonSettingsBtn.style.display = isAnon ? '' : 'none';
|
||||
if (anonLogoutBtn) anonLogoutBtn.style.display = isAnon ? '' : 'none';
|
||||
if (anonDivider) anonDivider.style.display = isAnon ? '' : 'none';
|
||||
if (guestFavsNav) guestFavsNav.style.display = isAnon ? '' : 'none';
|
||||
if (guestFavsLink) guestFavsLink.style.display = isAnon ? '' : 'none';
|
||||
|
||||
if (isAnon && window.f0ckSession) {
|
||||
window.f0ckSession.user = window.f0ckSession.user || 'anonymous';
|
||||
window.f0ckSession.is_anon = true;
|
||||
window.f0ckSession.logged_in = true;
|
||||
}
|
||||
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
|
||||
}
|
||||
|
||||
_applySessionData(data, hwFingerprint) {
|
||||
this.isSessionReady = true;
|
||||
if (window.f0ckSession) {
|
||||
window.f0ckSession.user = 'anonymous';
|
||||
window.f0ckSession.is_anon = true;
|
||||
window.f0ckSession.logged_in = true;
|
||||
window.f0ckSession.id = data.user_id;
|
||||
window.f0ckSession.user_id = data.user_id;
|
||||
if (data.csrf_token) window.f0ckSession.csrf_token = data.csrf_token;
|
||||
}
|
||||
const metaCsrf = document.querySelector('meta[name="csrf-token"]');
|
||||
if (metaCsrf && data.csrf_token) metaCsrf.content = data.csrf_token;
|
||||
window.f0ckAnonIdentity = {
|
||||
userId: data.user_id,
|
||||
fingerprint: data.fingerprint,
|
||||
shortFingerprint: data.short_fingerprint,
|
||||
hwFingerprint: data.hw_fingerprint || hwFingerprint,
|
||||
credentialId: data.credential_id
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent('f0ck:anon_session_ready', { detail: window.f0ckAnonIdentity }));
|
||||
if (typeof window.syncRatingButtonUI === 'function') window.syncRatingButtonUI();
|
||||
}
|
||||
|
||||
// ── Register a new passkey (anonymous user) ────────────────────────────
|
||||
|
||||
async register() {
|
||||
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
|
||||
|
||||
// 1. Get registration options from server
|
||||
const beginRes = await fetch('/api/v2/anon/passkey/register/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const beginData = await beginRes.json();
|
||||
if (!beginData.success) throw new Error(beginData.msg || 'Server error during registration setup');
|
||||
|
||||
const rawChallenge = beginData.options.challenge;
|
||||
const opts = beginData.options;
|
||||
|
||||
// 2. Decode options for the WebAuthn API
|
||||
const pkOpts = {
|
||||
rp: opts.rp,
|
||||
user: {
|
||||
id: b64urlToArr(opts.user.id),
|
||||
name: opts.user.name,
|
||||
displayName: opts.user.displayName
|
||||
},
|
||||
challenge: b64urlToArr(rawChallenge),
|
||||
pubKeyCredParams: opts.pubKeyCredParams,
|
||||
timeout: opts.timeout || 60000,
|
||||
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
|
||||
authenticatorSelection: opts.authenticatorSelection,
|
||||
attestation: opts.attestation || 'none'
|
||||
};
|
||||
|
||||
// 3. Browser passkey creation prompt
|
||||
const cred = await navigator.credentials.create({ publicKey: pkOpts });
|
||||
|
||||
// 4. Send attestation to server
|
||||
const hwFp = await getHardwareFingerprint();
|
||||
const tombstone = getTombstone();
|
||||
const finishRes = await fetch('/api/v2/anon/passkey/register/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge: rawChallenge,
|
||||
credentialId: arrToB64url(cred.rawId),
|
||||
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
|
||||
attestationObject: arrToB64url(cred.response.attestationObject),
|
||||
hw_fingerprint: hwFp,
|
||||
tombstone: tombstone
|
||||
})
|
||||
});
|
||||
const finishData = await finishRes.json();
|
||||
|
||||
if (finishData.banned) {
|
||||
setTombstone({
|
||||
banned: true,
|
||||
fingerprint: finishData.fingerprint,
|
||||
hw_fingerprint: finishData.hw_fingerprint || hwFp,
|
||||
reason: finishData.reason,
|
||||
expires: finishData.expires
|
||||
});
|
||||
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
|
||||
throw new Error('Banned: ' + (finishData.reason || ''));
|
||||
}
|
||||
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
|
||||
|
||||
// Mark this browser as having a registered passkey for this site
|
||||
this._markLocalPasskey();
|
||||
return finishData;
|
||||
}
|
||||
|
||||
// ── Authenticate with an existing passkey (anonymous user) ────────────
|
||||
|
||||
async authenticate() {
|
||||
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
|
||||
|
||||
// 1. Get auth challenge from server
|
||||
const beginRes = await fetch('/api/v2/anon/passkey/auth/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const beginData = await beginRes.json();
|
||||
if (!beginData.success) throw new Error(beginData.msg || 'Server error during authentication setup');
|
||||
|
||||
const rawChallenge = beginData.options.challenge;
|
||||
const opts = beginData.options;
|
||||
|
||||
// 2. Invoke browser passkey picker
|
||||
const pkOpts = {
|
||||
challenge: b64urlToArr(rawChallenge),
|
||||
rpId: opts.rpId,
|
||||
userVerification: opts.userVerification || 'preferred',
|
||||
timeout: opts.timeout || 60000,
|
||||
allowCredentials: (opts.allowCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) }))
|
||||
};
|
||||
|
||||
const assertion = await navigator.credentials.get({ publicKey: pkOpts });
|
||||
|
||||
// 3. Send assertion to server
|
||||
const hwFp = await getHardwareFingerprint();
|
||||
const tombstone = getTombstone();
|
||||
const finishRes = await fetch('/api/v2/anon/passkey/auth/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challenge: rawChallenge,
|
||||
credentialId: arrToB64url(assertion.rawId),
|
||||
clientDataJSON: arrToB64url(assertion.response.clientDataJSON),
|
||||
authenticatorData: arrToB64url(assertion.response.authenticatorData),
|
||||
signature: arrToB64url(assertion.response.signature),
|
||||
hw_fingerprint: hwFp,
|
||||
tombstone: tombstone
|
||||
})
|
||||
});
|
||||
const finishData = await finishRes.json();
|
||||
|
||||
if (finishData.banned) {
|
||||
setTombstone({
|
||||
banned: true,
|
||||
fingerprint: finishData.fingerprint,
|
||||
hw_fingerprint: finishData.hw_fingerprint || hwFp,
|
||||
reason: finishData.reason,
|
||||
expires: finishData.expires
|
||||
});
|
||||
if (window.location.pathname !== '/banned') window.location.href = finishData.redirect || '/banned';
|
||||
throw new Error('Banned: ' + (finishData.reason || ''));
|
||||
}
|
||||
if (!finishData.success) throw new Error(finishData.msg || 'Authentication failed');
|
||||
|
||||
return finishData;
|
||||
}
|
||||
|
||||
// ── Add a passkey to the current anonymous identity ───────────────────
|
||||
|
||||
async addPasskey() {
|
||||
if (!this.supported) throw new Error('WebAuthn / Passkeys are not supported in this browser.');
|
||||
|
||||
const csrfToken = (window.f0ckSession && window.f0ckSession.csrf_token)
|
||||
|| document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const beginRes = await fetch('/api/v2/anon/passkey/add/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
const beginData = await beginRes.json();
|
||||
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
|
||||
|
||||
const rawChallenge = beginData.options.challenge;
|
||||
const opts = beginData.options;
|
||||
|
||||
const cred = await navigator.credentials.create({ publicKey: {
|
||||
rp: opts.rp,
|
||||
user: {
|
||||
id: b64urlToArr(opts.user.id),
|
||||
name: opts.user.name,
|
||||
displayName: opts.user.displayName
|
||||
},
|
||||
challenge: b64urlToArr(rawChallenge),
|
||||
pubKeyCredParams: opts.pubKeyCredParams,
|
||||
timeout: opts.timeout || 60000,
|
||||
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
|
||||
authenticatorSelection: opts.authenticatorSelection,
|
||||
attestation: opts.attestation || 'none'
|
||||
}});
|
||||
|
||||
const finishRes = await fetch('/api/v2/anon/passkey/add/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
||||
body: JSON.stringify({
|
||||
challenge: rawChallenge,
|
||||
credentialId: arrToB64url(cred.rawId),
|
||||
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
|
||||
attestationObject: arrToB64url(cred.response.attestationObject),
|
||||
hw_fingerprint: await getHardwareFingerprint()
|
||||
})
|
||||
});
|
||||
const finishData = await finishRes.json();
|
||||
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
|
||||
return finishData;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
_hasLocalPasskey() {
|
||||
try { return !!localStorage.getItem('f0ck_anon_has_passkey'); } catch (e) { return false; }
|
||||
}
|
||||
|
||||
_markLocalPasskey() {
|
||||
try { localStorage.setItem('f0ck_anon_has_passkey', '1'); } catch (e) {}
|
||||
}
|
||||
|
||||
async _finishLogin(data) {
|
||||
const hwFp = await getHardwareFingerprint();
|
||||
this._applySessionData(data, hwFp);
|
||||
if (window.f0ckGuestFavs && typeof window.f0ckGuestFavs.importToAccount === 'function') {
|
||||
await window.f0ckGuestFavs.importToAccount();
|
||||
}
|
||||
this.updateNavUI(true);
|
||||
// Close both modals
|
||||
const sm = document.getElementById('anon-setup-modal'); if (sm) sm.style.display = 'none';
|
||||
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
|
||||
if (typeof window.showToastNotification === 'function') {
|
||||
window.showToastNotification('Logged in as anonymous');
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// ── Public: called by the setup modal's "Create my passkey" button ────────
|
||||
|
||||
async doRegister() {
|
||||
if (this._loginInProgress) return;
|
||||
this._loginInProgress = true;
|
||||
try {
|
||||
const data = await this.register(); // throws on error/cancel
|
||||
this._markLocalPasskey();
|
||||
await this._finishLogin(data);
|
||||
} catch (err) {
|
||||
this._loginInProgress = false;
|
||||
throw err; // modal handles the error display
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public: called by the setup modal's "Use my passkey" button ───────────
|
||||
|
||||
async doAuthenticate() {
|
||||
if (this._loginInProgress) return;
|
||||
this._loginInProgress = true;
|
||||
try {
|
||||
const data = await this.authenticate();
|
||||
await this._finishLogin(data);
|
||||
} catch (err) {
|
||||
this._loginInProgress = false;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public: opens the setup modal (new vs returning view) ─────────────────
|
||||
|
||||
openSetupModal() {
|
||||
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) return;
|
||||
const tombstone = getTombstone();
|
||||
if (tombstone && tombstone.banned) {
|
||||
if (window.location.pathname !== '/banned') window.location.href = '/banned';
|
||||
return;
|
||||
}
|
||||
if (!this.supported) {
|
||||
alert('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Close the login modal first
|
||||
const lm = document.getElementById('login-modal'); if (lm) lm.style.display = 'none';
|
||||
|
||||
const hasPasskey = this._hasLocalPasskey();
|
||||
const newView = document.getElementById('anon-setup-new');
|
||||
const retView = document.getElementById('anon-setup-returning');
|
||||
if (newView) newView.style.display = hasPasskey ? 'none' : '';
|
||||
if (retView) retView.style.display = hasPasskey ? '' : 'none';
|
||||
|
||||
const modal = document.getElementById('anon-setup-modal');
|
||||
if (modal) modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
// ── Legacy: clicking "Login as anonymous" anywhere just opens the modal ───
|
||||
|
||||
loginAsAnonymous() {
|
||||
this.openSetupModal();
|
||||
}
|
||||
|
||||
// ── Logout ────────────────────────────────────────────────────────────────
|
||||
|
||||
async logoutAnonymous() {
|
||||
try {
|
||||
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||||
} finally {
|
||||
this.updateNavUI(false);
|
||||
if (typeof window.showToastNotification === 'function') {
|
||||
window.showToastNotification('Logged out from anonymous session');
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity modal (shown when already logged in as anon) ─────────────────
|
||||
|
||||
openModal() {
|
||||
const modal = document.getElementById('anon-passkey-modal');
|
||||
if (!modal) return;
|
||||
this._refreshModalContent();
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
const modal = document.getElementById('anon-passkey-modal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
|
||||
async _refreshModalContent() {
|
||||
try {
|
||||
const res = await fetch('/api/v2/anon/identity');
|
||||
const data = await res.json();
|
||||
const fpEl = document.getElementById('anon-pk-fp-display');
|
||||
if (fpEl) fpEl.textContent = data.fingerprint ? data.fingerprint.slice(7, 15) : 'none';
|
||||
this._renderPasskeyCount(data.passkey_count, data.passkey_max);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_renderPasskeyCount(count, max) {
|
||||
const countEl = document.getElementById('anon-pk-count');
|
||||
const maxEl = document.getElementById('anon-pk-max');
|
||||
const addBtn = document.getElementById('anon-pk-add-btn');
|
||||
if (countEl && typeof count === 'number') countEl.textContent = count;
|
||||
if (maxEl && typeof max === 'number') maxEl.textContent = max;
|
||||
if (addBtn && typeof count === 'number' && typeof max === 'number') {
|
||||
const full = count >= max;
|
||||
addBtn.disabled = full;
|
||||
addBtn.innerHTML = full
|
||||
? '<i class="fa-solid fa-lock"></i> Limit reached'
|
||||
: '<i class="fa-solid fa-plus"></i> Add passkey';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async init() {
|
||||
if (window.location.pathname === '/banned') return;
|
||||
|
||||
if (window.f0ckSession && window.f0ckSession.enable_anonymous_access === false) {
|
||||
if (window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
|
||||
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
this.updateNavUI(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Registered user — nothing to do
|
||||
if (window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon) return;
|
||||
|
||||
// Check tombstone — if banned, don't auto-login
|
||||
const tombstone = getTombstone();
|
||||
if (tombstone && tombstone.banned) {
|
||||
this.updateNavUI(false);
|
||||
this.attachUIListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// If backend session already shows is_anon, mark ready
|
||||
if (window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
|
||||
this.isSessionReady = true;
|
||||
this.updateNavUI(true);
|
||||
} else {
|
||||
// Stale backend session without a local key no longer applies — just show guest
|
||||
if (window.f0ckSession && window.f0ckSession.is_anon && !window.f0ckSession.logged_in) {
|
||||
await fetch('/api/v2/anon/logout', { method: 'POST', credentials: 'same-origin' }).catch(() => {});
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
this.updateNavUI(false);
|
||||
}
|
||||
|
||||
this.attachUIListeners();
|
||||
}
|
||||
|
||||
attachUIListeners() {
|
||||
// Modal login-as-anonymous button
|
||||
const modalAnonBtn = document.getElementById('modal-login-as-anon-btn');
|
||||
if (modalAnonBtn) {
|
||||
modalAnonBtn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); this.loginAsAnonymous(); });
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('#nav-anon-identity-btn')) {
|
||||
e.preventDefault(); this.openModal(); return;
|
||||
}
|
||||
if (e.target.closest('#nav-login-anon-btn, #modal-login-as-anon-btn, .trigger-login-anon')) {
|
||||
e.preventDefault(); this.loginAsAnonymous(); return;
|
||||
}
|
||||
const logoutTarget = e.target.closest('#nav-anon-logout-btn, a[href="/logout"]');
|
||||
if (logoutTarget && window.f0ckSession && window.f0ckSession.is_anon && window.f0ckSession.logged_in) {
|
||||
e.preventDefault(); this.logoutAnonymous(); return;
|
||||
}
|
||||
});
|
||||
|
||||
const anonBtn = document.getElementById('nav-anon-identity-btn');
|
||||
if (anonBtn) anonBtn.addEventListener('click', e => { e.preventDefault(); this.openModal(); });
|
||||
|
||||
const modalClose = document.getElementById('anon-passkey-modal-close');
|
||||
if (modalClose) modalClose.addEventListener('click', () => this.closeModal());
|
||||
|
||||
const modalOverlay = document.getElementById('anon-passkey-modal');
|
||||
if (modalOverlay) modalOverlay.addEventListener('click', e => { if (e.target === modalOverlay) this.closeModal(); });
|
||||
|
||||
// Add-passkey button inside modal — attaches a new passkey to this identity (max enforced server-side)
|
||||
const addBtn = document.getElementById('anon-pk-add-btn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', async () => {
|
||||
const errEl = document.getElementById('anon-pk-error');
|
||||
if (errEl) errEl.style.display = 'none';
|
||||
addBtn.disabled = true;
|
||||
try {
|
||||
const data = await this.addPasskey();
|
||||
if (typeof window.showToastNotification === 'function') window.showToastNotification('Passkey added');
|
||||
this._renderPasskeyCount(data.passkey_count, data.passkey_max);
|
||||
} catch (err) {
|
||||
console.warn('[ANON_PASSKEY] Modal add passkey error:', err);
|
||||
if (errEl) {
|
||||
errEl.style.display = '';
|
||||
errEl.textContent = (err && err.name === 'NotAllowedError')
|
||||
? 'Cancelled or blocked. Unlock your password manager and try again.'
|
||||
: ((err && err.message) || 'Failed to add passkey.');
|
||||
}
|
||||
addBtn.disabled = false;
|
||||
this._refreshModalContent();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Passkey manager for registered users (used by settings page) ──────────
|
||||
|
||||
class PasskeyManager {
|
||||
async listPasskeys() {
|
||||
const res = await fetch('/api/v2/settings/passkeys');
|
||||
return (await res.json()).passkeys || [];
|
||||
}
|
||||
|
||||
async addPasskey(name) {
|
||||
// 1. Begin
|
||||
const beginRes = await fetch('/api/v2/settings/passkeys/register/begin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name || 'Passkey' })
|
||||
});
|
||||
const beginData = await beginRes.json();
|
||||
if (!beginData.success) throw new Error(beginData.msg || 'Server error');
|
||||
|
||||
const rawChallenge = beginData.options.challenge;
|
||||
const opts = beginData.options;
|
||||
|
||||
const pkOpts = {
|
||||
rp: opts.rp,
|
||||
user: {
|
||||
id: b64urlToArr(opts.user.id),
|
||||
name: opts.user.name,
|
||||
displayName: opts.user.displayName
|
||||
},
|
||||
challenge: b64urlToArr(rawChallenge),
|
||||
pubKeyCredParams: opts.pubKeyCredParams,
|
||||
timeout: opts.timeout || 60000,
|
||||
excludeCredentials: (opts.excludeCredentials || []).map(c => ({ type: c.type, id: b64urlToArr(c.id) })),
|
||||
authenticatorSelection: opts.authenticatorSelection,
|
||||
attestation: opts.attestation || 'none'
|
||||
};
|
||||
|
||||
const cred = await navigator.credentials.create({ publicKey: pkOpts });
|
||||
|
||||
// 2. Finish
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const finishRes = await fetch('/api/v2/settings/passkeys/register/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
||||
body: JSON.stringify({
|
||||
challenge: rawChallenge,
|
||||
credentialId: arrToB64url(cred.rawId),
|
||||
clientDataJSON: arrToB64url(cred.response.clientDataJSON),
|
||||
attestationObject: arrToB64url(cred.response.attestationObject),
|
||||
name: name || 'Passkey'
|
||||
})
|
||||
});
|
||||
const finishData = await finishRes.json();
|
||||
if (!finishData.success) throw new Error(finishData.msg || 'Registration failed');
|
||||
return finishData;
|
||||
}
|
||||
|
||||
async deletePasskey(credentialId) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const res = await fetch('/api/v2/settings/passkeys/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': csrfToken },
|
||||
body: JSON.stringify({ credential_id: credentialId })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.msg || 'Delete failed');
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bootstrap ────────────────────────────────────────────────────────────
|
||||
|
||||
window.f0ckAnonPasskey = new AnonPasskey();
|
||||
window.f0ckPasskeyManager = new PasskeyManager();
|
||||
|
||||
// Backwards compat alias (so any code that checks window.f0ckAnonSSH still works for guards)
|
||||
window.f0ckAnonSSH = window.f0ckAnonPasskey;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => window.f0ckAnonPasskey.init());
|
||||
} else {
|
||||
window.f0ckAnonPasskey.init();
|
||||
}
|
||||
})();
|
||||
+44
-327
@@ -27,8 +27,6 @@ class CommentSystem {
|
||||
if (this.displayMode === 1) this.sort = 'old';
|
||||
|
||||
this.customEmojis = CommentSystem.emojiCache || {};
|
||||
this._selfPostedCommentIds = new Set();
|
||||
this._pendingSelfCommentTexts = new Set();
|
||||
|
||||
this.icons = {
|
||||
reply: `<i class="fa-solid fa-reply"></i>`,
|
||||
@@ -47,36 +45,21 @@ class CommentSystem {
|
||||
// inline script in header.html (prevents flash). Here we sync the
|
||||
// container-level class and display state to match.
|
||||
if (this.container) {
|
||||
// A slide cut short by navigating away must not leave its border behind
|
||||
this._legacySlide = null;
|
||||
document.body.classList.remove('comments-sliding');
|
||||
const isHidden = localStorage.getItem('comments_hidden') === 'true';
|
||||
// Force show if hash is present
|
||||
if (window.location.hash && window.location.hash.startsWith('#c')) {
|
||||
this.container.classList.remove('faded-out', 'is-hidden');
|
||||
this.container.style.removeProperty('display');
|
||||
this.container.style.display = '';
|
||||
this.container.classList.remove('faded-out');
|
||||
this.container.style.display = 'block';
|
||||
localStorage.setItem('comments_hidden', 'false');
|
||||
document.body.classList.remove('sidebar-left-hidden', 'comments-hidden');
|
||||
document.body.classList.remove('sidebar-left-hidden');
|
||||
const layout = this.container.closest('.item-layout-container');
|
||||
if (layout) layout.classList.remove('sidebar-hidden');
|
||||
const sidebar = this.container.closest('.item-sidebar-left');
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('is-hidden');
|
||||
sidebar.style.removeProperty('display');
|
||||
sidebar.style.display = '';
|
||||
}
|
||||
} else if (isHidden) {
|
||||
this.container.classList.add('faded-out', 'is-hidden');
|
||||
this.container.style.setProperty('display', 'none', 'important');
|
||||
document.body.classList.add('sidebar-left-hidden', 'comments-hidden');
|
||||
} else if (isHidden && (window.innerWidth >= 1000 || !document.body.classList.contains('layout-modern'))) {
|
||||
this.container.classList.add('faded-out');
|
||||
this.container.style.display = 'none';
|
||||
document.body.classList.add('sidebar-left-hidden');
|
||||
const layout = this.container.closest('.item-layout-container');
|
||||
if (layout) layout.classList.add('sidebar-hidden');
|
||||
const sidebar = this.container.closest('.item-sidebar-left');
|
||||
if (sidebar) {
|
||||
sidebar.classList.add('is-hidden');
|
||||
sidebar.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +130,7 @@ class CommentSystem {
|
||||
let isAtBottom = currentlyAtBottom;
|
||||
|
||||
// Base the bottom state primarily on whether the input box / end-of-thread marker is visible
|
||||
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice');
|
||||
const bottomElement = container.querySelector('.main-input') || container.querySelector('.lock-notice') || container.querySelector('.login-placeholder');
|
||||
|
||||
if (bottomElement) {
|
||||
const bottomRect = bottomElement.getBoundingClientRect();
|
||||
@@ -477,34 +460,6 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
_isSelfComment(data) {
|
||||
if (!data) return false;
|
||||
if (data.id && this._selfPostedCommentIds?.has(data.id)) return true;
|
||||
if (data.body && this._pendingSelfCommentTexts?.has(data.body)) return true;
|
||||
|
||||
const session = window.f0ckSession || {};
|
||||
const currentUserId = session.id || session.user_id;
|
||||
if (currentUserId && data.user_id && parseInt(data.user_id, 10) === parseInt(currentUserId, 10)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentUsername = session.user || this.user;
|
||||
if (currentUsername && data.username && currentUsername.toLowerCase() === data.username.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (session.is_anon && data.is_anon) {
|
||||
if (data.anon_fingerprint && session.fingerprint && data.anon_fingerprint === session.fingerprint) {
|
||||
return true;
|
||||
}
|
||||
if (data.anon_short_fingerprint && session.fingerprint && session.fingerprint.includes(data.anon_short_fingerprint)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
handleLiveComment(data) {
|
||||
if (!this.container || !this.itemId) return;
|
||||
// 1. Check if comment belongs to this item
|
||||
@@ -570,20 +525,14 @@ class CommentSystem {
|
||||
|
||||
// Danmaku: fire one-shot for other users' comments.
|
||||
// Own comment is handled by the optimistic submit path (fire + addItem).
|
||||
const isSelf = this._isSelfComment(data);
|
||||
if (window.danmakuInstance && !isSelf) {
|
||||
// The _loadDanmaku re-render will add this comment to the items rotation.
|
||||
const currentUser = window.f0ckSession?.user;
|
||||
if (window.danmakuInstance && data.username !== currentUser) {
|
||||
window.danmakuInstance.fire(
|
||||
data.body,
|
||||
data.display_name || data.username || '?',
|
||||
data.username_color || null
|
||||
);
|
||||
window.danmakuInstance.addItem({
|
||||
id: data.id,
|
||||
content: data.body,
|
||||
video_time: data.video_time ?? null,
|
||||
display_name: data.display_name || data.username || '?',
|
||||
username_color: data.username_color || null
|
||||
});
|
||||
}
|
||||
|
||||
// Update backlinks for live comment
|
||||
@@ -793,25 +742,16 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
|
||||
// Render skeleton only if the fetch is slow. Rendering it immediately made every item switch flash an
|
||||
// empty list that the real comments then replaced a moment later.
|
||||
// Render skeleton (Result: Layout visible immediately)
|
||||
// Skip when preserveScroll=true (tab re-focus refresh): the user already sees comments,
|
||||
// so wiping the DOM causes the browser to lose the #c anchor element and auto-scroll to top.
|
||||
let skeletonTimer = null;
|
||||
if (!scrollToId && !preserveScroll) {
|
||||
if (this.user || !this.container.querySelector('.comment-input, .lock-notice')) {
|
||||
skeletonTimer = setTimeout(() => {
|
||||
skeletonTimer = null;
|
||||
this.render([], this.user, initialIsSubscribed);
|
||||
this.restoreState(state);
|
||||
}, 250);
|
||||
}
|
||||
this.render([], this.user, initialIsSubscribed);
|
||||
this.restoreState(state);
|
||||
}
|
||||
const cancelSkeleton = () => { if (skeletonTimer) { clearTimeout(skeletonTimer); skeletonTimer = null; } };
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${this.itemId}?sort=${this.sort}`);
|
||||
cancelSkeleton();
|
||||
|
||||
// If server is restarting (502/503), res.ok will be false.
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
@@ -924,7 +864,6 @@ class CommentSystem {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
cancelSkeleton();
|
||||
console.error('[CommentSystem] Error loading comments:', e);
|
||||
// Catch-all for network errors, 502s, JSON parse errors (e.g. server restart)
|
||||
// If initial load already finished (SSR or first fetch), just keep existing comments on screen.
|
||||
@@ -1400,8 +1339,7 @@ class CommentSystem {
|
||||
} else if (currentUserId) {
|
||||
inputSection = this.renderInput();
|
||||
} else {
|
||||
// Guests can read but not comment: an inert look-alike keeps the layout identical
|
||||
inputSection = this.renderGuestInput();
|
||||
inputSection = '<div class="login-placeholder"><a href="/login" class="login-trigger-btn">Login</a> to comment</div>';
|
||||
}
|
||||
|
||||
const isLegacy = document.body.classList.contains('layout-legacy') || document.body.classList.contains('legacy-view');
|
||||
@@ -1488,7 +1426,7 @@ class CommentSystem {
|
||||
});
|
||||
}
|
||||
|
||||
const mainInput = this.container.querySelector('.main-input:not(.is-guest)');
|
||||
const mainInput = this.container.querySelector('.main-input');
|
||||
if (mainInput) this.setupEmojiPicker(mainInput);
|
||||
|
||||
// Lazy emoji load: scan the just-rendered comment text for :emoji: patterns.
|
||||
@@ -2271,30 +2209,14 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
const bannerEnabled = window.f0ckCommentBannerEnabled !== false && window.f0ckSession?.comment_banner_enabled !== false;
|
||||
let bannerStyle = (bannerEnabled && comment.banner_file && comment.banner_file !== 'null')
|
||||
const bannerStyle = (bannerEnabled && comment.banner_file && comment.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${comment.banner_file}'); --author-banner-position: ${comment.banner_position === 'center' ? 'center top' : (comment.banner_position || 'center top')}; --author-banner-size: ${(comment.banner_size && comment.banner_size !== 'cover') ? comment.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
const authorUserId = comment.user_id ?? (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? (window.f0ckSession.id || window.f0ckSession.user_id) : null);
|
||||
const authorUsernameColor = comment.username_color || (comment.username && window.f0ckSession && comment.username.toLowerCase() === (window.f0ckSession.user || '').toLowerCase() ? window.f0ckSession.username_color : null);
|
||||
|
||||
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
|
||||
if (isAnonGuest) bannerStyle = '';
|
||||
|
||||
const avatarHtml = isAnonGuest
|
||||
? `<div class="comment-avatar"><img src="/a/default.png"></div>`
|
||||
: `<div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div>`;
|
||||
|
||||
const isAnon = isAnonGuest || comment.is_anon || comment.anon_fingerprint || comment.username === 'anonymous' || (comment.username && comment.username.startsWith('anon_'));
|
||||
const authorHtml = isAnon
|
||||
? `<span class="comment-author">anonymous</span>`
|
||||
: (comment.username
|
||||
? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>`
|
||||
: '<span class="comment-author">System</span>');
|
||||
|
||||
const anonDataAttrs = isAnon ? '' : `data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}"`;
|
||||
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}>${avatarHtml}<div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${authorHtml}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" ${anonDataAttrs}>${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, isAnon ? null : comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted ? `${(currentUserId || window.f0ckAnonSSH?.pubkey) ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button>` : ''}<button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" ${anonDataAttrs}>#${comment.id}</a></div>${repliesHtml}`;
|
||||
return `<div class="${commentClass} ${isDeleted ? 'deleted' : ''} ${isPinned ? 'pinned' : ''}" id="c${comment.id}" ${bannerStyle}><div class="comment-avatar">${comment.username ? `<a href="/user/${comment.username}">` : ''}<img src="${comment.avatar_file ? `/a/${comment.avatar_file}` : (comment.avatar ? `/t/${comment.avatar}.webp` : '/a/default.png')}">${comment.username ? `</a>` : ''}</div><div class="comment-body"><div class="comment-header"><div class="comment-header-left">${pinnedBadge}${comment.username ? `<a href="/user/${comment.username}" class="comment-author" tooltip="ID: ${authorUserId ?? ''}" ${authorUsernameColor ? `style="color: ${authorUsernameColor}"` : ''}>${this.escapeHtml(comment.display_name || comment.username)}</a>` : '<span class="comment-author">System</span>'}${contextMarker}${backlinkHtml}</div><a href="#c${comment.id}" class="comment-time timeago" tooltip="${fullDate}" data-iso="${isoDate}" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">${timeAgo}</a></div><div class="comment-content" data-raw="${this.escapeHtml(comment.content)}">${content}</div>${this.renderCommentAttachments(comment.files, comment.content)}${this.renderCommentPoll(comment.poll, comment.id, comment.username)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${!isDeleted && currentUserId ? `<button class="reply-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Reply"><i class="fa-solid fa-reply"></i></button><button class="quote-btn" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}" title="Quote with Text"><i class="fa-solid fa-quote-left"></i></button><button class="report-comment-btn" data-id="${comment.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><i class="fa-solid fa-triangle-exclamation"></i></button>` : ''}${adminButtons}${userDeleteButton}</div></div></div></div><a href="#c${comment.id}" class="comment-permalink" title="Permalink" data-id="${comment.id}" data-username="${comment.username}" data-display="${this.escapeHtml(comment.display_name || '')}">#${comment.id}</a></div>${repliesHtml}`;
|
||||
}
|
||||
|
||||
timeAgo(date) {
|
||||
@@ -2395,24 +2317,6 @@ class CommentSystem {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Same markup as renderInput() so every theme styles it identically, but inert: readonly, not focusable,
|
||||
// no pointer events on the controls, no submit. Clicking it opens the login modal.
|
||||
renderGuestInput() {
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const placeholder = i18n.login_to_comment || 'Log in to comment';
|
||||
const postLabel = i18n.post || 'Post';
|
||||
return `
|
||||
<div class="comment-input main-input is-guest" aria-disabled="true" title="${placeholder}"
|
||||
onclick="const b=document.getElementById('nav-login-btn'); if (b) b.click();">
|
||||
<textarea placeholder="${placeholder}" readonly tabindex="-1" aria-disabled="true"></textarea>
|
||||
<div class="comment-file-preview"></div>
|
||||
<div class="input-actions">
|
||||
<button class="submit-comment" type="button" tabindex="-1" aria-disabled="true"><span class="submit-label">${postLabel}</span><i class="fa-solid fa-spinner fa-spin submit-spinner"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
renderInput(parentId = null) {
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const session = window.f0ckSession || {};
|
||||
@@ -2426,8 +2330,7 @@ class CommentSystem {
|
||||
const counter = (maxLen !== null && maxLen !== undefined)
|
||||
? `<span class="char-counter" data-max="${maxLen}">0 / ${maxLen}</span>`
|
||||
: '';
|
||||
const anonAttachmentsDisabled = session.is_anon && session.anon_permissions?.comment_attachments === false;
|
||||
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments && !anonAttachmentsDisabled;
|
||||
const fileUploadEnabled = session.logged_in && session.allow_fileupload_comments;
|
||||
const multiFile = session.fileupload_comments_multifile;
|
||||
const attachBtn = fileUploadEnabled
|
||||
? `<button class="comment-attach-btn" title="${attachLabel}" type="button"><i class="fa-solid fa-paperclip"></i></button><input type="file" class="comment-file-input" accept="image/*,video/*,audio/*" ${multiFile ? 'multiple' : ''} style="display:none;">`
|
||||
@@ -2772,7 +2675,7 @@ class CommentSystem {
|
||||
});
|
||||
} else {
|
||||
// Scroll to Bottom of comments
|
||||
const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice');
|
||||
const bottomElement = this.container.querySelector('.main-input') || this.container.querySelector('.lock-notice') || this.container.querySelector('.login-placeholder');
|
||||
if (bottomElement) {
|
||||
bottomElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
} else {
|
||||
@@ -3411,11 +3314,6 @@ class CommentSystem {
|
||||
return;
|
||||
}
|
||||
if (submitBtn.classList.contains('loading') || submitBtn.disabled) return;
|
||||
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions?.comment === false) {
|
||||
if (window.flashMessage) window.flashMessage('Anonymous commenting is disabled.', 3000, 'error');
|
||||
else alert('Anonymous commenting is disabled.');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Upload all staged files now (at submit time) ───────────────────────
|
||||
const fileIds = [];
|
||||
@@ -3508,7 +3406,6 @@ class CommentSystem {
|
||||
} else {
|
||||
this.isMainSubmitting = true;
|
||||
}
|
||||
this._pendingSelfCommentTexts.add(text);
|
||||
|
||||
let retryCount = 0;
|
||||
const maxRetries = 20; // Allow several minutes of retrying during restart
|
||||
@@ -3535,59 +3432,18 @@ class CommentSystem {
|
||||
params.append('has_poll', '1');
|
||||
}
|
||||
|
||||
let csrfToken = window.f0ckSession?.csrf_token || document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
if (csrfToken) params.append('csrf_token', csrfToken);
|
||||
|
||||
const fetchHeaders = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
};
|
||||
|
||||
if ((!window.f0ckSession?.user || window.f0ckSession?.is_anon) && window.f0ckAnonSSH) {
|
||||
try {
|
||||
const ident = window.f0ckAnonSSH.getIdentity();
|
||||
if (ident && ident.pubkey) {
|
||||
const ts = Date.now();
|
||||
const msg = `anon-auth:${ts}:${ident.pubkey}`;
|
||||
const sig = await window.f0ckAnonSSH.sign(msg);
|
||||
fetchHeaders['X-SSH-Pubkey'] = ident.pubkey;
|
||||
fetchHeaders['X-SSH-Timestamp'] = String(ts);
|
||||
fetchHeaders['X-SSH-Signature'] = sig;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[ANON_COMMENTS] Failed to sign comment:', e);
|
||||
}
|
||||
}
|
||||
|
||||
let res = await fetch('/api/comments', {
|
||||
const res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: fetchHeaders,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: params
|
||||
});
|
||||
|
||||
// Auto-recovery if CSRF token drifted
|
||||
if (res.status === 403) {
|
||||
const errJson = await res.clone().json().catch(() => ({}));
|
||||
if (errJson.msg === 'Invalid CSRF token' || errJson.message === 'Invalid CSRF token') {
|
||||
try {
|
||||
const idRes = await fetch('/api/v2/anon/identity', { credentials: 'same-origin' });
|
||||
const idData = await idRes.json();
|
||||
if (idData && idData.csrf_token) {
|
||||
if (window.f0ckSession) window.f0ckSession.csrf_token = idData.csrf_token;
|
||||
const mCsrf = document.querySelector('meta[name="csrf-token"]');
|
||||
if (mCsrf) mCsrf.content = idData.csrf_token;
|
||||
params.set('csrf_token', idData.csrf_token);
|
||||
fetchHeaders['X-CSRF-Token'] = idData.csrf_token;
|
||||
res = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: fetchHeaders,
|
||||
body: params
|
||||
});
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status >= 500) {
|
||||
throw new Error(`Server returned ${res.status}`);
|
||||
@@ -3595,7 +3451,7 @@ class CommentSystem {
|
||||
// For 4xx errors, we stop and show the error to user (likely validation or auth)
|
||||
const json = await res.json().catch(() => ({}));
|
||||
alert('Error: ' + (json.message || `Status ${res.status}`));
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3753,11 +3609,6 @@ class CommentSystem {
|
||||
poll: null
|
||||
};
|
||||
|
||||
if (json.comment?.id) {
|
||||
this._selfPostedCommentIds.add(json.comment.id);
|
||||
}
|
||||
this._pendingSelfCommentTexts.delete(text);
|
||||
|
||||
// Danmaku: fire immediately (one-shot) + add to future rotation
|
||||
if (window.danmakuInstance) {
|
||||
window.danmakuInstance.fire(
|
||||
@@ -3766,7 +3617,6 @@ class CommentSystem {
|
||||
session.username_color || null
|
||||
);
|
||||
window.danmakuInstance.addItem({
|
||||
id: newComment.id,
|
||||
content: text,
|
||||
video_time: newComment.video_time ?? null,
|
||||
display_name: session.display_name || currentUsername || '?',
|
||||
@@ -3877,10 +3727,10 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
this._silentSync();
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
|
||||
} else {
|
||||
alert('Error: ' + json.message);
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[CommentSystem] Submit attempt ${retryCount + 1} failed:`, err);
|
||||
@@ -3892,7 +3742,7 @@ class CommentSystem {
|
||||
setTimeout(attemptSubmit, delay);
|
||||
} else {
|
||||
alert('Failed to send comment after multiple attempts. Please check your connection.');
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId, text);
|
||||
this._finishSubmit(submitBtn, originalBtnHtml, parentId);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3924,10 +3774,7 @@ class CommentSystem {
|
||||
console.log('[ensureBtn] done, button in DOM:', !!contentEl.querySelector('.load-full-comment-btn'));
|
||||
}
|
||||
|
||||
_finishSubmit(btn, originalHtml, parentId, submittedText = null) {
|
||||
if (submittedText) {
|
||||
this._pendingSelfCommentTexts?.delete(submittedText);
|
||||
}
|
||||
_finishSubmit(btn, originalHtml, parentId) {
|
||||
if (parentId) {
|
||||
this.pendingSubmissions.delete(parentId);
|
||||
} else {
|
||||
@@ -4030,12 +3877,7 @@ class CommentSystem {
|
||||
e.preventDefault();
|
||||
|
||||
// If comments are hidden, show them first
|
||||
const isHidden = cs.container.classList.contains('faded-out') ||
|
||||
cs.container.classList.contains('is-hidden') ||
|
||||
cs.container.style.display === 'none' ||
|
||||
document.body.classList.contains('sidebar-left-hidden') ||
|
||||
document.body.classList.contains('comments-hidden') ||
|
||||
localStorage.getItem('comments_hidden') === 'true';
|
||||
const isHidden = cs.container.classList.contains('faded-out') || cs.container.style.display === 'none';
|
||||
if (isHidden) cs.toggleComments();
|
||||
|
||||
// Legacy layout: comments are in the normal page flow under .item-main-content
|
||||
@@ -4116,7 +3958,7 @@ class CommentSystem {
|
||||
toggleComments() {
|
||||
if (!this.container) return;
|
||||
|
||||
if (document.body.classList.contains('layout-modern') && !document.body.classList.contains('onara-modal-open')) {
|
||||
if (document.body.classList.contains('layout-modern')) {
|
||||
if (window.innerWidth < 1000) return;
|
||||
if (typeof window.toggleSidebarLeft === 'function') {
|
||||
window.toggleSidebarLeft();
|
||||
@@ -4131,138 +3973,31 @@ class CommentSystem {
|
||||
] : [];
|
||||
|
||||
// Check if currently hidden (or slid out)
|
||||
const isHidden = this.container.classList.contains('faded-out') ||
|
||||
this.container.classList.contains('is-hidden') ||
|
||||
this.container.style.display === 'none' ||
|
||||
document.body.classList.contains('sidebar-left-hidden') ||
|
||||
document.body.classList.contains('comments-hidden') ||
|
||||
localStorage.getItem('comments_hidden') === 'true';
|
||||
|
||||
// Legacy layout: comments slide up behind the info box (and back down out of it)
|
||||
const reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (document.body.classList.contains('layout-legacy') && typeof this.container.animate === 'function' && !reduceMotion) {
|
||||
// A slide still running decides the direction, since the classes only flip when it ends
|
||||
const running = this._legacySlide && this._legacySlide.el === this.container ? this._legacySlide : null;
|
||||
const hiddenNow = running ? running.dir === 'hide' : isHidden;
|
||||
this.slideLegacyComments(!hiddenNow, layout, sidebar, siblings);
|
||||
return;
|
||||
}
|
||||
const isHidden = this.container.classList.contains('faded-out') || this.container.style.display === 'none';
|
||||
|
||||
if (isHidden) {
|
||||
// SHOW: expand grid first, then slide content in
|
||||
if (layout) layout.classList.remove('sidebar-hidden');
|
||||
document.body.classList.remove('sidebar-left-hidden', 'comments-hidden');
|
||||
this.container.classList.remove('is-hidden');
|
||||
this.container.style.removeProperty('display');
|
||||
document.body.classList.remove('sidebar-left-hidden');
|
||||
this.container.style.display = '';
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('is-hidden');
|
||||
sidebar.style.removeProperty('display');
|
||||
sidebar.style.display = '';
|
||||
}
|
||||
localStorage.setItem('comments_hidden', 'false');
|
||||
void this.container.offsetWidth; // force reflow so transition fires
|
||||
this.container.classList.remove('faded-out');
|
||||
siblings.forEach(el => el.classList.remove('faded-out'));
|
||||
} else {
|
||||
// HIDE: mark state immediately so quick navigations stay hidden, fade out content, then collapse
|
||||
// HIDE: slide content out first, then collapse grid
|
||||
localStorage.setItem('comments_hidden', 'true');
|
||||
this.container.classList.add('faded-out');
|
||||
siblings.forEach(el => el.classList.add('faded-out'));
|
||||
setTimeout(() => {
|
||||
if (!this.container.classList.contains('faded-out')) return;
|
||||
this.container.classList.add('is-hidden');
|
||||
this.container.style.setProperty('display', 'none', 'important');
|
||||
if (sidebar) {
|
||||
sidebar.classList.add('is-hidden');
|
||||
sidebar.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
this.container.style.display = 'none';
|
||||
if (layout) layout.classList.add('sidebar-hidden');
|
||||
document.body.classList.add('sidebar-left-hidden', 'comments-hidden');
|
||||
document.body.classList.add('sidebar-left-hidden');
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy layout slide: the container shrinks from the bottom up while its content moves up by the same
|
||||
// amount, so the comments look like they retract behind the box above them (reverse on show).
|
||||
// Pressing again mid-slide turns it around from where it is.
|
||||
slideLegacyComments(hide, layout, sidebar, siblings) {
|
||||
const el = this.container;
|
||||
const kids = Array.from(el.children);
|
||||
// Curtain feel: slow, soft start, gentle settle at the end
|
||||
const DURATION = 900;
|
||||
const EASING = 'cubic-bezier(0.45, 0, 0.2, 1)';
|
||||
|
||||
let startH = null;
|
||||
if (this._legacySlide) {
|
||||
// Only turn around from mid-slide on the same item; a slide left over from a previous item is dropped
|
||||
if (this._legacySlide.el === el) startH = el.getBoundingClientRect().height;
|
||||
this._legacySlide.anims.forEach(a => a.cancel());
|
||||
this._legacySlide = null;
|
||||
}
|
||||
|
||||
if (!hide) {
|
||||
// Put it back in the flow first (collapsed), so its full height can be measured
|
||||
if (layout) layout.classList.remove('sidebar-hidden');
|
||||
document.body.classList.remove('sidebar-left-hidden', 'comments-hidden');
|
||||
// Border stays until the comments are fully down again
|
||||
document.body.classList.add('comments-sliding');
|
||||
el.classList.remove('is-hidden', 'faded-out');
|
||||
el.style.removeProperty('display');
|
||||
if (sidebar) {
|
||||
sidebar.classList.remove('is-hidden');
|
||||
sidebar.style.removeProperty('display');
|
||||
}
|
||||
siblings.forEach(s => s.classList.remove('faded-out'));
|
||||
localStorage.setItem('comments_hidden', 'false');
|
||||
if (startH === null) startH = 0;
|
||||
} else {
|
||||
// Mark state immediately so quick navigations stay hidden
|
||||
localStorage.setItem('comments_hidden', 'true');
|
||||
document.body.classList.add('comments-sliding');
|
||||
if (startH === null) startH = el.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
const fullH = el.scrollHeight;
|
||||
const endH = hide ? 0 : fullH;
|
||||
// Height + clipping go through a class and a registered custom property (see f0ckm.css): layouts
|
||||
// like the Onara modal pin the container with `height: auto !important; overflow: visible
|
||||
// !important`, which beats both an animated height and an inline overflow. The class rule is
|
||||
// more specific and !important, and its height reads the variable the animation drives.
|
||||
el.style.setProperty('--comments-slide-h', endH + 'px');
|
||||
el.classList.add('is-comments-sliding');
|
||||
|
||||
const frames = (h0, h1) => [{ transform: 'translateY(' + (h0 - fullH) + 'px)' }, { transform: 'translateY(' + (h1 - fullH) + 'px)' }];
|
||||
// A turnaround covers only part of the distance, so it takes proportionally less time (same speed)
|
||||
const share = fullH > 0 ? Math.abs(endH - startH) / fullH : 1;
|
||||
const opts = { duration: Math.round(DURATION * Math.max(0.3, Math.min(1, share))), easing: EASING };
|
||||
const anims = [el.animate([{ '--comments-slide-h': startH + 'px' }, { '--comments-slide-h': endH + 'px' }], opts)];
|
||||
kids.forEach(k => anims.push(k.animate(frames(startH, endH), opts)));
|
||||
|
||||
const slide = { dir: hide ? 'hide' : 'show', anims, el };
|
||||
this._legacySlide = slide;
|
||||
anims[0].onfinish = () => {
|
||||
if (this._legacySlide !== slide) return;
|
||||
this._legacySlide = null;
|
||||
el.classList.remove('is-comments-sliding');
|
||||
el.style.removeProperty('--comments-slide-h');
|
||||
if (!hide) {
|
||||
document.body.classList.remove('comments-sliding');
|
||||
return;
|
||||
}
|
||||
el.classList.add('faded-out', 'is-hidden');
|
||||
el.style.setProperty('display', 'none', 'important');
|
||||
siblings.forEach(s => s.classList.add('faded-out'));
|
||||
if (sidebar) {
|
||||
sidebar.classList.add('is-hidden');
|
||||
sidebar.style.setProperty('display', 'none', 'important');
|
||||
}
|
||||
if (layout) layout.classList.add('sidebar-hidden');
|
||||
document.body.classList.add('sidebar-left-hidden', 'comments-hidden');
|
||||
document.body.classList.remove('comments-sliding');
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
escapeHtml(unsafe) {
|
||||
@@ -5008,28 +4743,6 @@ class CommentSystem {
|
||||
}, { once: true });
|
||||
};
|
||||
|
||||
// Bring the whole picker into view with some room below it. Measured once the enter animation has
|
||||
// finished (mid-animation it is shifted up and slightly squashed, so it would look smaller than it
|
||||
// is) and scrolled on whatever actually scrolls here (Onara modal, index container or the window).
|
||||
const revealPicker = () => {
|
||||
if (!picker || picker.style.display === 'none') return;
|
||||
let done = false;
|
||||
const run = () => {
|
||||
if (done || !picker || picker.style.display === 'none') return;
|
||||
done = true;
|
||||
const MARGIN = 24;
|
||||
const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(picker) : null;
|
||||
const view = sc ? sc.getBoundingClientRect() : { top: 0, bottom: window.innerHeight };
|
||||
const r = picker.getBoundingClientRect();
|
||||
let delta = r.bottom + MARGIN - view.bottom;
|
||||
// Never push the picker's own top out of view (very short viewports)
|
||||
delta = Math.min(delta, r.top - view.top - 8);
|
||||
if (delta > 0) (sc || window).scrollBy({ top: delta, behavior: 'smooth' });
|
||||
};
|
||||
picker.addEventListener('animationend', run, { once: true });
|
||||
setTimeout(run, 300);
|
||||
};
|
||||
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -5053,7 +4766,9 @@ class CommentSystem {
|
||||
picker.offsetHeight; // reflow
|
||||
picker.style.animation = '';
|
||||
trigger.classList.add('is-active');
|
||||
revealPicker();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() =>
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -5116,7 +4831,9 @@ class CommentSystem {
|
||||
}
|
||||
|
||||
container.appendChild(picker);
|
||||
revealPicker();
|
||||
requestAnimationFrame(() => requestAnimationFrame(() =>
|
||||
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' })
|
||||
));
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
+94
-528
@@ -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;
|
||||
|
||||
|
||||
@@ -7,47 +7,10 @@
|
||||
const dragModalClose = document.getElementById('drag-modal-close');
|
||||
const dragForm = dragModal ? dragModal.querySelector('.upload-form') : null;
|
||||
|
||||
const navUploadLink = document.getElementById('nav-upload-link');
|
||||
|
||||
// Dropdown placement: right under the navbar "Upload" link, left-aligned with it and kept inside the
|
||||
// window. Without a visible link (phone: it sits in the collapsed menu) it drops from the navbar.
|
||||
const positionModal = () => {
|
||||
if (!dragModal || !dragModal.classList.contains('show')) return;
|
||||
const content = dragModal.querySelector('.modal-content');
|
||||
if (!content) return;
|
||||
const vw = document.documentElement.clientWidth;
|
||||
const linkRect = navUploadLink ? navUploadLink.getBoundingClientRect() : null;
|
||||
const useLink = !!(linkRect && linkRect.width > 0 && linkRect.height > 0);
|
||||
const nav = document.querySelector('body > nav.navbar, nav.navbar');
|
||||
const top = Math.round((useLink ? linkRect.bottom : (nav ? nav.getBoundingClientRect().bottom : 0)) + 6);
|
||||
content.style.top = top + 'px';
|
||||
content.style.maxHeight = Math.max(200, window.innerHeight - top - 12) + 'px';
|
||||
const w = content.offsetWidth;
|
||||
let left = useLink ? linkRect.left : (vw - w) / 2;
|
||||
left = Math.max(8, Math.min(left, vw - w - 8));
|
||||
content.style.left = Math.round(left) + 'px';
|
||||
};
|
||||
window.addEventListener('resize', positionModal, { passive: true });
|
||||
|
||||
// The navbar link shows when the dropdown is open. Watched on the modal itself because it gets closed
|
||||
// from several places (close button, Escape, toggle, finished upload, hideAllModals).
|
||||
if (dragModal && navUploadLink) {
|
||||
const syncLink = () => {
|
||||
const open = dragModal.classList.contains('show');
|
||||
navUploadLink.classList.toggle('is-active', open);
|
||||
navUploadLink.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
};
|
||||
new MutationObserver(syncLink).observe(dragModal, { attributes: true, attributeFilter: ['class'] });
|
||||
syncLink();
|
||||
}
|
||||
const navEl = document.querySelector('body > nav.navbar, nav.navbar');
|
||||
if (navEl && window.ResizeObserver) new ResizeObserver(positionModal).observe(navEl);
|
||||
|
||||
const showModal = () => {
|
||||
if (!dragModal) return;
|
||||
dragModal.classList.add('show', 'is-dropdown');
|
||||
dragModal.classList.add('show');
|
||||
document.body.classList.add('modal-open');
|
||||
positionModal();
|
||||
// Reset scroll position so it always starts at the top
|
||||
dragModal.scrollTop = 0;
|
||||
const modalContent = dragModal.querySelector('.modal-content');
|
||||
@@ -56,22 +19,14 @@
|
||||
if (modalBody) modalBody.scrollTop = 0;
|
||||
};
|
||||
|
||||
// Hide without resetting: a picked file / typed tags survive, reopening continues there.
|
||||
// (The close button and Escape still close + reset, see below.)
|
||||
const hideModal = () => {
|
||||
if (!dragModal) return;
|
||||
dragModal.classList.remove('show');
|
||||
document.body.classList.remove('modal-open');
|
||||
};
|
||||
|
||||
// Navbar upload link — always attached so it works even if drag-drop can't init.
|
||||
// Toggles the dropdown when available; falls back to /upload navigation otherwise.
|
||||
// Opens the modal when available; falls back to /upload navigation otherwise.
|
||||
const navUploadLink = document.getElementById('nav-upload-link');
|
||||
if (navUploadLink) {
|
||||
navUploadLink.addEventListener('click', (e) => {
|
||||
if (!dragModal) return; // no modal → fall back to href navigation
|
||||
e.preventDefault();
|
||||
if (dragModal.classList.contains('show')) hideModal();
|
||||
else showModal();
|
||||
showModal();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1359
-15170
File diff suppressed because it is too large
Load Diff
+45
-160
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
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;">→ ' + 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);
|
||||
})();
|
||||
@@ -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
@@ -36,8 +36,6 @@
|
||||
const filterResetBtn = document.getElementById('filter-reset-btn');
|
||||
const filterApplyBtn = document.getElementById('filter-apply-btn');
|
||||
const filterSummary = document.getElementById('filter-active-summary');
|
||||
let filterSummaryText = document.getElementById('filter-active-summary-text');
|
||||
let filterClearBtn = document.getElementById('filter-active-clear');
|
||||
const tagInput = document.getElementById('filter-tag-input');
|
||||
const tagClear = document.getElementById('filter-tag-clear');
|
||||
const tagSuggestEl = document.getElementById('tag-suggestions');
|
||||
@@ -81,8 +79,8 @@
|
||||
const CACHE_MAX = 200;
|
||||
const CACHE_TTL = 30 * 60 * 1000;
|
||||
const VOL_KEY = 'scroller_volume';
|
||||
const PREFS_KEY = 'scroller_prefs';
|
||||
const PRESETS_KEY = 'scroller_presets';
|
||||
const PREFS_KEY = 'scroller_prefs';
|
||||
const PRESETS_KEY = 'scroller_presets';
|
||||
|
||||
// ── User Preferences ──────────────────────────────────────────────────────
|
||||
function loadPrefs() {
|
||||
@@ -99,34 +97,7 @@
|
||||
let autoNextLoops = Math.max(0, parseInt(prefs.autoNextLoops ?? 1, 10));
|
||||
let leftHandEnabled = prefs.leftHand === true;
|
||||
|
||||
const getIsScrollerGuest = () => {
|
||||
if (!window.f0ckSession) return true;
|
||||
if (window.f0ckSession.user && !window.f0ckSession.is_anon) return false;
|
||||
if (window.f0ckSession.is_anon && (window.f0ckSession.logged_in || window.f0ckSession.user)) return false;
|
||||
if (window.f0ckAnonSSH && (window.f0ckAnonSSH.isSessionReady || window.f0ckAnonSSH.pubkey)) return false;
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('f0ck_anon_ssh_pub')) return false;
|
||||
return !window.f0ckSession.logged_in;
|
||||
};
|
||||
const isScrollerGuest = getIsScrollerGuest();
|
||||
const isScrollerAnon = !(window.f0ckSession && window.f0ckSession.user && !window.f0ckSession.is_anon);
|
||||
const scrollerAllowedMimes = window.f0ckSession?.anon_permissions?.allowed_mimes;
|
||||
const scrollerSingleMime = (isScrollerAnon && Array.isArray(scrollerAllowedMimes) && scrollerAllowedMimes.length === 1) ? scrollerAllowedMimes[0] : null;
|
||||
|
||||
const scrollerAllowedModes = isScrollerGuest ? ['sfw'] : window.f0ckSession?.anon_permissions?.allowed_modes;
|
||||
const scrollerModeNames = ['sfw', 'nsfw', 'untagged', 'all', 'nsfl'];
|
||||
let effectiveScrollerMode = defaultMode;
|
||||
if (isScrollerGuest) {
|
||||
effectiveScrollerMode = 0;
|
||||
} else if (isScrollerAnon && Array.isArray(scrollerAllowedModes)) {
|
||||
const curName = scrollerModeNames[effectiveScrollerMode] || 'sfw';
|
||||
if (!scrollerAllowedModes.includes(curName)) {
|
||||
const fallbackName = scrollerAllowedModes[0] || 'sfw';
|
||||
const fallbackIdx = scrollerModeNames.indexOf(fallbackName);
|
||||
effectiveScrollerMode = fallbackIdx >= 0 ? fallbackIdx : 0;
|
||||
}
|
||||
}
|
||||
|
||||
let applied = { mode: effectiveScrollerMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null };
|
||||
let applied = { mode: defaultMode, mime: '', order: 'random', tags: [], externalUrl: null };
|
||||
let pending = { ...applied, tags: [] };
|
||||
|
||||
// Volume / mute
|
||||
@@ -221,8 +192,7 @@
|
||||
if (hid && !cache.items.some(item => String(item.id) === hid)) return false;
|
||||
|
||||
if (cache.filters) {
|
||||
applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], ...cache.filters };
|
||||
if (scrollerSingleMime) applied.mime = scrollerSingleMime;
|
||||
applied = { mode: defaultMode, mime: '', order: 'random', tags: [], ...cache.filters };
|
||||
applied.tags = Array.isArray(cache.filters.tags) ? [...cache.filters.tags] : [];
|
||||
pending = { ...applied, tags: [...applied.tags] };
|
||||
}
|
||||
@@ -558,7 +528,7 @@
|
||||
}
|
||||
|
||||
// ── Filter Presets CRUD ───────────────────────────────────────────────────
|
||||
const PRESETS_LABELS = { mode: { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 3: 'All', 4: 'NSFL' } };
|
||||
const PRESETS_LABELS = { mode: { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 3: 'All', 4: 'NSFL' } };
|
||||
function getPresets() { try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '[]'); } catch { return []; } }
|
||||
function savePresets(arr) { localStorage.setItem(PRESETS_KEY, JSON.stringify(arr)); }
|
||||
|
||||
@@ -1171,23 +1141,7 @@
|
||||
}
|
||||
|
||||
async function toggleFav(slide) {
|
||||
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
|
||||
const errMsg = 'Anonymous favoriting is disabled.';
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(errMsg, 3000, 'error');
|
||||
} else if (typeof showShareToast === 'function') {
|
||||
showShareToast(errMsg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!window.scrollerLoggedIn) {
|
||||
if (typeof showShareToast === 'function') {
|
||||
showShareToast('Login to favorite posts');
|
||||
} else if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Login to favorite posts', 3000, 'warning');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!window.scrollerLoggedIn) return;
|
||||
const id = slide.dataset.localId || slide.dataset.id;
|
||||
// External items have non-numeric IDs (e.g. "gif/123") — can't fav until rehosted
|
||||
if (!/^\d+$/.test(id)) { showShareToast('Can\u2019t fav external items'); return; }
|
||||
@@ -1211,37 +1165,13 @@
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `postid=${id}&action=${nowFaved ? 'add' : 'delete'}&favorited=${nowFaved}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
body: `postid=${id}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data.success) {
|
||||
// Rollback optimistic update
|
||||
if (favBtn) {
|
||||
favBtn.classList.toggle('faved', wasFaved);
|
||||
const icon = favBtn.querySelector('i');
|
||||
if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart';
|
||||
const countEl = favBtn.querySelector('.scroll-btn-count');
|
||||
if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1));
|
||||
}
|
||||
const errMsg = (data && (data.msg || data.error)) || 'Anonymous favoriting is disabled.';
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(errMsg, 3000, 'error');
|
||||
} else if (typeof showShareToast === 'function') {
|
||||
showShareToast(errMsg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Sync state and count to server truth (handles race conditions)
|
||||
if (data.success && favBtn) {
|
||||
if (data.favorited !== undefined) {
|
||||
favBtn.classList.toggle('faved', data.favorited);
|
||||
const icon = favBtn.querySelector('i');
|
||||
if (icon) icon.className = (data.favorited ? 'fa-solid' : 'fa-regular') + ' fa-heart';
|
||||
}
|
||||
if (data.favs) {
|
||||
const countEl = favBtn.querySelector('.scroll-btn-count');
|
||||
if (countEl) countEl.textContent = data.favs.length;
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Sync count to server truth (handles race conditions)
|
||||
if (data.success && favBtn && data.favs) {
|
||||
const countEl = favBtn.querySelector('.scroll-btn-count');
|
||||
if (countEl) countEl.textContent = data.favs.length;
|
||||
}
|
||||
} catch {
|
||||
// Rollback optimistic update on error
|
||||
@@ -1249,8 +1179,6 @@
|
||||
favBtn.classList.toggle('faved', wasFaved);
|
||||
const icon = favBtn.querySelector('i');
|
||||
if (icon) icon.className = (wasFaved ? 'fa-solid' : 'fa-regular') + ' fa-heart';
|
||||
const countEl = favBtn.querySelector('.scroll-btn-count');
|
||||
if (countEl) countEl.textContent = Math.max(0, (parseInt(countEl.textContent || '0', 10)) + (wasFaved ? 0 : -1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1417,8 +1345,7 @@
|
||||
const meta = document.createElement('div'); meta.className = 'scroll-meta';
|
||||
// esc() the color value: a raw '"' in username_color would break out of the
|
||||
// style attribute and allow arbitrary HTML injection (XSS).
|
||||
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
|
||||
const colorStyle = (!isAnonGuest && item.username_color) ? `color:${esc(item.username_color)}` : '';
|
||||
const colorStyle = item.username_color ? `color:${esc(item.username_color)}` : '';
|
||||
const ratingHtml = `<span class="scroll-rating ${esc(item.rating_class)}" data-item-id="${item.id}" data-rating="${esc(item.rating_class)}">${esc(item.rating_label)}</span>`;
|
||||
const ocHtml = item.is_oc ? `<span class="scroll-oc"><i class="fa-solid fa-star" style="font-size:.6rem"></i> OC</span>` : '';
|
||||
const badgesHtml = `<div class="scroll-badges">${ratingHtml}${ocHtml}</div>`;
|
||||
@@ -1428,15 +1355,10 @@
|
||||
item.tags.split(', ').map(t => `<span class="scroll-tag-pill" data-tag="${esc(t.trim())}">${esc(t.trim())}</span>`).join('')
|
||||
}</div>`
|
||||
: '';
|
||||
const userMetaHtml = isAnonGuest
|
||||
? `<div class="scroll-meta-top">
|
||||
<img class="scroll-avatar" src="/a/default.png" alt="" loading="lazy">
|
||||
<div>
|
||||
<div class="scroll-username">anonymous</div>
|
||||
<div class="scroll-timeago">${esc(item.stamp ? timeAgo(item.stamp * 1000) : (item.timeago || ''))}</div>
|
||||
</div>
|
||||
</div>`
|
||||
: `<div class="scroll-meta-top">
|
||||
meta.innerHTML = `
|
||||
<div class="scroll-meta-inner">
|
||||
${badgesHtml}
|
||||
<div class="scroll-meta-top">
|
||||
<a href="/user/${esc(item.username)}" class="scroll-user-link">
|
||||
<img class="scroll-avatar" src="${esc(item.avatar)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
|
||||
</a>
|
||||
@@ -1446,17 +1368,13 @@
|
||||
</a>
|
||||
<div class="scroll-timeago">${esc(item.stamp ? timeAgo(item.stamp * 1000) : (item.timeago || ''))}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
meta.innerHTML = `
|
||||
<div class="scroll-meta-inner">
|
||||
${badgesHtml}
|
||||
${userMetaHtml}
|
||||
</div>
|
||||
${tagsHtml}
|
||||
${item.is_external && item.external_board && item.external_tid
|
||||
? `<a class="scroll-id-link" href="https://boards.4chan.org/${item.external_board}/thread/${item.external_tid}#p${item.external_id}" target="_blank">/${item.external_board}/thread/${item.external_tid}</a>`
|
||||
: (item.local_id
|
||||
? `<a class="scroll-id-link" href="/${item.local_path || item.local_id}" target="_blank">/${item.local_path || item.local_id}</a>`
|
||||
: `<a class="scroll-id-link" href="/abyss/${item.id}">#${item.id}</a>`)}
|
||||
? `<a class="scroll-id-link" href="/${item.local_id}" target="_blank">#${item.local_id}</a>`
|
||||
: `<a class="scroll-id-link" href="/abyss#${item.id}">#${item.id}</a>`)}
|
||||
</div>`;
|
||||
slide.appendChild(meta);
|
||||
|
||||
@@ -1464,14 +1382,11 @@
|
||||
const actions = document.createElement('div'); actions.className = 'scroll-actions';
|
||||
const _i = window.f0ckI18n || {};
|
||||
actions.innerHTML = `
|
||||
${(() => {
|
||||
const isFaved = !!item.is_faved;
|
||||
return `
|
||||
<button class="scroll-btn js-fav-btn${isFaved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
||||
<div class="scroll-btn-icon"><i class="${isFaved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
||||
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
||||
</button>`;
|
||||
})()}
|
||||
${window.scrollerLoggedIn ? `
|
||||
<button class="scroll-btn js-fav-btn${item.is_faved ? ' faved' : ''}" title="${_i.favourite || 'Favourite'} (double-tap)">
|
||||
<div class="scroll-btn-icon"><i class="${item.is_faved ? 'fa-solid' : 'fa-regular'} fa-heart"></i></div>
|
||||
<span class="scroll-btn-count">${item.fav_count ?? 0}</span>
|
||||
</button>` : ''}
|
||||
<button class="scroll-btn js-comments-btn" data-id="${item.id}" title="${_i.comments_label || 'Comments'} (C)">
|
||||
<div class="scroll-btn-icon"><i class="fa-regular fa-comment"></i></div>
|
||||
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
||||
@@ -1486,7 +1401,7 @@
|
||||
</button>
|
||||
${item.is_external ? (
|
||||
item.local_id
|
||||
? `<a class="scroll-btn rehost-btn success" href="/${item.local_path || item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
||||
? `<a class="scroll-btn rehost-btn success" href="/${item.local_id}" target="_blank" title="${_i.already_added || 'Already added'}">
|
||||
<div class="scroll-btn-icon"><i class="fa-solid fa-check"></i></div>
|
||||
<span class="scroll-btn-label">${_i.view_label || 'View'}</span>
|
||||
</a>`
|
||||
@@ -1636,8 +1551,8 @@
|
||||
const localId = slide?.dataset.localId;
|
||||
const id = localId || shareBtn.dataset.id;
|
||||
const abyssUrl = localId
|
||||
? `${location.origin}/abyss/${localId}`
|
||||
: (id ? `${location.origin}/abyss/${id}` : `${location.origin}/abyss`);
|
||||
? `${location.origin}/abyss#${localId}`
|
||||
: `${location.origin}/abyss#${id}`;
|
||||
openSharePanel(abyssUrl);
|
||||
});
|
||||
const rehostBtn = actions.querySelector('.rehost-btn');
|
||||
@@ -1761,7 +1676,6 @@
|
||||
|
||||
const external_media_url = `https://i.4cdn.org/${data.board}/${p.tim}${ext}`;
|
||||
const local_id = allRehosts[external_media_url] || null;
|
||||
const local_path = (local_id && data.rehost_paths && data.rehost_paths[local_id]) || local_id;
|
||||
|
||||
return {
|
||||
id: `${data.board}/${p.no}`,
|
||||
@@ -1771,7 +1685,6 @@
|
||||
external_source: '4chan',
|
||||
is_external: true,
|
||||
local_id,
|
||||
local_path,
|
||||
external_media_url,
|
||||
mime: isVideo ? 'video/unknown' : (isImage ? 'image/unknown' : 'application/octet-stream'),
|
||||
dest: `/api/v2/scroller/external/4chan/${data.board}/media/${p.tim}${ext}`,
|
||||
@@ -1938,6 +1851,10 @@
|
||||
const origClass = icon.className;
|
||||
icon.className = 'fa-solid fa-spinner';
|
||||
|
||||
let rating = applied.mode === 0 ? 'sfw' : (applied.mode === 1 ? 'nsfw' : (applied.mode === 4 ? 'nsfl' : 'sfw'));
|
||||
if (item.external_board === 'wsg') rating = 'sfw';
|
||||
else if (item.external_board === 'gif') rating = 'nsfw';
|
||||
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch('/api/v2/scroller/rehost', {
|
||||
@@ -1948,6 +1865,9 @@
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: item.external_media_url || item.dest,
|
||||
rating: rating,
|
||||
tags: '',
|
||||
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
|
||||
...(item.original_filename ? { original_filename: item.original_filename } : {})
|
||||
})
|
||||
});
|
||||
@@ -1974,7 +1894,7 @@
|
||||
// Update button to link to the new site-internal post
|
||||
setTimeout(() => {
|
||||
btn.outerHTML = `
|
||||
<a href="/${data.item_path || data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
|
||||
<a href="/${data.item_id}" target="_blank" class="scroll-btn rehost-btn success" style="text-decoration:none;">
|
||||
<div class="scroll-btn-icon"><i class="fa-solid fa-arrow-up-right-from-square"></i></div>
|
||||
<span class="scroll-btn-label">View</span>
|
||||
</a>
|
||||
@@ -1985,8 +1905,8 @@
|
||||
if (slide) {
|
||||
const idLink = slide.querySelector('.scroll-id-link');
|
||||
if (idLink) {
|
||||
idLink.href = `/${data.item_path || data.item_id}`;
|
||||
idLink.textContent = `/${data.item_path || data.item_id}`;
|
||||
idLink.href = `/${data.item_id}`;
|
||||
idLink.textContent = `#${data.item_id}`;
|
||||
}
|
||||
// Reflect rehoster's username and local timestamp
|
||||
if (window.scrollerUsername) {
|
||||
@@ -2239,11 +2159,10 @@
|
||||
function renderCommentEl(c, canReply) {
|
||||
const el = document.createElement('div'); el.className = 'comment-item';
|
||||
el.dataset.commentId = c.id || '';
|
||||
const isAnonGuest = window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in);
|
||||
const av = isAnonGuest ? '/a/default.png' : (c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png'));
|
||||
const nc = (!isAnonGuest && c.username_color) ? `color:${esc(c.username_color)}` : '';
|
||||
const av = c.avatar_file ? `/a/${c.avatar_file}` : (c.avatar ? `/t/${c.avatar}.webp` : '/a/default.png');
|
||||
const nc = c.username_color ? `color:${esc(c.username_color)}` : '';
|
||||
const _i = window.f0ckI18n || {};
|
||||
const uname = isAnonGuest ? 'anonymous' : (c.display_name || c.username || 'anon');
|
||||
const uname = c.display_name || c.username || 'anon';
|
||||
el.innerHTML = `
|
||||
<img class="comment-avatar" src="${esc(av)}" alt="" loading="lazy" onerror="this.src='/a/default.png'">
|
||||
<div class="comment-body">
|
||||
@@ -2251,7 +2170,7 @@
|
||||
<div class="comment-content" data-raw="${esc(c.content || '')}">${renderCommentContent(c.content || '')}</div>
|
||||
<div class="comment-meta">
|
||||
<span class="comment-time">${c.created_at ? timeAgo(c.created_at) : (_i.ta_just_now || _i.just_now || 'just now')}</span>
|
||||
${(!isAnonGuest && canReply) ? `
|
||||
${canReply ? `
|
||||
<button class="comment-reply-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.reply || 'Reply'}</button>
|
||||
<button class="comment-quote-btn" data-id="${c.id}" data-user="${esc(c.username || uname)}">${_i.quote || 'Quote'}</button>
|
||||
` : ''}
|
||||
@@ -3057,7 +2976,7 @@
|
||||
if (addTagSendBtn) addTagSendBtn.addEventListener('click', submitTag);
|
||||
|
||||
// ── Filter panel ──────────────────────────────────────────────────────────
|
||||
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 3: 'All', 4: 'NSFL' };
|
||||
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 3: 'All', 4: 'NSFL' };
|
||||
|
||||
filterOpenBtn.addEventListener('click', () => {
|
||||
pending = { ...applied, tags: [...applied.tags] }; syncPanelUI();
|
||||
@@ -3066,23 +2985,6 @@
|
||||
});
|
||||
|
||||
function syncPanelUI() {
|
||||
const isGuest = getIsScrollerGuest();
|
||||
if (isGuest) {
|
||||
pending.mode = 0;
|
||||
applied.mode = 0;
|
||||
} else {
|
||||
document.querySelectorAll('#mode-pills .filter-pill').forEach(pill => {
|
||||
pill.classList.remove('locked');
|
||||
pill.disabled = false;
|
||||
pill.style.cursor = '';
|
||||
pill.style.opacity = '';
|
||||
pill.querySelectorAll('.fa-lock').forEach(i => i.remove());
|
||||
});
|
||||
}
|
||||
if (scrollerSingleMime) {
|
||||
pending.mime = scrollerSingleMime;
|
||||
applied.mime = scrollerSingleMime;
|
||||
}
|
||||
document.querySelectorAll('#mode-pills .filter-pill').forEach(p => p.classList.toggle('active', +p.dataset.mode === pending.mode));
|
||||
document.querySelectorAll('#mime-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.mime === pending.mime));
|
||||
document.querySelectorAll('#order-pills .filter-pill').forEach(p => p.classList.toggle('active', p.dataset.order === pending.order));
|
||||
@@ -3091,32 +2993,13 @@
|
||||
}
|
||||
|
||||
function makePillListener(groupId, key, transform) {
|
||||
const el = document.getElementById(groupId);
|
||||
if (!el) return;
|
||||
el.querySelectorAll('.filter-pill').forEach(pill => {
|
||||
pill.addEventListener('click', (e) => {
|
||||
if (groupId === 'mode-pills' && (getIsScrollerGuest() || pill.classList.contains('locked') || pill.disabled)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (groupId === 'mime-pills' && scrollerSingleMime) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
el.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(pill => {
|
||||
pill.addEventListener('click', () => {
|
||||
document.getElementById(groupId).querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
|
||||
pill.classList.add('active'); pending[key] = transform ? transform(pill.dataset[key]) : pill.dataset[key];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('f0ck:anon_session_ready', () => {
|
||||
if (window.f0ckSession) {
|
||||
window.f0ckSession.user = 'anonymous';
|
||||
window.f0ckSession.is_anon = true;
|
||||
window.f0ckSession.logged_in = true;
|
||||
}
|
||||
syncPanelUI();
|
||||
});
|
||||
makePillListener('mode-pills', 'mode', v => +v);
|
||||
makePillListener('mime-pills', 'mime', v => v);
|
||||
makePillListener('order-pills', 'order', v => v);
|
||||
@@ -3141,7 +3024,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auto-load 4chan thread from path or hash (e.g. /abyss/wsg/6211653 or #wsg/6211653) ────────────────
|
||||
// ── Auto-load 4chan thread from hash (e.g. #wsg/6132740) ────────────────
|
||||
let chanHashPending = null; // async promise if we need server lookup
|
||||
{
|
||||
const hid = hashId();
|
||||
@@ -3155,29 +3038,21 @@
|
||||
const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}');
|
||||
tid = postMap[hid]; // hid is "board/postno"
|
||||
} catch(_) {}
|
||||
|
||||
const effectiveTid = tid || postno;
|
||||
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${effectiveTid}`;
|
||||
applied.order = 'oldest';
|
||||
unlock4chan();
|
||||
|
||||
if (!tid) {
|
||||
// In case postno was a reply post rather than thread OP, query find in background
|
||||
if (tid) {
|
||||
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${tid}`;
|
||||
applied.order = 'oldest';
|
||||
unlock4chan();
|
||||
} else {
|
||||
// No local mapping — ask the server to find the thread
|
||||
chanHashPending = fetch(`/api/v2/scroller/external/4chan/${board}/find/${postno}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
window.f0ckDebug && window.f0ckDebug('[CHAN] Find result:', data);
|
||||
window.f0ckDebug('[CHAN] Find result:', data);
|
||||
if (data.success && data.tid) {
|
||||
try {
|
||||
const postMap = JSON.parse(localStorage.getItem('4chan_post_threads') || '{}');
|
||||
postMap[hid] = data.tid;
|
||||
localStorage.setItem('4chan_post_threads', JSON.stringify(postMap));
|
||||
} catch(_) {}
|
||||
if (String(data.tid) !== String(effectiveTid)) {
|
||||
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`;
|
||||
applied.order = 'oldest';
|
||||
reloadFeed();
|
||||
}
|
||||
applied.externalUrl = `https://boards.4chan.org/${board}/thread/${data.tid}`;
|
||||
applied.order = 'oldest';
|
||||
unlock4chan();
|
||||
window.f0ckDebug('[CHAN] Set externalUrl:', applied.externalUrl);
|
||||
}
|
||||
})
|
||||
.catch(err => { console.error('[CHAN] Find error:', err); });
|
||||
@@ -3185,61 +3060,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); });
|
||||
|
||||
function clearActiveFilters() {
|
||||
applied = { mode: defaultMode, mime: scrollerSingleMime || '', order: 'random', tags: [], externalUrl: null };
|
||||
pending = { ...applied, tags: [] };
|
||||
if (externalUrlInput) externalUrlInput.value = '';
|
||||
if (galleryOpen) toggleGallery();
|
||||
if (chanGalleryBtn) chanGalleryBtn.style.display = 'none';
|
||||
if (tagInput) tagInput.value = '';
|
||||
if (tagClear) tagClear.classList.remove('show');
|
||||
if (tagSuggestEl) tagSuggestEl.innerHTML = '';
|
||||
lastSugg = [];
|
||||
renderActiveTags();
|
||||
syncPanelUI();
|
||||
renderPresets();
|
||||
reloadFeed();
|
||||
}
|
||||
|
||||
function setupFilterSummary() {
|
||||
if (!filterSummary) return;
|
||||
if (!filterSummaryText || !filterClearBtn) {
|
||||
if (!filterSummary.querySelector('#filter-active-summary-text')) {
|
||||
const txt = document.createElement('span');
|
||||
txt.id = 'filter-active-summary-text';
|
||||
const btn = document.createElement('button');
|
||||
btn.id = 'filter-active-clear';
|
||||
btn.type = 'button';
|
||||
btn.title = (window.f0ckI18n && window.f0ckI18n.clear_filter) || 'Clear filter';
|
||||
btn.setAttribute('aria-label', (window.f0ckI18n && window.f0ckI18n.clear_filter) || 'Clear filter');
|
||||
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
filterSummary.innerHTML = '';
|
||||
filterSummary.appendChild(txt);
|
||||
filterSummary.appendChild(btn);
|
||||
}
|
||||
filterSummaryText = document.getElementById('filter-active-summary-text');
|
||||
filterClearBtn = document.getElementById('filter-active-clear');
|
||||
}
|
||||
if (filterClearBtn && !filterClearBtn._hasListener) {
|
||||
filterClearBtn._hasListener = true;
|
||||
filterClearBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearActiveFilters();
|
||||
});
|
||||
}
|
||||
}
|
||||
setupFilterSummary();
|
||||
filterResetBtn.addEventListener('click', () => { pending = { mode: defaultMode, mime: '', order: 'random', tags: [] }; syncPanelUI(); tagInput.value = ''; tagClear.classList.remove('show'); tagSuggestEl.innerHTML = ''; lastSugg = []; renderActiveTags(); });
|
||||
|
||||
function updateFilterSummary() {
|
||||
if (!filterSummary) return;
|
||||
setupFilterSummary();
|
||||
const is4chan = !!applied.externalUrl;
|
||||
const isDef = applied.mode === defaultMode && applied.mime === (scrollerSingleMime || '') && applied.order === 'random' && applied.tags.length === 0 && !is4chan;
|
||||
if (filterOpenBtn) filterOpenBtn.classList.toggle('has-filter', !isDef);
|
||||
filterSummary.classList.toggle('show', !isDef);
|
||||
const isDef = applied.mode === defaultMode && applied.mime === '' && applied.order === 'random' && applied.tags.length === 0 && !is4chan;
|
||||
filterOpenBtn.classList.toggle('has-filter', !isDef); filterSummary.classList.toggle('show', !isDef);
|
||||
if (!isDef) {
|
||||
// Check if current filters exactly match a saved preset
|
||||
const tagsKey = t => [...t].map(s => s.toLowerCase()).sort().join(',');
|
||||
@@ -3249,9 +3075,8 @@
|
||||
p.order === applied.order &&
|
||||
tagsKey(p.tags) === tagsKey(applied.tags)
|
||||
);
|
||||
let summaryText = '';
|
||||
if (matchedPreset && !is4chan) {
|
||||
summaryText = matchedPreset.name;
|
||||
filterSummary.textContent = matchedPreset.name;
|
||||
} else {
|
||||
const parts = [];
|
||||
if (is4chan) parts.push('4chan');
|
||||
@@ -3259,12 +3084,7 @@
|
||||
if (applied.mime) parts.push(applied.mime);
|
||||
if (applied.order !== 'random') parts.push(applied.order);
|
||||
parts.push(...applied.tags);
|
||||
summaryText = parts.join(' · ');
|
||||
}
|
||||
if (filterSummaryText) {
|
||||
filterSummaryText.textContent = summaryText;
|
||||
} else {
|
||||
filterSummary.textContent = summaryText;
|
||||
filterSummary.textContent = parts.join(' · ');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3417,7 +3237,6 @@
|
||||
else if (e.key === 'l' || e.key === 'L') { e.preventDefault(); if (currentSlide) toggleFav(currentSlide); }
|
||||
else if (e.key === 'i' || e.key === 'I') { e.preventDefault(); if (currentSlide) openTagBar(currentSlide.dataset.id); }
|
||||
else if (e.key === 'e' || e.key === 'E') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress upload modal shortcut in abyss
|
||||
else if (e.key === 's' || e.key === 'S') { e.preventDefault(); e.stopImmediatePropagation(); } // suppress flash yank shortcut in scroller
|
||||
else if (e.key === 'Escape') { window.location.href = '/'; }
|
||||
}, true); // capture phase — fires before f0ckm.js bubble listeners
|
||||
|
||||
@@ -3662,26 +3481,11 @@
|
||||
|
||||
const initScrollerSSE = () => {
|
||||
if (sseEs) sseEs.close();
|
||||
let sseUrl = `/api/notifications/stream?tabId=${tabId}`;
|
||||
try {
|
||||
const fp = localStorage.getItem('f0ck_anon_ssh_fp');
|
||||
if (fp) sseUrl += `&fp=${encodeURIComponent(fp)}`;
|
||||
const hw = localStorage.getItem('f0ck_anon_hw_fp');
|
||||
if (hw) sseUrl += `&hw=${encodeURIComponent(hw)}`;
|
||||
} catch (e) {}
|
||||
sseEs = new EventSource(sseUrl);
|
||||
sseEs = new EventSource(`/api/notifications/stream?tabId=${tabId}`);
|
||||
sseEs.onopen = () => { sseRetryCount = 0; };
|
||||
sseEs.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.type === 'banned') {
|
||||
try {
|
||||
const tombstoneData = { banned: true, reason: data.data?.reason, expires: data.data?.expires };
|
||||
document.cookie = `f0ck_banned=${encodeURIComponent(JSON.stringify(tombstoneData))}; Path=/; Max-Age=31536000; SameSite=Lax`;
|
||||
} catch (err) {}
|
||||
window.location.href = data.data?.redirect || '/banned';
|
||||
return;
|
||||
}
|
||||
if (data.type === 'notify') {
|
||||
pollNotifCount(); // instant re-fetch on SSE push
|
||||
if (navigator.vibrate) navigator.vibrate([200, 80, 200]);
|
||||
@@ -3714,8 +3518,8 @@
|
||||
|
||||
// Tab type arrays
|
||||
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||
const sActiveTabEl = sNotifDropdown ? sNotifDropdown.querySelector('.notif-tab.active') : null;
|
||||
let sActiveTab = sActiveTabEl ? sActiveTabEl.dataset.tab : ((window.f0ckEnableComments === false) ? 'system' : 'user');
|
||||
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
|
||||
let sActiveTab = 'user';
|
||||
let sCachedNotifs = [];
|
||||
|
||||
if (sNotifBtn && sNotifDropdown) {
|
||||
@@ -3832,9 +3636,8 @@
|
||||
if (n.type === 'warning') {
|
||||
thumb = `<div class="notif-thumb" style="display:flex;align-items:center;justify-content:center;background:var(--bg-lighter);color:var(--danger);font-size:1.5em;"><i class="fa-solid fa-triangle-exclamation"></i></div>`;
|
||||
} else {
|
||||
// Pending items aren't in /t/ yet: fall back to /pending/t/ (uploader + staff only)
|
||||
const thumbSrc = n.type === 'admin_pending' ? `/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
|
||||
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='/pending/t/${n.item_id}.webp'"></div>` : '';
|
||||
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
|
||||
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
|
||||
}
|
||||
return `<a href="${link}" target="_blank" class="notif-item ${n.is_read ? '' : 'unread'} notif-with-thumb" data-id="${n.id}">
|
||||
${thumb}
|
||||
@@ -3873,24 +3676,19 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all read (current tab only)
|
||||
// Mark all read
|
||||
if (sMarkAll) {
|
||||
sMarkAll.addEventListener('click', async () => {
|
||||
try {
|
||||
const tab = sActiveTab || 'user';
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
await fetch('/api/notifications/read?tab=' + encodeURIComponent(tab), {
|
||||
await fetch('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
});
|
||||
// Mark only the active tab's cached notifications as read
|
||||
const tabCheck = tab === 'user' ? isUserType : (tab === 'system' ? isSystemType : () => false);
|
||||
sCachedNotifs = sCachedNotifs.map(n => tabCheck(n.type) ? { ...n, is_read: true } : n);
|
||||
updateScrollerNotifBadge(0);
|
||||
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
|
||||
updateScrollerTabBadges(sCachedNotifs);
|
||||
renderScrollerNotifs(filterByTab(sCachedNotifs, sActiveTab));
|
||||
// Update overall badge
|
||||
const totalUnread = sCachedNotifs.filter(n => !n.is_read).length;
|
||||
updateScrollerNotifBadge(totalUnread);
|
||||
} catch (e) {}
|
||||
});
|
||||
}
|
||||
@@ -3939,7 +3737,7 @@
|
||||
|
||||
const restored = tryRestoreFromCache();
|
||||
if (!restored) {
|
||||
if (chanHashPending && !applied.externalUrl) {
|
||||
if (chanHashPending) {
|
||||
chanHashPending.then(() => fetchItems());
|
||||
} else {
|
||||
fetchItems();
|
||||
|
||||
+16
-177
@@ -151,10 +151,7 @@
|
||||
const allowedTypes = ['image/gif', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
if (chooseBtn && fileInput) {
|
||||
chooseBtn.addEventListener('click', (e) => {
|
||||
if (e.target.closest('#avatar-remove-btn')) return; // removing, not choosing
|
||||
fileInput.click();
|
||||
});
|
||||
chooseBtn.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files[0];
|
||||
@@ -197,24 +194,8 @@
|
||||
progressWrapper.style.display = 'flex';
|
||||
progressFill.style.width = '0%';
|
||||
progressText.textContent = '0%';
|
||||
progressWrapper.setAttribute('aria-valuenow', '0');
|
||||
showStatus(i18n.uploading || 'Uploading...', '');
|
||||
|
||||
// Show the chosen image in the live preview right away; the progress overlay sits on top of it
|
||||
chooseBtn.classList.add('is-uploading');
|
||||
const liveAvatar = document.getElementById('live-preview-avatar');
|
||||
const prevAvatarSrc = liveAvatar ? liveAvatar.src : null;
|
||||
const localUrl = URL.createObjectURL(file);
|
||||
if (liveAvatar) liveAvatar.src = localUrl;
|
||||
const finishUpload = (ok) => {
|
||||
progressWrapper.style.display = 'none';
|
||||
chooseBtn.classList.remove('is-uploading');
|
||||
if (!ok && liveAvatar && prevAvatarSrc) liveAvatar.src = prevAvatarSrc;
|
||||
// Keep the local image until the server copy has loaded, then free it
|
||||
if (ok && liveAvatar) liveAvatar.addEventListener('load', () => URL.revokeObjectURL(localUrl), { once: true });
|
||||
else URL.revokeObjectURL(localUrl);
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
@@ -224,19 +205,14 @@
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
progressFill.style.width = percent + '%';
|
||||
progressWrapper.setAttribute('aria-valuenow', String(percent));
|
||||
// At 100% the bytes are sent but the server is still processing
|
||||
if (percent >= 100) progressText.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i>';
|
||||
else progressText.textContent = percent + '%';
|
||||
progressText.textContent = percent + '%';
|
||||
}
|
||||
});
|
||||
|
||||
let uploadOk = false;
|
||||
xhr.addEventListener('load', () => {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (xhr.status === 200 && res.success) {
|
||||
uploadOk = true;
|
||||
showStatus(res.msg || 'Avatar uploaded!', 'success');
|
||||
|
||||
// Update preview
|
||||
@@ -260,15 +236,7 @@
|
||||
|
||||
// Show remove button if not present
|
||||
const existingRemoveBtn = document.getElementById('avatar-remove-btn');
|
||||
if (!existingRemoveBtn && chooseBtn.classList.contains('editable-avatar-container')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.id = 'avatar-remove-btn';
|
||||
btn.className = 'avatar-quick-remove';
|
||||
btn.title = 'Remove custom avatar';
|
||||
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
chooseBtn.appendChild(btn);
|
||||
} else if (!existingRemoveBtn) {
|
||||
if (!existingRemoveBtn) {
|
||||
const actionsDiv = document.querySelector('.avatar-upload-actions');
|
||||
if (actionsDiv) {
|
||||
const btn = document.createElement('button');
|
||||
@@ -290,14 +258,14 @@
|
||||
showStatus('Upload failed: Invalid response', 'error');
|
||||
}
|
||||
|
||||
finishUpload(uploadOk);
|
||||
progressWrapper.style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
chooseBtn.disabled = false;
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
showStatus('Upload failed: Network error', 'error');
|
||||
finishUpload(false);
|
||||
progressWrapper.style.display = 'none';
|
||||
uploadBtn.disabled = true;
|
||||
chooseBtn.disabled = false;
|
||||
});
|
||||
@@ -686,43 +654,11 @@
|
||||
|
||||
if (bannerUploadBtn) bannerUploadBtn.disabled = true;
|
||||
if (bannerChooseBtn) bannerChooseBtn.disabled = true;
|
||||
const bannerLabel = (inner) => '<i class="fa-solid fa-image"></i> ' + inner;
|
||||
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'block'; bannerProgressWrapper.setAttribute('aria-valuenow', '0'); }
|
||||
if (bannerProgressWrapper) { bannerProgressWrapper.style.display = 'flex'; }
|
||||
if (bannerProgressFill) bannerProgressFill.style.width = '0%';
|
||||
if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel('0%');
|
||||
if (bannerProgressText) bannerProgressText.textContent = '0%';
|
||||
showBannerStatus('Uploading...', '');
|
||||
|
||||
// Show the cropped banner on the live card right away; roll back if the upload fails
|
||||
const liveCard = document.getElementById('live-profile-preview-box');
|
||||
const prevBanner = liveCard ? {
|
||||
img: liveCard.style.getPropertyValue('--author-banner'),
|
||||
size: liveCard.style.getPropertyValue('--author-banner-size'),
|
||||
pos: liveCard.style.getPropertyValue('--author-banner-position')
|
||||
} : null;
|
||||
const localBannerUrl = URL.createObjectURL(payload.file);
|
||||
if (liveCard) {
|
||||
liveCard.classList.add('is-banner-uploading');
|
||||
liveCard.style.setProperty('--author-banner', `url('${localBannerUrl}')`);
|
||||
liveCard.style.setProperty('--author-banner-size', payload.banner_size);
|
||||
liveCard.style.setProperty('--author-banner-position', payload.banner_position);
|
||||
}
|
||||
const finishBannerUpload = (ok) => {
|
||||
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
|
||||
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
|
||||
if (liveCard) {
|
||||
liveCard.classList.remove('is-banner-uploading');
|
||||
if (!ok && prevBanner) {
|
||||
['--author-banner', '--author-banner-size', '--author-banner-position'].forEach((prop, i) => {
|
||||
const v = [prevBanner.img, prevBanner.size, prevBanner.pos][i];
|
||||
if (v) liveCard.style.setProperty(prop, v); else liveCard.style.removeProperty(prop);
|
||||
});
|
||||
}
|
||||
}
|
||||
// The server URL replaced the local one on success; give the browser time to swap before freeing it
|
||||
setTimeout(() => URL.revokeObjectURL(localBannerUrl), ok ? 5000 : 0);
|
||||
};
|
||||
let bannerOk = false;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', payload.file);
|
||||
if (payload.file_orig) formData.append('file_orig', payload.file_orig);
|
||||
@@ -735,9 +671,7 @@
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
if (bannerProgressFill) bannerProgressFill.style.width = pct + '%';
|
||||
if (bannerProgressWrapper) bannerProgressWrapper.setAttribute('aria-valuenow', String(pct));
|
||||
// At 100% the bytes are sent but the server is still processing
|
||||
if (bannerProgressText) bannerProgressText.innerHTML = bannerLabel(pct >= 100 ? '<i class="fa-solid fa-spinner fa-spin"></i>' : pct + '%');
|
||||
if (bannerProgressText) bannerProgressText.textContent = pct + '%';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -745,7 +679,6 @@
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (xhr.status === 200 && res.success) {
|
||||
bannerOk = true;
|
||||
showBannerStatus(res.msg || 'Banner uploaded!', 'success');
|
||||
|
||||
const bannerPreview = document.getElementById('banner-preview');
|
||||
@@ -777,19 +710,7 @@
|
||||
}
|
||||
|
||||
const existingRemoveBtn = document.getElementById('banner-remove-btn');
|
||||
const descControls = document.getElementById('desc-edit-controls');
|
||||
if (!existingRemoveBtn && descControls) {
|
||||
// Live card: add Reposition / Remove next to Change Banner, same as the server render
|
||||
const mk = (id, cls, icon, label) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button'; b.id = id; b.className = cls;
|
||||
b.style.cssText = 'padding: 4px 10px; font-size: 0.8em;';
|
||||
b.innerHTML = `<i class="fa-solid ${icon}"></i> ${label}`;
|
||||
return b;
|
||||
};
|
||||
descControls.appendChild(mk('banner-edit-btn', 'button button-sm', 'fa-arrows-up-down-left-right', 'Reposition'));
|
||||
descControls.appendChild(mk('banner-remove-btn', 'button button-danger button-sm', 'fa-trash', 'Remove Banner'));
|
||||
} else if (!existingRemoveBtn) {
|
||||
if (!existingRemoveBtn) {
|
||||
const actionsDiv = bannerUploadBtn ? bannerUploadBtn.closest('.avatar-upload-actions') : null;
|
||||
if (actionsDiv) {
|
||||
const editBtn = document.createElement('button');
|
||||
@@ -841,12 +762,14 @@
|
||||
showBannerStatus('Upload failed: Invalid response', 'error');
|
||||
}
|
||||
|
||||
finishBannerUpload(bannerOk);
|
||||
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
|
||||
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
showBannerStatus('Upload failed: Network error', 'error');
|
||||
finishBannerUpload(false);
|
||||
if (bannerProgressWrapper) bannerProgressWrapper.style.display = 'none';
|
||||
if (bannerChooseBtn) bannerChooseBtn.disabled = false;
|
||||
});
|
||||
|
||||
xhr.open('POST', '/api/v2/settings/uploadBanner');
|
||||
@@ -1575,7 +1498,7 @@
|
||||
} else {
|
||||
document.documentElement.classList.remove('blur-untagged-active');
|
||||
}
|
||||
showStatus(enabled ? 'Unrated blurring enabled!' : 'Unrated blurring disabled!', 'success');
|
||||
showStatus(enabled ? 'Untagged blurring enabled!' : 'Untagged blurring disabled!', 'success');
|
||||
});
|
||||
}
|
||||
const blurDetailToggle = document.getElementById('blur_detail_toggle');
|
||||
@@ -1632,74 +1555,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Background & Onara Sliders Helper
|
||||
const setupAudioTuningSlider = (sliderId, valId, tuningKey, defaultVal, formatFn, parserFn) => {
|
||||
const slider = document.getElementById(sliderId);
|
||||
const valSpan = document.getElementById(valId);
|
||||
if (!slider) return;
|
||||
|
||||
let currentTuning = (window.audioVisualizerTuning && Object.keys(window.audioVisualizerTuning).length > 0) ? window.audioVisualizerTuning : {};
|
||||
try {
|
||||
const raw = localStorage.getItem('f0ck_audio_tuning');
|
||||
if (raw) currentTuning = Object.assign({}, currentTuning, JSON.parse(raw));
|
||||
} catch (e) {}
|
||||
|
||||
const initialVal = currentTuning[tuningKey] !== undefined ? Number(currentTuning[tuningKey]) : defaultVal;
|
||||
slider.value = initialVal;
|
||||
if (valSpan) valSpan.textContent = formatFn(initialVal);
|
||||
|
||||
slider.addEventListener('input', () => {
|
||||
const val = parserFn ? parserFn(slider.value) : parseFloat(slider.value);
|
||||
if (valSpan) valSpan.textContent = formatFn(val);
|
||||
if (window.audioVisualizerTuning) window.audioVisualizerTuning[tuningKey] = val;
|
||||
if (window.applyBackgroundOpacitySettings) window.applyBackgroundOpacitySettings(window.audioVisualizerTuning);
|
||||
try {
|
||||
const raw = localStorage.getItem('f0ck_audio_tuning');
|
||||
const t = raw ? JSON.parse(raw) : {};
|
||||
t[tuningKey] = val;
|
||||
localStorage.setItem('f0ck_audio_tuning', JSON.stringify(t));
|
||||
} catch (e) {}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Onara enable/disable toggle
|
||||
const onaraEnabledToggle = document.getElementById('onara_enabled_toggle');
|
||||
if (onaraEnabledToggle) {
|
||||
const LS_KEY = 'f0ck_onara_enabled';
|
||||
const hasGlobalConfig = window.f0ckSession && window.f0ckSession.onara_cfg !== undefined && window.f0ckSession.onara_cfg !== null;
|
||||
|
||||
if (hasGlobalConfig) {
|
||||
const globalVal = !!window.f0ckSession.onara_cfg;
|
||||
onaraEnabledToggle.checked = globalVal;
|
||||
onaraEnabledToggle.disabled = true;
|
||||
window.onara = globalVal;
|
||||
} else {
|
||||
// Seed checkbox from localStorage; fall back to the server-set session value
|
||||
const stored = localStorage.getItem(LS_KEY);
|
||||
const sessionOnara = !!(window.f0ckSession?.onara);
|
||||
onaraEnabledToggle.checked = stored !== null ? stored === 'true' : sessionOnara;
|
||||
// Reflect current state into window.onara so isOnaraActive() sees it
|
||||
window.onara = onaraEnabledToggle.checked;
|
||||
// Only persist an explicit choice; without one the config default keeps applying
|
||||
if (stored !== null) {
|
||||
try {
|
||||
document.cookie = `f0ck_onara=${onaraEnabledToggle.checked ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
onaraEnabledToggle.addEventListener('change', () => {
|
||||
const enabled = onaraEnabledToggle.checked;
|
||||
localStorage.setItem(LS_KEY, String(enabled));
|
||||
try {
|
||||
document.cookie = `f0ck_onara=${enabled ? '1' : '0'}; path=/; max-age=31536000; SameSite=Lax`;
|
||||
} catch {}
|
||||
window.onara = enabled;
|
||||
showStatus('Onara viewer ' + (enabled ? 'enabled' : 'disabled') + '.', 'success');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Quote Emojis Toggle
|
||||
const quoteEmojisToggle = document.getElementById('quote_emojis_toggle');
|
||||
if (quoteEmojisToggle) {
|
||||
@@ -2030,17 +1885,6 @@
|
||||
const liveDesc = document.getElementById('live-preview-description');
|
||||
const descControls = document.getElementById('desc-edit-controls');
|
||||
|
||||
// Inline-editable profile fields are text inputs: keep their key events away from the site-wide
|
||||
// shortcut handlers (bubble-phase on document/window), not all of which skip contenteditable.
|
||||
// The fields' own listeners (Enter / Ctrl+Enter to save, Escape) sit on the element and still run.
|
||||
document.querySelectorAll('.settings [contenteditable="true"]').forEach((el) => {
|
||||
if (el.dataset.keysIsolated) return;
|
||||
el.dataset.keysIsolated = '1';
|
||||
['keydown', 'keyup', 'keypress'].forEach((type) => {
|
||||
el.addEventListener(type, (e) => e.stopPropagation());
|
||||
});
|
||||
});
|
||||
|
||||
if (liveDesc && descriptionTextarea) {
|
||||
liveDesc.addEventListener('focus', () => {
|
||||
if (descControls) descControls.style.display = 'flex';
|
||||
@@ -2163,9 +2007,7 @@
|
||||
if (passwordForm) {
|
||||
passwordForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
// Absent on passkey-only accounts (no current password; setting one re-enables password login)
|
||||
const currentInput = document.getElementById('current_password');
|
||||
const current_password = currentInput ? currentInput.value : '';
|
||||
const current_password = document.getElementById('current_password').value;
|
||||
const new_password = document.getElementById('new_password').value;
|
||||
const new_password_confirm = document.getElementById('new_password_confirm').value;
|
||||
|
||||
@@ -2175,7 +2017,6 @@
|
||||
}
|
||||
|
||||
const btn = passwordForm.querySelector('button');
|
||||
const btnLabel = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Updating...';
|
||||
|
||||
@@ -2192,8 +2033,6 @@
|
||||
if (data.success) {
|
||||
showAccountStatus(passwordStatus, data.msg || 'Password updated correctly', 'success');
|
||||
passwordForm.reset();
|
||||
// Password login was off: reload so the account section shows the normal state again
|
||||
if (!currentInput) setTimeout(() => location.reload(), 900);
|
||||
} else {
|
||||
showAccountStatus(passwordStatus, data.msg || 'Failed to update password', 'error');
|
||||
}
|
||||
@@ -2201,7 +2040,7 @@
|
||||
showAccountStatus(passwordStatus, 'Request failed', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = btnLabel;
|
||||
btn.textContent = 'Update Password';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+15
-1596
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -18,22 +18,12 @@ window.TagAutocomplete = (() => {
|
||||
const MIN_QUERY_LEN = 1;
|
||||
|
||||
function destroy() {
|
||||
if (activeInstance) {
|
||||
if (activeInstance.cleanup) {
|
||||
try { activeInstance.cleanup(); } catch {}
|
||||
}
|
||||
const { wrapper } = activeInstance;
|
||||
if (wrapper && wrapper.parentElement) {
|
||||
wrapper.parentElement.removeChild(wrapper);
|
||||
}
|
||||
activeInstance = null;
|
||||
if (!activeInstance) return;
|
||||
const { wrapper } = activeInstance;
|
||||
if (wrapper && wrapper.parentElement) {
|
||||
wrapper.parentElement.removeChild(wrapper);
|
||||
}
|
||||
document.querySelectorAll('.tag-ac-wrapper').forEach(el => el.remove());
|
||||
}
|
||||
|
||||
function isOpen() {
|
||||
return !!(activeInstance && activeInstance.wrapper && activeInstance.wrapper.parentElement) ||
|
||||
!!document.querySelector('.tag-ac-wrapper');
|
||||
activeInstance = null;
|
||||
}
|
||||
|
||||
function open(opts) {
|
||||
@@ -226,21 +216,10 @@ window.TagAutocomplete = (() => {
|
||||
|
||||
input.addEventListener('input', onInput);
|
||||
|
||||
const onEscapeKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
wrapper.addEventListener('keydown', onEscapeKey);
|
||||
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onEscapeKey(e);
|
||||
e.preventDefault();
|
||||
destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,14 +310,6 @@ window.TagAutocomplete = (() => {
|
||||
}
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
document.removeEventListener('mousedown', onDocMousedown);
|
||||
document.removeEventListener('touchstart', onDocTouchstart);
|
||||
if (outsideTapTimer) clearTimeout(outsideTapTimer);
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
};
|
||||
activeInstance.cleanup = cleanup;
|
||||
|
||||
// Delay attaching to avoid capturing the opening touch.
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', onDocMousedown);
|
||||
@@ -367,5 +338,5 @@ window.TagAutocomplete = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
return { open, destroy, isOpen };
|
||||
return { open, destroy };
|
||||
})();
|
||||
|
||||
+48
-643
@@ -7,16 +7,6 @@ window.escapeHtmlUpload = window.escapeHtmlUpload || ((unsafe) => {
|
||||
.replace(/'/g, "'");
|
||||
});
|
||||
|
||||
// Manual approval: after an upload, send the uploader to their status page focused on the new item
|
||||
window.f0ckGoToPending = window.f0ckGoToPending || ((id) => {
|
||||
const target = '/pending' + (id ? '#i' + id : '');
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(target, false, { bypassCache: true, skipCache: true, skipInherit: true });
|
||||
} else {
|
||||
window.location.href = target;
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// URL Upload Tracker — single panel, active jobs only
|
||||
// ============================================================
|
||||
@@ -208,9 +198,7 @@ window.f0ckGoToPending = window.f0ckGoToPending || ((id) => {
|
||||
tag_id: item.rating_tag_id || item.tag_id || 0,
|
||||
is_oc: !!item.is_oc,
|
||||
slug: item.slug,
|
||||
visibility: item.visibility || 0,
|
||||
is_album: !!item.is_album,
|
||||
album_count: item.album_count || 0
|
||||
visibility: item.visibility || 0
|
||||
});
|
||||
}
|
||||
})
|
||||
@@ -466,17 +454,6 @@ window.initUploadForm = (selector) => {
|
||||
const statusDiv = form.querySelector('.upload-status');
|
||||
const thumbSection = form.querySelector('#custom-thumbnail-section');
|
||||
const thumbInput = form.querySelector('#upload-thumbnail-input');
|
||||
const redirectCheckbox = form.querySelector('input[name="redirect_to_item"], #upload-redirect-checkbox');
|
||||
|
||||
if (redirectCheckbox) {
|
||||
const savedRedirect = localStorage.getItem('upload_redirect_to_item');
|
||||
if (savedRedirect !== null) {
|
||||
redirectCheckbox.checked = (savedRedirect === 'true' || savedRedirect === '1');
|
||||
}
|
||||
redirectCheckbox.addEventListener('change', () => {
|
||||
localStorage.setItem('upload_redirect_to_item', redirectCheckbox.checked ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
// Capture the SSR-translated initial button text so updateSubmitButton
|
||||
// always uses the correct language regardless of window.f0ckI18n state.
|
||||
@@ -523,34 +500,6 @@ window.initUploadForm = (selector) => {
|
||||
let autoTags = []; // Track tags suggested from metadata
|
||||
let selectedFiles = []; // Array of files for shitpost_mode
|
||||
let activeMode = 'file'; // 'file' or 'url'
|
||||
|
||||
// Album mode state and helpers
|
||||
const albumChoiceContainer = form.querySelector('#album-choice-container');
|
||||
let albumChoiceMode = 'album'; // 'album' | 'batch'
|
||||
|
||||
const isAlbumModeActive = () => {
|
||||
if (activeMode === 'album') {
|
||||
return selectedFiles.length > 0;
|
||||
}
|
||||
return activeMode === 'file' && selectedFiles.length > 1 && (albumChoiceMode === 'album' || !isShitpost);
|
||||
};
|
||||
|
||||
if (albumChoiceContainer) {
|
||||
const btns = albumChoiceContainer.querySelectorAll('.album-choice-btn');
|
||||
btns.forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const choice = btn.getAttribute('data-choice');
|
||||
if (choice === albumChoiceMode) return;
|
||||
albumChoiceMode = choice;
|
||||
btns.forEach(b => b.classList.toggle('active', b === btn));
|
||||
selectedFiles.forEach(item => { delete item._rendered; });
|
||||
if (filePreview) filePreview.innerHTML = '';
|
||||
handleFile();
|
||||
updateSubmitButton();
|
||||
});
|
||||
});
|
||||
}
|
||||
// Shared emoji cache for per-item pickers (fetched once, reused by all items)
|
||||
let _emojiCache = null;
|
||||
let _emojiCachePromise = null;
|
||||
@@ -700,47 +649,14 @@ window.initUploadForm = (selector) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const mode = tab.dataset.mode;
|
||||
if (mode === activeMode) return;
|
||||
const prevMode = activeMode;
|
||||
activeMode = mode;
|
||||
|
||||
modeTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
|
||||
if (modeFile) modeFile.style.display = (mode === 'file' || mode === 'album') ? '' : 'none';
|
||||
if (modeFile) modeFile.style.display = mode === 'file' ? '' : 'none';
|
||||
if (modeUrl) modeUrl.style.display = mode === 'url' ? '' : 'none';
|
||||
|
||||
if (mode === 'album') {
|
||||
albumChoiceMode = 'album';
|
||||
form.classList.add('album-mode-active');
|
||||
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
|
||||
if (fileInput) {
|
||||
try {
|
||||
const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
|
||||
fileInput.accept = Object.keys(mimesObj).join(',') + (form.getAttribute('data-beatmaps') === '1' ? ',.osz' : '');
|
||||
} catch {}
|
||||
}
|
||||
if (selectedFiles.length > 0) {
|
||||
renderAlbumStaging();
|
||||
}
|
||||
} else if (mode === 'file') {
|
||||
if (fileInput) {
|
||||
try {
|
||||
const mimesObj = JSON.parse(form.getAttribute('data-mimes') || '{}');
|
||||
fileInput.accept = Object.keys(mimesObj).join(',') + (form.getAttribute('data-beatmaps') === '1' ? ',.osz' : '');
|
||||
} catch {}
|
||||
}
|
||||
if (!isAlbumModeActive()) {
|
||||
form.classList.remove('album-mode-active');
|
||||
if (prevMode === 'album' && isShitpost && selectedFiles.length > 0) {
|
||||
selectedFiles.forEach(item => { delete item._rendered; });
|
||||
if (filePreview) filePreview.innerHTML = '';
|
||||
handleFile();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
form.classList.remove('album-mode-active');
|
||||
}
|
||||
|
||||
// Reset status
|
||||
if (statusDiv) {
|
||||
statusDiv.textContent = '';
|
||||
@@ -1097,17 +1013,11 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
const isShitpost = !!window.f0ckShitpostMode;
|
||||
const isAlbum = isAlbumModeActive();
|
||||
const isAlbumTab = activeMode === 'album';
|
||||
const isAlbumActive = isAlbum || isAlbumTab;
|
||||
|
||||
form.classList.toggle('album-mode-active', isAlbumActive);
|
||||
|
||||
const rating = form.querySelector('input[name="rating"]:checked');
|
||||
|
||||
// In Shitpost Mode, ratings are per-item unless album mode is active
|
||||
// In Shitpost Mode, ratings are per-item. If require rating is true, every item must be rated.
|
||||
let hasRating = true;
|
||||
if (isShitpost && !isAlbumActive && activeMode === 'file') {
|
||||
if (isShitpost && activeMode === 'file') {
|
||||
if (shitpostRequireRating) {
|
||||
hasRating = selectedFiles.length > 0 && selectedFiles.every(item => ['sfw', 'nsfw', 'nsfl'].includes(item.rating));
|
||||
}
|
||||
@@ -1116,7 +1026,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
let hasTags = true;
|
||||
if (!isShitpost || isAlbumActive) {
|
||||
if (!isShitpost) {
|
||||
hasTags = tags.length >= minTags;
|
||||
} else if (shitpostMinTags > 0 && activeMode === 'file') {
|
||||
// In shitpost file mode with min-tags enforced: every queued item must meet the threshold.
|
||||
@@ -1128,21 +1038,17 @@ window.initUploadForm = (selector) => {
|
||||
const commentSec = form.querySelector('.global-comment-section');
|
||||
const tagsSec = form.querySelector('.global-tag-section');
|
||||
const ocSec = form.querySelector('.global-oc-section');
|
||||
const titleSec = form.querySelector('.global-title-section');
|
||||
const formActions = form.querySelector('.form-actions');
|
||||
if (isShitpost) {
|
||||
if (formActions) {
|
||||
formActions.style.display = (activeMode === 'url' && !isAlbumActive) ? 'none' : 'block';
|
||||
formActions.style.display = activeMode === 'url' ? 'none' : 'block';
|
||||
}
|
||||
const hide = activeMode === 'file' && !isAlbumActive;
|
||||
const hide = activeMode === 'file';
|
||||
const disp = hide ? 'none' : 'block';
|
||||
|
||||
if (ratingSec) {
|
||||
ratingSec.style.display = disp;
|
||||
ratingSec.querySelectorAll('input').forEach(i => {
|
||||
i.disabled = hide;
|
||||
i.required = !hide;
|
||||
});
|
||||
ratingSec.querySelectorAll('input').forEach(i => i.disabled = hide);
|
||||
}
|
||||
if (commentSec) {
|
||||
commentSec.style.display = disp;
|
||||
@@ -1153,19 +1059,13 @@ window.initUploadForm = (selector) => {
|
||||
tagsSec.querySelectorAll('input').forEach(i => i.disabled = hide);
|
||||
}
|
||||
if (ocSec) {
|
||||
ocSec.style.display = isAlbumActive ? 'block' : 'none';
|
||||
ocSec.querySelectorAll('input').forEach(i => i.disabled = !isAlbumActive);
|
||||
}
|
||||
if (titleSec) {
|
||||
titleSec.style.display = isAlbumActive ? 'block' : 'none';
|
||||
titleSec.querySelectorAll('input').forEach(i => i.disabled = !isAlbumActive);
|
||||
ocSec.style.display = 'none';
|
||||
ocSec.querySelectorAll('input').forEach(i => i.disabled = true);
|
||||
}
|
||||
}
|
||||
|
||||
let hasContent = false;
|
||||
if (activeMode === 'album') {
|
||||
hasContent = selectedFiles.length >= 2;
|
||||
} else if (activeMode === 'file') {
|
||||
if (activeMode === 'file') {
|
||||
hasContent = selectedFiles.length > 0;
|
||||
} else {
|
||||
hasContent = urlInput && urlInput.value.trim().length > 0;
|
||||
@@ -1177,19 +1077,13 @@ window.initUploadForm = (selector) => {
|
||||
const btnText = submitBtn.querySelector('.btn-text');
|
||||
if (btnText) {
|
||||
const i18n = window.f0ckI18n || {};
|
||||
if (activeMode === 'album' && selectedFiles.length === 0) {
|
||||
btnText.textContent = i18n.album_select_pictures || 'Select files for album';
|
||||
submitBtn.disabled = true;
|
||||
} else if (activeMode === 'album' && selectedFiles.length === 1) {
|
||||
btnText.textContent = i18n.album_min_pictures || 'Add at least 2 items for an album';
|
||||
submitBtn.disabled = true;
|
||||
} else if (!hasContent) {
|
||||
if (!hasContent) {
|
||||
btnText.textContent = activeMode === 'file'
|
||||
? (ssrSelectFileText || i18n.select_file || 'Select a file')
|
||||
: (i18n.enter_url || 'Enter a URL');
|
||||
} else if (!hasTags) {
|
||||
// non-shitpost or shitpost with min-tags
|
||||
if (isShitpost && !isAlbumActive && shitpostMinTags > 0) {
|
||||
if (isShitpost && shitpostMinTags > 0) {
|
||||
const remaining = shitpostMinTags - Math.min(...selectedFiles.map(item => (item.tags || []).length));
|
||||
btnText.textContent = `${remaining} more tag${remaining !== 1 ? 's' : ''} required per item`;
|
||||
} else {
|
||||
@@ -1201,7 +1095,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
} else if (!hasRating) {
|
||||
const nsflEnabled = !!form.querySelector('input[name="rating"][value="nsfl"]');
|
||||
if (isShitpost && !isAlbumActive && shitpostRequireRating) {
|
||||
if (isShitpost && shitpostRequireRating) {
|
||||
btnText.textContent = 'Select a rating for each item';
|
||||
} else {
|
||||
if (nsflEnabled) {
|
||||
@@ -1211,10 +1105,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isAlbumActive) {
|
||||
const tpl = i18n.upload_album || 'Upload Album (%s subf0cks)';
|
||||
btnText.textContent = tpl.replace('%s', selectedFiles.length);
|
||||
} else if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) {
|
||||
if (activeMode === 'url' && urlInput && ytRegex.test(urlInput.value.trim()) && window.f0ckEnableYoutubeUpload !== false) {
|
||||
btnText.textContent = i18n.embed_youtube || 'Embed YouTube Video';
|
||||
} else if (activeMode === 'url') {
|
||||
btnText.textContent = i18n.upload_from_url || 'Upload from URL';
|
||||
@@ -1235,237 +1126,15 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
};
|
||||
|
||||
const renderAlbumStaging = () => {
|
||||
if (!filePreview) return;
|
||||
filePreview.style.display = 'block';
|
||||
filePreview.innerHTML = '';
|
||||
|
||||
const stagingCont = document.createElement('div');
|
||||
stagingCont.className = 'album-staging-container';
|
||||
|
||||
const stagingHeader = document.createElement('div');
|
||||
stagingHeader.className = 'album-staging-header';
|
||||
stagingHeader.innerHTML = `
|
||||
<div class="album-staging-title">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
<span>${(window.f0ckI18n && window.f0ckI18n.album_title) || 'Album'} (${selectedFiles.length} subf0cks)</span>
|
||||
</div>
|
||||
<button type="button" class="btn-add-album-pics">
|
||||
<i class="fa-solid fa-plus"></i> ${(window.f0ckI18n && window.f0ckI18n.album_add_more) || 'Add subf0cks'}
|
||||
</button>
|
||||
`;
|
||||
const addBtn = stagingHeader.querySelector('.btn-add-album-pics');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (fileInput) fileInput.click();
|
||||
});
|
||||
}
|
||||
stagingCont.appendChild(stagingHeader);
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'album-staging-grid';
|
||||
|
||||
selectedFiles.forEach((item, index) => {
|
||||
const file = item.file || item;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'album-stage-card' + (index === 0 ? ' is-cover' : '');
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'album-stage-badge';
|
||||
badge.innerHTML = index === 0 ? '<i class="fa-solid fa-star"></i> Cover' : `#${index + 1}`;
|
||||
card.appendChild(badge);
|
||||
|
||||
// URL items (YouTube links etc.) — no File/Blob available
|
||||
if (item.type === 'url') {
|
||||
const urlStr = item.url || '';
|
||||
const ytMatch = urlStr.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([A-Za-z0-9_-]{11})/);
|
||||
if (ytMatch) {
|
||||
const thumb = document.createElement('img');
|
||||
thumb.src = `https://img.youtube.com/vi/${ytMatch[1]}/mqdefault.jpg`;
|
||||
thumb.alt = 'YouTube thumbnail';
|
||||
thumb.style.cssText = 'width:100%;height:100%;object-fit:cover;';
|
||||
card.appendChild(thumb);
|
||||
const mimeBadge = document.createElement('span');
|
||||
mimeBadge.className = 'album-stage-mime-badge';
|
||||
mimeBadge.innerHTML = '<i class="fa-brands fa-youtube"></i>';
|
||||
card.appendChild(mimeBadge);
|
||||
} else {
|
||||
const urlPreview = document.createElement('div');
|
||||
urlPreview.className = 'album-stage-audio-preview';
|
||||
const shortUrl = urlStr.replace(/^https?:\/\//, '').substring(0, 40);
|
||||
urlPreview.innerHTML = `
|
||||
<i class="fa-solid fa-link"></i>
|
||||
<span class="album-stage-audio-name" title="${urlStr}">${shortUrl}</span>
|
||||
`;
|
||||
card.appendChild(urlPreview);
|
||||
const mimeBadge = document.createElement('span');
|
||||
mimeBadge.className = 'album-stage-mime-badge';
|
||||
mimeBadge.innerHTML = '<i class="fa-solid fa-link"></i>';
|
||||
card.appendChild(mimeBadge);
|
||||
}
|
||||
} else {
|
||||
const isVideo = (file.type && file.type.startsWith('video/')) || /\.(mp4|webm|mov|mkv)$/i.test(file.name || '');
|
||||
const isAudio = (file.type && file.type.startsWith('audio/')) || /\.(mp3|ogg|wav|flac|m4a|aac)$/i.test(file.name || '');
|
||||
|
||||
if (isVideo) {
|
||||
const video = document.createElement('video');
|
||||
video.src = URL.createObjectURL(file);
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.autoplay = false;
|
||||
video.preload = 'metadata';
|
||||
card.appendChild(video);
|
||||
|
||||
const mimeBadge = document.createElement('span');
|
||||
mimeBadge.className = 'album-stage-mime-badge';
|
||||
mimeBadge.innerHTML = '<i class="fa-solid fa-play"></i>';
|
||||
card.appendChild(mimeBadge);
|
||||
} else if (isAudio) {
|
||||
const audioPreview = document.createElement('div');
|
||||
audioPreview.className = 'album-stage-audio-preview';
|
||||
audioPreview.innerHTML = `
|
||||
<i class="fa-solid fa-music"></i>
|
||||
<span class="album-stage-audio-name" title="${file.name || 'Audio'}">${file.name || 'Audio'}</span>
|
||||
`;
|
||||
card.appendChild(audioPreview);
|
||||
|
||||
const mimeBadge = document.createElement('span');
|
||||
mimeBadge.className = 'album-stage-mime-badge';
|
||||
mimeBadge.innerHTML = '<i class="fa-solid fa-music"></i>';
|
||||
card.appendChild(mimeBadge);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.alt = file.name || `Subf0ck ${index + 1}`;
|
||||
card.appendChild(img);
|
||||
}
|
||||
}
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'album-stage-actions';
|
||||
|
||||
// Move Left
|
||||
const btnLeft = document.createElement('button');
|
||||
btnLeft.type = 'button';
|
||||
btnLeft.className = 'album-action-btn btn-album-move-left';
|
||||
btnLeft.title = window.f0ckI18n?.album_move_left || 'Move left';
|
||||
btnLeft.innerHTML = '<i class="fa-solid fa-arrow-left"></i>';
|
||||
if (index === 0) {
|
||||
btnLeft.disabled = true;
|
||||
btnLeft.style.opacity = '0.3';
|
||||
btnLeft.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
btnLeft.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const temp = selectedFiles[index];
|
||||
selectedFiles[index] = selectedFiles[index - 1];
|
||||
selectedFiles[index - 1] = temp;
|
||||
renderAlbumStaging();
|
||||
updateSubmitButton();
|
||||
});
|
||||
}
|
||||
actions.appendChild(btnLeft);
|
||||
|
||||
// Move Right
|
||||
const btnRight = document.createElement('button');
|
||||
btnRight.type = 'button';
|
||||
btnRight.className = 'album-action-btn btn-album-move-right';
|
||||
btnRight.title = window.f0ckI18n?.album_move_right || 'Move right';
|
||||
btnRight.innerHTML = '<i class="fa-solid fa-arrow-right"></i>';
|
||||
if (index === selectedFiles.length - 1) {
|
||||
btnRight.disabled = true;
|
||||
btnRight.style.opacity = '0.3';
|
||||
btnRight.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
btnRight.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const temp = selectedFiles[index];
|
||||
selectedFiles[index] = selectedFiles[index + 1];
|
||||
selectedFiles[index + 1] = temp;
|
||||
renderAlbumStaging();
|
||||
updateSubmitButton();
|
||||
});
|
||||
}
|
||||
actions.appendChild(btnRight);
|
||||
|
||||
// Remove
|
||||
const btnRemove = document.createElement('button');
|
||||
btnRemove.type = 'button';
|
||||
btnRemove.className = 'album-action-btn btn-album-remove';
|
||||
btnRemove.title = window.f0ckI18n?.album_remove || 'Remove subf0ck';
|
||||
btnRemove.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
btnRemove.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectedFiles.splice(index, 1);
|
||||
if (selectedFiles.length === 0) {
|
||||
if (form._f0ckUploader && typeof form._f0ckUploader.reset === 'function') {
|
||||
form._f0ckUploader.reset();
|
||||
}
|
||||
} else {
|
||||
handleFile();
|
||||
updateSubmitButton();
|
||||
}
|
||||
});
|
||||
actions.appendChild(btnRemove);
|
||||
|
||||
card.appendChild(actions);
|
||||
|
||||
const caption = document.createElement('div');
|
||||
caption.className = 'album-stage-caption';
|
||||
caption.title = file.name;
|
||||
card.appendChild(caption);
|
||||
|
||||
const tagWrap = document.createElement('div');
|
||||
tagWrap.className = 'album-stage-tags-wrap';
|
||||
const tagInput = document.createElement('input');
|
||||
tagInput.type = 'text';
|
||||
tagInput.className = 'album-stage-tags-input';
|
||||
tagInput.placeholder = (window.f0ckI18n && window.f0ckI18n.album_subf0ck_tags_placeholder) || 'Tags for this subf0ck (optional)';
|
||||
tagInput.value = item.subTags || '';
|
||||
tagInput.addEventListener('input', () => {
|
||||
item.subTags = tagInput.value;
|
||||
});
|
||||
tagInput.addEventListener('click', (e) => e.stopPropagation());
|
||||
tagWrap.appendChild(tagInput);
|
||||
card.appendChild(tagWrap);
|
||||
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
// Add More Card in Grid if under 100 subf0cks
|
||||
const maxAlbumItems = 100;
|
||||
if (selectedFiles.length < maxAlbumItems) {
|
||||
const addMoreCard = document.createElement('div');
|
||||
addMoreCard.className = 'album-stage-card album-stage-add-more';
|
||||
addMoreCard.innerHTML = `
|
||||
<div class="album-add-icon"><i class="fa-solid fa-plus"></i></div>
|
||||
<div class="album-add-text">${(window.f0ckI18n && window.f0ckI18n.album_add_more) || 'Add subf0cks'}</div>
|
||||
<div class="album-add-sub">(${selectedFiles.length}/${maxAlbumItems})</div>
|
||||
`;
|
||||
addMoreCard.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (fileInput) fileInput.click();
|
||||
});
|
||||
grid.appendChild(addMoreCard);
|
||||
}
|
||||
|
||||
stagingCont.appendChild(grid);
|
||||
filePreview.appendChild(stagingCont);
|
||||
};
|
||||
|
||||
const handleFile = (files) => {
|
||||
const isShitpost = !!window.f0ckShitpostMode;
|
||||
|
||||
// If files were provided, process them (append or replace)
|
||||
if (files && files.length > 0) {
|
||||
const isMultiAllowed = isShitpost || activeMode === 'album' || files.length > 1 || selectedFiles.length > 0;
|
||||
const filesToProcess = isMultiAllowed ? Array.from(files) : [files[0]];
|
||||
if (!isMultiAllowed && selectedFiles.length === 0) {
|
||||
selectedFiles = []; // Reset for normal mode single non-image file
|
||||
const filesToProcess = isShitpost ? Array.from(files) : [files[0]];
|
||||
if (!isShitpost) {
|
||||
selectedFiles = []; // Reset for normal mode — replace, not append
|
||||
// Also wipe the preview DOM so the old card doesn't linger
|
||||
if (filePreview) filePreview.innerHTML = '';
|
||||
}
|
||||
|
||||
@@ -1530,9 +1199,7 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
const mimeOk = !file.type || allowedMimes.includes(file.type);
|
||||
// Beatmap sets (.osz): the server turns them into an audio post with Square Clicker maps
|
||||
const extOk = (allowedExts.length > 0 && allowedExts.includes(fileExt)) ||
|
||||
(fileExt === 'osz' && form.getAttribute('data-beatmaps') === '1');
|
||||
const extOk = allowedExts.length > 0 && allowedExts.includes(fileExt);
|
||||
|
||||
if (allowedMimes.length > 0 && !mimeOk && !extOk) {
|
||||
const errorMsg = `File type ${file.type || '.' + fileExt} is not allowed.`;
|
||||
@@ -1552,32 +1219,18 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (activeMode === 'album' && selectedFiles.length >= 100) {
|
||||
const errorMsg = 'Album limit reached (maximum 100 subf0cks).';
|
||||
if (typeof window.flashMessage === 'function') window.flashMessage('✕ ' + errorMsg, 4000, 'error');
|
||||
else if (window.showFlash) window.showFlash(errorMsg, 'error');
|
||||
else if (statusDiv) { statusDiv.textContent = errorMsg; statusDiv.className = 'upload-status error'; }
|
||||
break;
|
||||
}
|
||||
|
||||
if (!selectedFiles.some(f => (f.file || f).name === file.name && (f.file || f).size === file.size)) {
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
if (isShitpost) {
|
||||
selectedFiles.push({ type: 'file', file: file, rating: '', visibility: '', tags: [], comment: '', title: '', is_oc: false });
|
||||
} else {
|
||||
selectedFiles.push(file); // Legacy single file mode uses raw File
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle album choice container
|
||||
if (activeMode === 'album') {
|
||||
albumChoiceMode = 'album';
|
||||
}
|
||||
const isAlbumCandidate = activeMode === 'file' && selectedFiles.length > 1;
|
||||
if (albumChoiceContainer) {
|
||||
albumChoiceContainer.style.display = isAlbumCandidate ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Rebuild UI state
|
||||
if (selectedFiles.length === 0) {
|
||||
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
|
||||
if (filePreview) {
|
||||
filePreview.style.display = 'none';
|
||||
filePreview.innerHTML = '';
|
||||
@@ -1604,23 +1257,14 @@ window.initUploadForm = (selector) => {
|
||||
statusDiv.className = 'upload-status';
|
||||
}
|
||||
|
||||
// Force 'file' or 'album' mode tab UI if coming from URL mode
|
||||
if (activeMode !== 'file' && activeMode !== 'album' && modeTabs.length > 0) {
|
||||
// Force 'file' mode tab UI
|
||||
if (activeMode !== 'file' && modeTabs.length > 0) {
|
||||
modeTabs.forEach(t => t.classList.remove('active'));
|
||||
const targetMode = isAlbumModeActive() ? 'album' : 'file';
|
||||
const targetTab = form.querySelector(`.upload-mode-tab[data-mode="${targetMode}"]`);
|
||||
if (targetTab) targetTab.classList.add('active');
|
||||
const fileTab = form.querySelector('.upload-mode-tab[data-mode="file"]');
|
||||
if (fileTab) fileTab.classList.add('active');
|
||||
if (modeFile) modeFile.style.display = '';
|
||||
if (modeUrl) modeUrl.style.display = 'none';
|
||||
activeMode = targetMode;
|
||||
}
|
||||
|
||||
// If Album Mode is active, render Album Staging
|
||||
if (isAlbumModeActive()) {
|
||||
renderAlbumStaging();
|
||||
updateSubmitButton();
|
||||
form.dispatchEvent(new CustomEvent('fileReady', { detail: { files: selectedFiles } }));
|
||||
return true;
|
||||
activeMode = 'file';
|
||||
}
|
||||
|
||||
let lastNewPreviewItem = null;
|
||||
@@ -2318,7 +1962,7 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
// Legacy Global Meta Sync (Non-Shitpost Mode)
|
||||
if (!isShitpost && selectedFiles.length > 0 && files && files.length > 0) {
|
||||
const primaryFile = selectedFiles[0].file || selectedFiles[0];
|
||||
const primaryFile = selectedFiles[0];
|
||||
autoTags = [];
|
||||
const metaCont = form.querySelector('.meta-suggestions-container');
|
||||
const metaList = form.querySelector('.meta-suggestions-list');
|
||||
@@ -2440,7 +2084,6 @@ window.initUploadForm = (selector) => {
|
||||
if (el._swfObjectUrl) { URL.revokeObjectURL(el._swfObjectUrl); el._swfObjectUrl = null; }
|
||||
});
|
||||
selectedFiles = [];
|
||||
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
|
||||
form.querySelector('.gps-privacy-warning')?.remove();
|
||||
if (fileInput) fileInput.value = '';
|
||||
if (dropZonePrompt) dropZonePrompt.style.display = 'block';
|
||||
@@ -2832,22 +2475,17 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
const isFileMode = activeMode === 'file';
|
||||
const isAlbum = isAlbumModeActive() || activeMode === 'album';
|
||||
|
||||
const globalRatingEl = form.querySelector('input[name="rating"]:checked');
|
||||
|
||||
// Validation
|
||||
if (isShitpost && isFileMode && !isAlbum) {
|
||||
if (isShitpost && isFileMode) {
|
||||
if (selectedFiles.length === 0) {
|
||||
if (window.showFlash) window.showFlash('No files selected', 'error');
|
||||
return;
|
||||
}
|
||||
// No tag or rating requirement in shitpost mode — untagged items are allowed
|
||||
} else {
|
||||
if (isAlbum && selectedFiles.length < 2) {
|
||||
if (window.showFlash) window.showFlash('Add at least 2 pictures for an album', 'error');
|
||||
return;
|
||||
}
|
||||
if (!globalRatingEl) {
|
||||
if (window.showFlash) window.showFlash('Please select a rating', 'error');
|
||||
return;
|
||||
@@ -3014,40 +2652,8 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (lastData?.manual_approval && lastData?.itemid) window.f0ckGoToPending(lastData.itemid);
|
||||
|
||||
// URL uploads: redirect or stay based on user preference (pending/async jobs skip redirect)
|
||||
if (!lastData?.pending && !lastData?.manual_approval) {
|
||||
if (lastData?.itemid && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: lastData.itemid,
|
||||
dest: lastData.dest,
|
||||
mime: lastData.mime,
|
||||
username: lastData.username || window.f0ckSession?.user || '',
|
||||
display_name: lastData.display_name || window.f0ckSession?.display_name || null,
|
||||
tag_id: lastData.tag_id ?? 0,
|
||||
is_oc: !!lastData.is_oc,
|
||||
slug: lastData.slug,
|
||||
visibility: lastData.visibility || 0,
|
||||
is_album: !!lastData.is_album,
|
||||
album_count: lastData.album_count || 0
|
||||
});
|
||||
}
|
||||
|
||||
const shouldRedirectToItem = redirectCheckbox
|
||||
? redirectCheckbox.checked
|
||||
: (localStorage.getItem('upload_redirect_to_item') !== 'false');
|
||||
if (shouldRedirectToItem && lastData?.itemid) {
|
||||
const isMultiNonAlbumShitpost = isShitpost && (successCount > 1 || urls.length > 1);
|
||||
const targetUrl = isMultiNonAlbumShitpost ? '/' : (lastData.slug ? `/${lastData.slug}` : `/${lastData.itemid}`);
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(targetUrl, false, { bypassCache: true, skipCache: true, skipInherit: true });
|
||||
} else {
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
}
|
||||
// else: stay on current page
|
||||
}
|
||||
// URL uploads: always stay on current page — tracker panel shows progress
|
||||
// (no redirect here; the file upload path below handles its own redirect)
|
||||
} else {
|
||||
restoreBtn();
|
||||
}
|
||||
@@ -3055,141 +2661,6 @@ window.initUploadForm = (selector) => {
|
||||
// --- File Upload ---
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const isAlbum = isAlbumModeActive() || activeMode === 'album';
|
||||
if (isAlbum) {
|
||||
const statusMsg = window.f0ckI18n?.uploading_album || `Uploading album (${selectedFiles.length} pictures)...`;
|
||||
setBtnLoading(statusMsg);
|
||||
if (progressContainer) progressContainer.style.display = 'flex';
|
||||
if (statusDiv) {
|
||||
statusDiv.textContent = '';
|
||||
statusDiv.className = 'upload-status';
|
||||
}
|
||||
|
||||
const globalRatingEl = form.querySelector('input[name="rating"]:checked');
|
||||
const globalVisEl = form.querySelector('input[name="visibility"]:checked');
|
||||
const globalExpiryEl = form.querySelector('select[name="expiry"], input[name="expiry"]');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('is_album', 'true');
|
||||
formData.append('rating', globalRatingEl ? globalRatingEl.value : 'sfw');
|
||||
formData.append('visibility', globalVisEl ? globalVisEl.value : '0');
|
||||
formData.append('expiry', globalExpiryEl ? globalExpiryEl.value : 'permanent');
|
||||
formData.append('tags', tags.join(','));
|
||||
formData.append('is_oc', isOc ? 'true' : 'false');
|
||||
if (titleVal) formData.append('title', titleVal);
|
||||
if (comment) formData.append('comment', comment);
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const item = selectedFiles[i];
|
||||
if (item.type === 'url') {
|
||||
// URL items (YouTube links etc.) — send as a URL field, not a binary file
|
||||
formData.append(`subf0ck_url_${i}`, item.url || '');
|
||||
} else {
|
||||
const f = item.file || item;
|
||||
formData.append('files', f);
|
||||
}
|
||||
const subTags = item.subTags || '';
|
||||
if (subTags) {
|
||||
formData.append(`subf0ck_tags_${i}`, subTags);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percent = Math.round((e.loaded / e.total) * 100);
|
||||
if (progressFill) progressFill.style.width = percent + '%';
|
||||
if (progressText) progressText.textContent = percent + '%';
|
||||
}
|
||||
});
|
||||
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const data = JSON.parse(xhr.responseText);
|
||||
resolve(data);
|
||||
} catch(e) {
|
||||
let msg = 'Server error';
|
||||
if (xhr.status === 413) msg = 'File too large';
|
||||
try {
|
||||
const errData = JSON.parse(xhr.responseText);
|
||||
if (errData.msg) msg = errData.msg;
|
||||
} catch(e2) {}
|
||||
reject(new Error(msg));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Connection error'));
|
||||
xhr.open('POST', '/api/v2/upload');
|
||||
const csrf = window.f0ckSession?.csrf_token || document.querySelector('input[name="csrf_token"]')?.value || '';
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrf);
|
||||
xhr.send(formData);
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
if (dragModal) dragModal.classList.remove('show');
|
||||
const dropModal = document.getElementById('upload-drag-modal');
|
||||
if (dropModal) dropModal.classList.remove('show');
|
||||
form._f0ckUploader.reset();
|
||||
|
||||
const successMsg = res.msg || `Album (${res.album_count || selectedFiles.length} subf0cks) uploaded successfully!`;
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(successMsg, 3000, 'success');
|
||||
} else if (!dragModal && statusDiv) {
|
||||
statusDiv.innerHTML = '✓ ' + successMsg;
|
||||
statusDiv.className = 'upload-status success';
|
||||
}
|
||||
|
||||
if (res.itemid && window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: res.itemid,
|
||||
dest: res.dest,
|
||||
mime: res.mime,
|
||||
username: res.username || window.f0ckSession?.user || '',
|
||||
display_name: res.display_name || window.f0ckSession?.display_name || null,
|
||||
tag_id: res.tag_id ?? 0,
|
||||
is_oc: !!res.is_oc,
|
||||
slug: res.slug,
|
||||
visibility: res.visibility || 0,
|
||||
is_album: !!res.is_album,
|
||||
album_count: res.album_count || 0
|
||||
});
|
||||
}
|
||||
|
||||
const shouldRedirectToItem = redirectCheckbox
|
||||
? redirectCheckbox.checked
|
||||
: (localStorage.getItem('upload_redirect_to_item') !== 'false');
|
||||
|
||||
if (shouldRedirectToItem) {
|
||||
const targetUrl = res.slug ? `/${res.slug}` : (res.itemid ? `/${res.itemid}` : '/');
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(targetUrl, false, { bypassCache: true, skipCache: true, skipInherit: true });
|
||||
} else {
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
}
|
||||
// else: stay on current page
|
||||
return;
|
||||
} else {
|
||||
const errMsg = res.msg || 'Upload failed';
|
||||
const err = new Error(errMsg);
|
||||
if (res.repost) err.repost = res.repost;
|
||||
throw err;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ALBUM UPLOAD ERROR]', err);
|
||||
if (err.repost) {
|
||||
statusDiv.innerHTML = '✕ ' + window.escapeHtmlUpload(err.message) + ` (<a href="/${err.repost}" class="repost-link">view existing</a>)`;
|
||||
} else {
|
||||
statusDiv.textContent = '✕ ' + err.message;
|
||||
}
|
||||
statusDiv.className = 'upload-status error';
|
||||
if (progressContainer) progressContainer.style.display = 'none';
|
||||
restoreBtn();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setBtnLoading(isShitpost ? `Uploading 1/${selectedFiles.length}...` : 'Uploading...');
|
||||
if (progressContainer) progressContainer.style.display = 'flex';
|
||||
if (statusDiv) {
|
||||
@@ -3199,7 +2670,6 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
let successCount = 0;
|
||||
let lastData = null;
|
||||
const uploadedResults = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
const item = selectedFiles[i];
|
||||
@@ -3295,7 +2765,6 @@ window.initUploadForm = (selector) => {
|
||||
if (res.success) {
|
||||
successCount++;
|
||||
lastData = res;
|
||||
uploadedResults.push(res);
|
||||
if (res.pending) {
|
||||
// Background URL download — show tracker widget entry
|
||||
if (window.urlUploadTracker) {
|
||||
@@ -3360,81 +2829,31 @@ window.initUploadForm = (selector) => {
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
const totalFilesToUpload = selectedFiles.length;
|
||||
const isBgUrlUpload = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (dragModal) dragModal.classList.remove('show');
|
||||
const dropModal = document.getElementById('upload-drag-modal');
|
||||
if (dropModal) dropModal.classList.remove('show');
|
||||
if (window.resetGlobalScrollState) window.resetGlobalScrollState();
|
||||
if (window.hideAllModals) window.hideAllModals();
|
||||
form._f0ckUploader.reset();
|
||||
|
||||
if (isBgUrlUpload) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage((window.f0ckI18n && window.f0ckI18n.url_upload_started) || 'Background upload started', 3000, 'info');
|
||||
if (isShitpost) {
|
||||
if (lastData?.manual_approval && typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
|
||||
}
|
||||
} else {
|
||||
if (lastData?.manual_approval) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
|
||||
}
|
||||
} else {
|
||||
const successMsg = successCount > 1
|
||||
? `${successCount} items uploaded successfully!`
|
||||
: (lastData?.msg || 'Upload successful');
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(successMsg, 3000, 'success');
|
||||
} else if (!dragModal && statusDiv) {
|
||||
statusDiv.innerHTML = '✓ ' + successMsg;
|
||||
statusDiv.className = 'upload-status success';
|
||||
}
|
||||
} else if (!dragModal && statusDiv) {
|
||||
statusDiv.innerHTML = '✓ ' + (lastData?.msg || 'Upload successful');
|
||||
statusDiv.className = 'upload-status success';
|
||||
}
|
||||
}
|
||||
|
||||
const shouldRedirectToItem = redirectCheckbox
|
||||
? redirectCheckbox.checked
|
||||
: (localStorage.getItem('upload_redirect_to_item') !== 'false');
|
||||
|
||||
// Prepend items to live grid seamlessly
|
||||
if (window.NotificationSystemInstance && typeof window.NotificationSystemInstance.handleNewItem === 'function') {
|
||||
for (const up of uploadedResults) {
|
||||
if (!up.manual_approval && !up.pending && up.itemid) {
|
||||
window.NotificationSystemInstance.handleNewItem({
|
||||
id: up.itemid,
|
||||
dest: up.dest,
|
||||
mime: up.mime,
|
||||
username: up.username || window.f0ckSession?.user || '',
|
||||
display_name: up.display_name || window.f0ckSession?.display_name || null,
|
||||
tag_id: up.tag_id ?? 0,
|
||||
is_oc: !!up.is_oc,
|
||||
slug: up.slug,
|
||||
visibility: up.visibility || 0,
|
||||
is_album: !!up.is_album,
|
||||
album_count: up.album_count || 0
|
||||
});
|
||||
}
|
||||
}
|
||||
// URL background uploads do not redirect; standard file uploads redirect back to main page ('/')
|
||||
const isBgUrlUpload = lastData?.pending && selectedFiles.every(i => i.type === 'url');
|
||||
if (isBgUrlUpload) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage((window.f0ckI18n && window.f0ckI18n.url_upload_started) || 'Background upload started', 3000, 'info');
|
||||
}
|
||||
|
||||
if (shouldRedirectToItem && !lastData?.manual_approval) {
|
||||
const isMultiNonAlbumShitpost = isShitpost && !isAlbum && (uploadedResults.length > 1 || successCount > 1 || totalFilesToUpload > 1);
|
||||
let targetUrl;
|
||||
if (isMultiNonAlbumShitpost) {
|
||||
targetUrl = '/';
|
||||
} else {
|
||||
const targetItem = lastData || (uploadedResults && uploadedResults[0]);
|
||||
targetUrl = targetItem ? (targetItem.slug ? `/${targetItem.slug}` : (targetItem.itemid ? `/${targetItem.itemid}` : '/')) : '/';
|
||||
}
|
||||
if (typeof window.loadPageAjax === 'function') {
|
||||
window.loadPageAjax(targetUrl, false, { bypassCache: true, skipCache: true, skipInherit: true });
|
||||
} else {
|
||||
window.location.href = targetUrl;
|
||||
}
|
||||
}
|
||||
if (lastData?.manual_approval) {
|
||||
const pendingItem = lastData || (uploadedResults && uploadedResults[0]);
|
||||
window.f0ckGoToPending(pendingItem && pendingItem.itemid);
|
||||
}
|
||||
// else (redirect disabled): stay on current page
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
restoreBtn();
|
||||
@@ -3453,21 +2872,8 @@ window.initUploadForm = (selector) => {
|
||||
reset: () => {
|
||||
isUploading = false;
|
||||
form.reset();
|
||||
if (redirectCheckbox) {
|
||||
const savedRedirect = localStorage.getItem('upload_redirect_to_item');
|
||||
if (savedRedirect !== null) {
|
||||
redirectCheckbox.checked = (savedRedirect === 'true' || savedRedirect === '1');
|
||||
}
|
||||
}
|
||||
tags = [];
|
||||
selectedFiles = [];
|
||||
if (albumChoiceContainer) albumChoiceContainer.style.display = 'none';
|
||||
albumChoiceMode = 'album';
|
||||
if (albumChoiceContainer) {
|
||||
albumChoiceContainer.querySelectorAll('.album-choice-btn').forEach(b => {
|
||||
b.classList.toggle('active', b.getAttribute('data-choice') === 'album');
|
||||
});
|
||||
}
|
||||
if (tagsList) tagsList.innerHTML = '';
|
||||
if (tagsHidden) tagsHidden.value = '';
|
||||
if (fileInput) fileInput.style.display = 'inline-block';
|
||||
@@ -3496,7 +2902,6 @@ window.initUploadForm = (selector) => {
|
||||
|
||||
// Reset mode to 'file'
|
||||
activeMode = 'file';
|
||||
form.classList.remove('album-mode-active');
|
||||
if (modeTabs.length > 0) {
|
||||
modeTabs.forEach(t => {
|
||||
if (t.dataset.mode === 'file') t.classList.add('active');
|
||||
|
||||
+76
-400
@@ -6,19 +6,13 @@
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const currentSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
|
||||
? window.albumGallery.getCurrentSubf0ck()
|
||||
: null;
|
||||
const subf0ck_id = currentSub ? (currentSub.slug || currentSub.id) : (tagsContainer?.dataset?.subf0ckSlug || tagsContainer?.dataset?.subf0ckId || null);
|
||||
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
subf0ck_id,
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
};
|
||||
@@ -52,16 +46,6 @@
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
if (!tagsContainer) return;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
|
||||
|
||||
const canManage = !!(
|
||||
document.querySelector('#tags[data-can-manage="true"]') ||
|
||||
(window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) ||
|
||||
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
|
||||
);
|
||||
|
||||
// Anonymous uploaders may change the rating of their own item without full manage rights
|
||||
const canRate = canManage || !!document.querySelector('#tags[data-can-rate="true"]');
|
||||
|
||||
// Only remove existing dynamically generated tags
|
||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||
@@ -71,66 +55,24 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
|
||||
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
|
||||
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
|
||||
|
||||
const tagsCopy = [...cleanTags];
|
||||
tagsCopy.reverse().forEach(tag => {
|
||||
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized);
|
||||
let contentEl;
|
||||
if (isRating) {
|
||||
contentEl = document.createElement("span");
|
||||
contentEl.className = "rating-label";
|
||||
contentEl.textContent = tag.tag;
|
||||
} else {
|
||||
contentEl = document.createElement("a");
|
||||
contentEl.href = "/tag/" + tag.normalized;
|
||||
contentEl.style = "color: inherit !important";
|
||||
contentEl.textContent = tag.tag;
|
||||
}
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.textContent = tag.tag;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge");
|
||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||
span.classList.add('new-tag-glow');
|
||||
}
|
||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
if (isRating) {
|
||||
span.classList.add('rating-tag', `is-${tag.normalized}`);
|
||||
span.dataset.rating = tag.normalized;
|
||||
if (activePostId) span.dataset.itemId = activePostId;
|
||||
if (canRate) {
|
||||
span.classList.add('can-cycle');
|
||||
}
|
||||
} else {
|
||||
span.classList.add('tag-badge');
|
||||
span.setAttribute('data-tag-id', tag.id || '');
|
||||
span.setAttribute('data-tag', tag.tag);
|
||||
span.setAttribute('data-tag-normalized', tag.normalized);
|
||||
if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) {
|
||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||
}
|
||||
const isExcl = !!tag.is_excluded;
|
||||
if (isExcl) span.classList.add('tag-is-excluded');
|
||||
}
|
||||
span.insertAdjacentElement("beforeend", a);
|
||||
|
||||
span.insertAdjacentElement("beforeend", contentEl);
|
||||
|
||||
if (!isRating && tag.can_exclude) {
|
||||
const excludeBtn = document.createElement('button');
|
||||
excludeBtn.type = 'button';
|
||||
excludeBtn.className = 'tag-exclude-btn';
|
||||
excludeBtn.setAttribute('title', tag.is_excluded ? 'Excluded (click to unexclude)' : 'Exclude tag');
|
||||
excludeBtn.setAttribute('aria-label', 'Exclude tag');
|
||||
excludeBtn.innerHTML = `<i class="fa-solid ${tag.is_excluded ? 'fa-circle-check' : 'fa-ban'}"></i>`;
|
||||
span.insertAdjacentElement('beforeend', excludeBtn);
|
||||
}
|
||||
|
||||
if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator) && !isRating) {
|
||||
if (window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) {
|
||||
const space = document.createTextNode('\u00A0'); //
|
||||
span.appendChild(space);
|
||||
|
||||
@@ -141,42 +83,9 @@
|
||||
span.insertAdjacentElement("beforeend", del);
|
||||
}
|
||||
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
|
||||
const hasRating = !!lastRatingTag;
|
||||
if (!hasRating) {
|
||||
const untaggedSpan = document.createElement("span");
|
||||
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canRate ? ' can-cycle' : ''}`;
|
||||
untaggedSpan.dataset.rating = 'untagged';
|
||||
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "rating-label";
|
||||
lbl.textContent = "unrated";
|
||||
untaggedSpan.appendChild(lbl);
|
||||
inner.insertAdjacentElement("afterbegin", untaggedSpan);
|
||||
}
|
||||
|
||||
// Safeguard: remove any extra rating tags in DOM
|
||||
const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]');
|
||||
if (allRatingEls.length > 1) {
|
||||
for (let i = 1; i < allRatingEls.length; i++) {
|
||||
allRatingEls[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
|
||||
if (activePostId) {
|
||||
const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
|
||||
document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
|
||||
th.setAttribute('data-mode', modeAttr);
|
||||
});
|
||||
if (document.body.classList.contains('onara-modal-open')) {
|
||||
const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
|
||||
if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle show more/less toggle visibility and count
|
||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||
@@ -209,7 +118,7 @@
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, subf0ck_id, tags } = ctx;
|
||||
const { postid, tags } = ctx;
|
||||
const anchor = document.querySelector("a#a_addtag");
|
||||
if (!anchor) return;
|
||||
|
||||
@@ -218,328 +127,95 @@
|
||||
existingTags: tags,
|
||||
anchorEl: anchor,
|
||||
onSubmit: async (tag) => {
|
||||
const payload = { tagname: tag };
|
||||
if (subf0ck_id) payload.subf0ck_id = subf0ck_id;
|
||||
const res = await post("/api/v2/tags/" + postid, payload);
|
||||
if (res.success) {
|
||||
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
|
||||
const cur = window.albumGallery.getCurrentSubf0ck();
|
||||
if (cur && res.tags) cur.tags = res.tags;
|
||||
}
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
|
||||
if (res.success && window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
renderTags: (newTags, hl) => {
|
||||
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
|
||||
const cur = window.albumGallery.getCurrentSubf0ck();
|
||||
if (cur) cur.tags = newTags;
|
||||
}
|
||||
renderTags(newTags, hl);
|
||||
}
|
||||
renderTags
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEvent = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ratingEl = document.querySelector('.rating-tag.can-cycle, button#a_toggle');
|
||||
if (window.cycleRating) {
|
||||
window.cycleRating(ratingEl);
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const res = await (await fetch('/api/v2/tags/' + encodeURIComponent(postid) + '/toggle', {
|
||||
method: 'PUT',
|
||||
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||
})).json();
|
||||
|
||||
renderTags(res.tags);
|
||||
const isNsfw = res.tags.some(t => t.id == 2);
|
||||
const isUntagged = res.tags.length === 0;
|
||||
const toggleBtn = document.querySelector('button#a_toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle('is-nsfw', isNsfw && !isUntagged);
|
||||
toggleBtn.classList.toggle('is-sfw', !isNsfw && !isUntagged);
|
||||
toggleBtn.classList.toggle('is-untagged', isUntagged);
|
||||
const labels = { true: 'NSFW', false: 'SFW' };
|
||||
toggleBtn.textContent = isUntagged ? '?' : (isNsfw ? 'NSFW' : 'SFW');
|
||||
}
|
||||
};
|
||||
|
||||
let favSeq = 0;
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
// e is the click event or undefined
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
if (!favoBtns.length) return;
|
||||
const firstFavoBtn = favoBtns[0];
|
||||
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
|
||||
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
|
||||
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
|
||||
let localId = firstFavoBtn?.dataset?.localId;
|
||||
|
||||
const ctx = getContext();
|
||||
const postid = localId ? Number(localId) : ctx?.postid;
|
||||
if (!postid && !chanUrl) return;
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const wasAlreadyFav = favoBtns[0].classList.contains('fa-solid') && !favoBtns[0].classList.contains('fa-spinner');
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
// Read state BEFORE the API call so we know which direction to toggle
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const setFavoUi = (isFav) => {
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fa-spinner', 'fa-spin');
|
||||
btn.classList.add('iconset', 'fa-heart');
|
||||
btn.classList.toggle('fa-solid', isFav);
|
||||
btn.classList.toggle('fa-regular', !isFav);
|
||||
btn.title = isFav ? (window.f0ckI18n?.fav_remove || 'Remove from favorites') : (window.f0ckI18n?.fav_add || 'Favorite');
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Instantly apply client UI
|
||||
setFavoUi(isNowFav);
|
||||
|
||||
// Micro-animation for tactile pop
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fav-pop');
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add('fav-pop');
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
|
||||
// Instant flash message & vibration feedback
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
|
||||
if (postid && window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
|
||||
// Optimistically update #favs badge list
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
const prevFavsHtml = favcontainer ? favcontainer.innerHTML : '';
|
||||
const prevFavsHidden = favcontainer ? favcontainer.hidden : true;
|
||||
|
||||
if (favcontainer) {
|
||||
const currentUser = (window.f0ckSession?.user || '').toLowerCase();
|
||||
const isAnon = !!(window.f0ckSession?.is_anon || window.f0ckSession?.user === 'anonymous');
|
||||
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
|
||||
|
||||
const findSelfBadge = () => {
|
||||
const selfBadges = favcontainer.querySelectorAll('[data-self="true"]');
|
||||
if (selfBadges.length) return selfBadges[0];
|
||||
if (currentUser && currentUser !== 'anonymous') {
|
||||
const links = favcontainer.querySelectorAll('a[href]');
|
||||
for (const a of links) {
|
||||
const path = a.getAttribute('href') || '';
|
||||
if (path.toLowerCase().endsWith('/user/' + currentUser)) return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!isNowFav) {
|
||||
const selfBadge = findSelfBadge();
|
||||
if (selfBadge) selfBadge.remove();
|
||||
if (favcontainer.children.length === 0) {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
} else {
|
||||
let selfBadge = findSelfBadge();
|
||||
if (!selfBadge) {
|
||||
selfBadge = document.createElement('a');
|
||||
selfBadge.dataset.self = 'true';
|
||||
selfBadge.setAttribute('flow', 'up');
|
||||
if (isAnonymized || isAnon) {
|
||||
selfBadge.className = 'ghost-fav';
|
||||
selfBadge.setAttribute('tooltip', 'anonymous');
|
||||
selfBadge.style.cursor = 'default';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/a/default.png';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
selfBadge.appendChild(img);
|
||||
} else {
|
||||
selfBadge.href = `/user/${currentUser}`;
|
||||
selfBadge.setAttribute('tooltip', window.f0ckSession?.display_name || window.f0ckSession?.user || 'anonymous');
|
||||
const img = document.createElement('img');
|
||||
const avatarFile = window.f0ckSession?.avatar_file;
|
||||
const avatar = window.f0ckSession?.avatar;
|
||||
img.src = avatarFile ? `/a/${avatarFile}` : (avatar ? `/t/${avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (window.f0ckSession?.username_color) img.style.borderColor = window.f0ckSession.username_color;
|
||||
selfBadge.appendChild(img);
|
||||
}
|
||||
favcontainer.appendChild(selfBadge);
|
||||
}
|
||||
favcontainer.hidden = false;
|
||||
if (res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
}
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
const mySeq = ++favSeq;
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
|
||||
// 2. Run server operation in background — can take as long as it needs
|
||||
(async () => {
|
||||
try {
|
||||
// If viewing un-rehosted 4chan post, auto-rehost first
|
||||
if (isChan && !localId && chanUrl) {
|
||||
if (mySeq !== favSeq) return;
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const rehostResp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: chanUrl,
|
||||
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
|
||||
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
|
||||
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
|
||||
})
|
||||
});
|
||||
const rehostData = await rehostResp.json();
|
||||
if (rehostData.success && rehostData.item_id) {
|
||||
localId = rehostData.item_id;
|
||||
favoBtns.forEach(btn => {
|
||||
btn.dataset.itemId = localId;
|
||||
btn.dataset.localId = localId;
|
||||
});
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
if (commentsEl) commentsEl.dataset.itemId = localId;
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
if (infoEl) infoEl.dataset.itemId = localId;
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const timeEl = document.querySelector('time.timeago');
|
||||
if (timeEl) {
|
||||
const nowIso = new Date().toISOString();
|
||||
timeEl.dataset.iso = nowIso;
|
||||
const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
|
||||
timeEl.setAttribute('tooltip', fullDate);
|
||||
timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
if (chanRehostBtn) {
|
||||
chanRehostBtn.classList.add('rehosted');
|
||||
chanRehostBtn.outerHTML = `
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
throw new Error(rehostData.msg || 'Rehost failed');
|
||||
}
|
||||
}
|
||||
|
||||
const effectivePostId = localId ? Number(localId) : (postid || getContext()?.postid);
|
||||
if (!effectivePostId) {
|
||||
throw new Error('Missing post ID');
|
||||
}
|
||||
|
||||
if (mySeq !== favSeq) return;
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: effectivePostId,
|
||||
chan_url: chanUrl,
|
||||
action: isNowFav ? 'add' : 'delete',
|
||||
favorited: isNowFav
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
});
|
||||
|
||||
if (mySeq !== favSeq) return;
|
||||
|
||||
if (res && res.success) {
|
||||
if (window.invalidateItemCache) {
|
||||
window.invalidateItemCache(effectivePostId);
|
||||
}
|
||||
const finalIsFav = (res.favorited !== undefined) ? !!res.favorited : isNowFav;
|
||||
setFavoUi(finalIsFav);
|
||||
|
||||
// Render authoritative favs list from server
|
||||
const curFavContainer = document.querySelector('#favs');
|
||||
if (curFavContainer && Array.isArray(res.favs)) {
|
||||
curFavContainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
|
||||
const fragment = document.createDocumentFragment();
|
||||
res.favs.forEach(f => {
|
||||
const isSelf = window.f0ckSession && (
|
||||
(window.f0ckSession.id && f.user_id && Number(window.f0ckSession.id) === Number(f.user_id)) ||
|
||||
(window.f0ckSession.user && f.user && window.f0ckSession.user.toLowerCase() === f.user.toLowerCase()) ||
|
||||
(window.f0ckSession.login && f.login && window.f0ckSession.login.toLowerCase() === f.login.toLowerCase())
|
||||
);
|
||||
const isFavAnon = f.is_anon || f.user === 'anonymous' || (typeof f.user === 'string' && f.user.startsWith('anon_'));
|
||||
|
||||
if (f.hide_fav_badge && !isSelf) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'ghost-fav';
|
||||
a.setAttribute('tooltip', '?');
|
||||
a.setAttribute('flow', 'up');
|
||||
a.style.cursor = 'default';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = '/s/img/ghost_fav.svg';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
|
||||
a.appendChild(img);
|
||||
fragment.appendChild(a);
|
||||
} else if (isAnonymized || isFavAnon) {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'ghost-fav';
|
||||
if (isSelf) a.dataset.self = 'true';
|
||||
a.setAttribute('tooltip', 'anonymous');
|
||||
a.setAttribute('flow', 'up');
|
||||
a.style.cursor = 'default';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = '/a/default.png';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
|
||||
a.appendChild(img);
|
||||
fragment.appendChild(a);
|
||||
} else {
|
||||
const a = document.createElement('a');
|
||||
if (isSelf) a.dataset.self = 'true';
|
||||
a.href = `/user/${(f.user || '').toLowerCase()}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user || 'anonymous');
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
a.appendChild(img);
|
||||
fragment.appendChild(a);
|
||||
}
|
||||
});
|
||||
curFavContainer.appendChild(fragment);
|
||||
curFavContainer.hidden = false;
|
||||
} else {
|
||||
curFavContainer.hidden = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (mySeq !== favSeq) return;
|
||||
setFavoUi(wasAlreadyFav);
|
||||
const curFavContainer = document.querySelector('#favs');
|
||||
if (curFavContainer) {
|
||||
curFavContainer.innerHTML = prevFavsHtml;
|
||||
curFavContainer.hidden = prevFavsHidden;
|
||||
}
|
||||
const errMsg = (res && (res.msg || res.error)) || 'Anonymous favoriting is disabled.';
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(errMsg, 3000, 'error');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[CHAN-FAV] Error during background favorite sync:', err);
|
||||
if (mySeq !== favSeq) return;
|
||||
setFavoUi(wasAlreadyFav);
|
||||
const curFavContainer = document.querySelector('#favs');
|
||||
if (curFavContainer) {
|
||||
curFavContainer.innerHTML = prevFavsHtml;
|
||||
curFavContainer.hidden = prevFavsHidden;
|
||||
}
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Failed to update favorite.', 3000, 'error');
|
||||
}
|
||||
favcontainer.hidden = false;
|
||||
} else {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
})();
|
||||
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
}
|
||||
else {
|
||||
// lul
|
||||
}
|
||||
};
|
||||
|
||||
// Event Delegation
|
||||
|
||||
@@ -419,7 +419,7 @@ if (!window.UserCommentSystem) {
|
||||
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
|
||||
return `<div class="comment" id="c${c.id}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions"><button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button></div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
return `<div class="comment" id="c${c.id}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
}
|
||||
|
||||
startLiveTimestamps() {
|
||||
|
||||
+24
-133
@@ -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
@@ -1,4 +1,4 @@
|
||||
const CACHE_NAME = 'f0ckm-pwa';
|
||||
const CACHE_NAME = 'w0bm-pwa-v9';
|
||||
const ASSETS_TO_CACHE = [
|
||||
'/',
|
||||
'/s/css/f0ckm.css',
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"lastId": 48551,
|
||||
"timestamp": "2026-09-13T02:54:17.890Z"
|
||||
}
|
||||
@@ -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());
|
||||
@@ -1,92 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import YAML from 'yaml';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const rootDir = path.resolve(__dirname, '..');
|
||||
|
||||
const DEFAULT_CONFIG_YAML = path.join(rootDir, 'config.yaml');
|
||||
const DEFAULT_CONFIG_JSON = path.join(rootDir, 'config.json');
|
||||
const EXAMPLE_CONFIG_YAML = path.join(rootDir, 'config_example.yaml');
|
||||
const EXAMPLE_CONFIG_JSON = path.join(rootDir, 'config_example.json');
|
||||
|
||||
function convertYamlToJson(yamlPath, jsonPath) {
|
||||
if (!fs.existsSync(yamlPath)) {
|
||||
console.error(`[CONFIG-GEN] Error: Source YAML file not found at ${yamlPath}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const yamlContent = fs.readFileSync(yamlPath, 'utf8');
|
||||
const parsed = YAML.parse(yamlContent);
|
||||
if (parsed === null || typeof parsed !== 'object') {
|
||||
throw new Error('Parsed YAML is not an object');
|
||||
}
|
||||
const jsonContent = JSON.stringify(parsed, null, 2) + '\n';
|
||||
fs.writeFileSync(jsonPath, jsonContent, 'utf8');
|
||||
console.log(`[CONFIG-GEN] Generated ${path.relative(rootDir, jsonPath)} from ${path.relative(rootDir, yamlPath)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[CONFIG-GEN] Failed to generate JSON from ${path.relative(rootDir, yamlPath)}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function convertJsonToYaml(jsonPath, yamlPath) {
|
||||
if (!fs.existsSync(jsonPath)) {
|
||||
console.error(`[CONFIG-GEN] Error: Source JSON file not found at ${jsonPath}`);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const jsonContent = fs.readFileSync(jsonPath, 'utf8');
|
||||
const parsed = JSON.parse(jsonContent);
|
||||
const doc = new YAML.Document(parsed);
|
||||
const yamlContent = String(doc);
|
||||
fs.writeFileSync(yamlPath, yamlContent, 'utf8');
|
||||
console.log(`[CONFIG-GEN] Generated ${path.relative(rootDir, yamlPath)} from ${path.relative(rootDir, jsonPath)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[CONFIG-GEN] Failed to generate YAML from ${path.relative(rootDir, jsonPath)}: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const isWatch = args.includes('--watch') || args.includes('-w');
|
||||
const isToYaml = args.includes('--to-yaml');
|
||||
const includeAll = args.includes('--all') || args.includes('--example');
|
||||
|
||||
if (isToYaml) {
|
||||
convertJsonToYaml(DEFAULT_CONFIG_JSON, DEFAULT_CONFIG_YAML);
|
||||
if (includeAll || fs.existsSync(EXAMPLE_CONFIG_JSON)) {
|
||||
convertJsonToYaml(EXAMPLE_CONFIG_JSON, EXAMPLE_CONFIG_YAML);
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Initial generation
|
||||
let success = convertYamlToJson(DEFAULT_CONFIG_YAML, DEFAULT_CONFIG_JSON);
|
||||
if (includeAll && fs.existsSync(EXAMPLE_CONFIG_YAML)) {
|
||||
convertYamlToJson(EXAMPLE_CONFIG_YAML, EXAMPLE_CONFIG_JSON);
|
||||
}
|
||||
|
||||
if (!success && !isWatch) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (isWatch) {
|
||||
console.log(`[CONFIG-GEN] Watching ${path.relative(rootDir, DEFAULT_CONFIG_YAML)} for changes... (Press Ctrl+C to stop)`);
|
||||
let debounceTimer = null;
|
||||
fs.watch(DEFAULT_CONFIG_YAML, (eventType) => {
|
||||
if (eventType === 'change' || eventType === 'rename') {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
console.log(`[CONFIG-GEN] File change detected, re-generating config.json...`);
|
||||
convertYamlToJson(DEFAULT_CONFIG_YAML, DEFAULT_CONFIG_JSON);
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
+8
-72
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -42,10 +42,6 @@ export const handleBannerUpload = async (req, res) => {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
if (user[0].user && user[0].user.startsWith('anon_')) {
|
||||
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
console.log('[BANNER HANDLER] Authorized:', req.session.user);
|
||||
|
||||
@@ -254,10 +250,6 @@ export const handleBannerDelete = async (req, res) => {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
if (user[0].user && user[0].user.startsWith('anon_')) {
|
||||
return sendJson(res, { success: false, msg: 'Action requires a registered account' }, 403);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
|
||||
// CSRF validation
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/ /g, ' ').replace(/&/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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* chan_http.mjs — curl invocation for all outgoing 4chan requests (API JSON, media, rehost downloads).
|
||||
*
|
||||
* Every call gets a randomized browser identity:
|
||||
* - If curl-impersonate is installed (wrapper binaries like curl_chrome116, curl_ff117, ...), a random
|
||||
* profile is used. These reproduce a real browser's TLS + HTTP/2 handshake and send matching headers,
|
||||
* so we must NOT override the User-Agent (a mismatch would defeat the point).
|
||||
* - Otherwise plain curl is used with a random, current User-Agent and a matching Accept-Language.
|
||||
*
|
||||
* config: main.curl_impersonate
|
||||
* (unset) / true → auto-detect wrappers on PATH
|
||||
* false → never use curl-impersonate
|
||||
* "<dir>" → look for wrappers in that directory (in addition to PATH)
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import cfg from './config.mjs';
|
||||
|
||||
const pick = (arr) => arr[Math.floor(Math.random() * arr.length)];
|
||||
|
||||
// Browser majors derived from the date so the pool never goes stale.
|
||||
// Chrome 100 shipped 2022-03-29, Firefox 100 on 2022-05-03; both release every 4 weeks.
|
||||
const majorSince = (base, isoDate) => base + Math.floor((Date.now() - Date.parse(isoDate)) / (28 * 86400000));
|
||||
|
||||
const randomUserAgent = () => {
|
||||
const chrome = majorSince(100, '2022-03-29') - Math.floor(Math.random() * 3); // current or up to 2 behind
|
||||
const firefox = majorSince(100, '2022-05-03') - Math.floor(Math.random() * 3);
|
||||
const templates = [
|
||||
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||
`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36`,
|
||||
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome}.0.0.0 Safari/537.36 Edg/${chrome}.0.0.0`,
|
||||
`Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
|
||||
`Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`,
|
||||
`Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}.0) Gecko/20100101 Firefox/${firefox}.0`
|
||||
];
|
||||
return pick(templates);
|
||||
};
|
||||
|
||||
const ACCEPT_LANGUAGES = [
|
||||
'en-US,en;q=0.9',
|
||||
'en-US,en;q=0.8',
|
||||
'en-GB,en;q=0.9,en-US;q=0.8',
|
||||
'en-US,en;q=0.9,de;q=0.8',
|
||||
'de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7'
|
||||
];
|
||||
|
||||
// ── curl-impersonate detection (cached after first lookup) ──────────────────
|
||||
// Wrapper names look like curl_chrome146, curl_firefox147, curl_edge101, curl_safari260, curl_chrome131_android.
|
||||
const IMPERSONATE_RE = /^curl_(chrome|edge|firefox|ff|safari)(\d+)([a-z0-9_]*)$/i;
|
||||
// Only the newest desktop profiles per browser: an old fingerprint (chrome99) stands out as much as plain curl.
|
||||
const PROFILES_PER_BROWSER = 3;
|
||||
let _impersonateBins = null;
|
||||
|
||||
const findImpersonateBins = () => {
|
||||
if (_impersonateBins) return _impersonateBins;
|
||||
const setting = cfg.main?.curl_impersonate;
|
||||
if (setting === false) return (_impersonateBins = []);
|
||||
|
||||
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
||||
if (typeof setting === 'string' && setting.trim()) dirs.unshift(setting.trim());
|
||||
|
||||
const byBrowser = new Map(); // family → [{ version, full }]
|
||||
const seen = new Set();
|
||||
for (const dir of dirs) {
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(dir); } catch { continue; }
|
||||
for (const name of entries) {
|
||||
const m = name.match(IMPERSONATE_RE);
|
||||
if (!m || m[3] || seen.has(name)) continue; // m[3] = suffix like _android/_ios/a → skip non-desktop variants
|
||||
const full = path.join(dir, name);
|
||||
try { fs.accessSync(full, fs.constants.X_OK); } catch { continue; }
|
||||
seen.add(name);
|
||||
const family = m[1].toLowerCase() === 'ff' ? 'firefox' : m[1].toLowerCase();
|
||||
if (!byBrowser.has(family)) byBrowser.set(family, []);
|
||||
byBrowser.get(family).push({ version: parseInt(m[2], 10), full });
|
||||
}
|
||||
}
|
||||
|
||||
// Edge shares Chrome's version numbers; drop Edge profiles that lag far behind the newest Chrome
|
||||
// (releases have shipped edge99/edge101 next to chrome146, which would stand out as ancient).
|
||||
const newestChrome = Math.max(0, ...(byBrowser.get('chrome') || []).map(p => p.version));
|
||||
if (newestChrome && byBrowser.has('edge')) {
|
||||
byBrowser.set('edge', byBrowser.get('edge').filter(p => p.version >= newestChrome - 10));
|
||||
}
|
||||
|
||||
_impersonateBins = [];
|
||||
for (const list of byBrowser.values()) {
|
||||
list.sort((a, b) => b.version - a.version);
|
||||
_impersonateBins.push(...list.slice(0, PROFILES_PER_BROWSER).map(p => p.full));
|
||||
}
|
||||
console.log(_impersonateBins.length
|
||||
? `[BOOT] 4chan requests: curl-impersonate enabled (${_impersonateBins.map(b => path.basename(b)).join(', ')})`
|
||||
: '[BOOT] 4chan requests: curl-impersonate not found, using curl with randomized User-Agent');
|
||||
return _impersonateBins;
|
||||
};
|
||||
|
||||
// The static curl-impersonate build looks for CAs at the Debian/Alpine path only. On other distros
|
||||
// (openSUSE, Fedora, macOS) point it at the local bundle explicitly.
|
||||
const CA_DEFAULT = '/etc/ssl/certs/ca-certificates.crt';
|
||||
const CA_FALLBACKS = ['/etc/ssl/ca-bundle.pem', '/etc/pki/tls/certs/ca-bundle.crt', '/etc/ssl/cert.pem'];
|
||||
let _caArgs = null;
|
||||
const caArgs = () => {
|
||||
if (_caArgs) return _caArgs;
|
||||
if (fs.existsSync(CA_DEFAULT)) return (_caArgs = []);
|
||||
const found = CA_FALLBACKS.find(f => fs.existsSync(f));
|
||||
return (_caArgs = found ? ['--cacert', found] : []);
|
||||
};
|
||||
|
||||
const socksArgs = () => {
|
||||
const socks = cfg.main?.socks;
|
||||
if (!socks || socks === 'undefined') return [];
|
||||
const host = socks.includes('://') ? socks.split('://')[1] : socks;
|
||||
return ['--socks5-hostname', host];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a curl command for a 4chan URL with a randomized browser identity.
|
||||
* @param {string} url
|
||||
* @param {string[]} [extraArgs] additional curl flags (e.g. ['-o', file, '--max-time', '300'])
|
||||
* @returns {{ bin: string, args: string[] }}
|
||||
*/
|
||||
export const chanCurl = (url, extraArgs = []) => {
|
||||
const base = ['-s', '-f', '-L', ...extraArgs, ...socksArgs()];
|
||||
const impersonate = findImpersonateBins();
|
||||
if (impersonate.length) {
|
||||
// Wrapper supplies its own UA, header order and TLS/HTTP2 fingerprint
|
||||
return { bin: pick(impersonate), args: [...base, ...caArgs(), url] };
|
||||
}
|
||||
return {
|
||||
bin: 'curl',
|
||||
args: [...base, '-A', randomUserAgent(), '-H', `Accept-Language: ${pick(ACCEPT_LANGUAGES)}`, url]
|
||||
};
|
||||
};
|
||||
|
||||
export default { chanCurl };
|
||||
+7
-63
@@ -39,42 +39,20 @@ const storage = process.env.STORAGE_DIR;
|
||||
|
||||
const resolvePath = (defaultRel) => {
|
||||
const local = path.resolve(path.join(base, defaultRel));
|
||||
let target = local;
|
||||
if (storage) {
|
||||
const absStorage = path.resolve(storage);
|
||||
if (defaultRel.startsWith('public/')) {
|
||||
const sub = defaultRel.replace('public/', '');
|
||||
if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') {
|
||||
const storagePath = path.join(absStorage, sub.split('/').pop());
|
||||
if (fs.existsSync(storagePath)) target = path.resolve(storagePath);
|
||||
else target = local;
|
||||
} else {
|
||||
target = path.resolve(path.join(absStorage, sub));
|
||||
if (fs.existsSync(storagePath)) return path.resolve(storagePath);
|
||||
return local;
|
||||
}
|
||||
} else {
|
||||
target = path.resolve(path.join(absStorage, defaultRel));
|
||||
return path.resolve(path.join(absStorage, sub));
|
||||
}
|
||||
return path.resolve(path.join(absStorage, defaultRel));
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
return fs.realpathSync(target);
|
||||
}
|
||||
const parent = path.dirname(target);
|
||||
if (fs.existsSync(parent)) {
|
||||
return path.join(fs.realpathSync(parent), path.basename(target));
|
||||
}
|
||||
} catch (_) {}
|
||||
return target;
|
||||
};
|
||||
|
||||
const resolveImportPath = () => {
|
||||
const p = storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'));
|
||||
try {
|
||||
if (fs.existsSync(p)) return fs.realpathSync(p);
|
||||
const parent = path.dirname(p);
|
||||
if (fs.existsSync(parent)) return path.join(fs.realpathSync(parent), path.basename(p));
|
||||
} catch (_) {}
|
||||
return p;
|
||||
return local;
|
||||
};
|
||||
|
||||
config.paths = {
|
||||
@@ -83,51 +61,17 @@ config.paths = {
|
||||
c: resolvePath('public/c'),
|
||||
t: resolvePath('public/t'),
|
||||
ca: resolvePath('public/ca'),
|
||||
s: (() => {
|
||||
const sPath = path.join(base, 'public/s');
|
||||
try { if (fs.existsSync(sPath)) return fs.realpathSync(sPath); } catch (_) {}
|
||||
return sPath;
|
||||
})(),
|
||||
s: path.join(base, 'public/s'),
|
||||
emojis: resolvePath('public/s/emojis'),
|
||||
koepfe: resolvePath('public/s/koepfe'),
|
||||
fonts: resolvePath('public/s/fonts'),
|
||||
memes: resolvePath('public/memes'),
|
||||
e: resolvePath('public/e'),
|
||||
cu: resolvePath('public/cu'),
|
||||
pending: resolvePath('pending'),
|
||||
deleted: resolvePath('deleted'),
|
||||
logs: resolvePath('logs'),
|
||||
tmp: resolvePath('tmp'),
|
||||
import: resolveImportPath()
|
||||
import: storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'))
|
||||
};
|
||||
|
||||
const configFilePath = path.resolve(base, "config.json");
|
||||
let watchDebounceTimer = null;
|
||||
try {
|
||||
if (fs.existsSync(configFilePath)) {
|
||||
const watcher = fs.watch(configFilePath, (eventType) => {
|
||||
if (eventType === 'change' || eventType === 'rename') {
|
||||
if (watchDebounceTimer) clearTimeout(watchDebounceTimer);
|
||||
watchDebounceTimer = setTimeout(() => {
|
||||
try {
|
||||
const raw = fs.readFileSync(configFilePath, 'utf8');
|
||||
const updated = JSON.parse(raw);
|
||||
Object.assign(config, updated);
|
||||
if (config.sql) {
|
||||
config.sql.host = process.env.DB_HOST || process.env.POSTGRES_HOST || process.env.PGHOST || config.sql.host;
|
||||
config.sql.port = parseInt(process.env.DB_PORT || process.env.POSTGRES_PORT || process.env.PGPORT || config.sql.port, 10);
|
||||
config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.env.PGUSER || config.sql.user;
|
||||
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
|
||||
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
|
||||
}
|
||||
console.log('[CONFIG] config.json reloaded dynamically');
|
||||
} catch (_) {}
|
||||
}, 100);
|
||||
if (watchDebounceTimer.unref) watchDebounceTimer.unref();
|
||||
}
|
||||
});
|
||||
if (watcher.unref) watcher.unref();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import db from "./sql.mjs";
|
||||
|
||||
/**
|
||||
* IDs of items that are not live (pending approval, soft-deleted, purged).
|
||||
* The public media routes (/t/, /ca/) consult this so a leftover file on disk can never
|
||||
* expose a pending or removed item. Cached briefly because every thumbnail request hits it.
|
||||
*/
|
||||
|
||||
const TTL_MS = 5000;
|
||||
let cache = new Set();
|
||||
let loadedAt = 0;
|
||||
let inflight = null;
|
||||
|
||||
const refresh = () => {
|
||||
if (!inflight) {
|
||||
inflight = db`select id from items where active = false`
|
||||
.then(rows => { cache = new Set(rows.map(r => r.id)); loadedAt = Date.now(); })
|
||||
.catch(e => { console.warn('[HIDDEN ITEMS] refresh failed:', e.message); })
|
||||
.finally(() => { inflight = null; });
|
||||
}
|
||||
return inflight;
|
||||
};
|
||||
|
||||
export const isHiddenItem = async (id) => {
|
||||
if (Date.now() - loadedAt > TTL_MS) await refresh();
|
||||
return cache.has(+id);
|
||||
};
|
||||
|
||||
// Call after an item changes state (approve / withdraw) so the next lookup reloads
|
||||
export const invalidateHiddenItems = () => { loadedAt = 0; };
|
||||
+12
-175
@@ -4,7 +4,6 @@ import db from "./sql.mjs";
|
||||
|
||||
import cfg from "./config.mjs";
|
||||
import { createI18n } from "./i18n.mjs";
|
||||
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo, canUseChan } from "./settings.mjs";
|
||||
|
||||
|
||||
|
||||
@@ -49,18 +48,7 @@ export default new class {
|
||||
return slug;
|
||||
}
|
||||
|
||||
formatSize(size) {
|
||||
if (size === undefined || size === null || size === '') return '0 B';
|
||||
if (typeof size === 'string') {
|
||||
if (/^\d+(\.\d+)?\s*(B|kB|KB|MB|GB|TB)$/i.test(size.trim())) {
|
||||
return size.trim();
|
||||
}
|
||||
const num = Number(size);
|
||||
if (isNaN(num)) return '0 B';
|
||||
size = num;
|
||||
}
|
||||
if (isNaN(size) || size <= 0) return '0 B';
|
||||
const i = Math.min(4, Math.max(0, ~~(Math.log(size) / Math.log(1024))));
|
||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||
};
|
||||
calcSpeed(b, s) {
|
||||
@@ -170,7 +158,6 @@ export default new class {
|
||||
if (env.tag) link.push("tag", encodeURIComponent(env.tag));
|
||||
if (env.hall) link.push("h", encodeURIComponent(env.hall));
|
||||
if (env.user) link.push("user", encodeURIComponent(env.user), env.type ?? 'uploads');
|
||||
else if (env.type === 'favs') link.push("favs");
|
||||
|
||||
let tmp = link.length === 0 ? '/' : link.join('/');
|
||||
if (!tmp.endsWith('/'))
|
||||
@@ -295,110 +282,24 @@ export default new class {
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
userHasFavorited(session, favorites) {
|
||||
if (!session || !Array.isArray(favorites) || favorites.length === 0) return false;
|
||||
const sessId = session.id ? Number(session.id) : (session.user_id ? Number(session.user_id) : null);
|
||||
const sessUser = (session.user || '').toLowerCase();
|
||||
const sessLogin = (session.login || '').toLowerCase();
|
||||
const sessAnonLogin = (session.anon_login || '').toLowerCase();
|
||||
|
||||
return favorites.some(f => {
|
||||
const fUserId = f.user_id ? Number(f.user_id) : (f.id ? Number(f.id) : null);
|
||||
if (sessId && fUserId && fUserId === sessId) return true;
|
||||
const fUser = (f.user || '').toLowerCase();
|
||||
const fLogin = (f.login || '').toLowerCase();
|
||||
if (sessUser && sessUser !== 'anonymous' && (fUser === sessUser || fLogin === sessUser)) return true;
|
||||
if (sessLogin && (fUser === sessLogin || fLogin === sessLogin)) return true;
|
||||
if (sessAnonLogin && (fUser === sessAnonLogin || fLogin === sessAnonLogin)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async getTags(itemid, session = null, subf0ckId = null) {
|
||||
const isAnonymized = isAnonymizeSession(session);
|
||||
const hasSession = !isAnonymized && !!(session && !session.is_anon && (typeof session === 'object' ? (session.id || session.user) : session));
|
||||
|
||||
let albumItemId = null;
|
||||
if (subf0ckId !== null && subf0ckId !== undefined && subf0ckId !== '') {
|
||||
if (Number.isInteger(+subf0ckId) && +subf0ckId > 0) {
|
||||
const row = await db`
|
||||
SELECT id FROM album_items
|
||||
WHERE (id = ${+subf0ckId} OR (item_id = ${+itemid} AND order_index = ${+subf0ckId}))
|
||||
AND item_id = ${+itemid}
|
||||
LIMIT 1
|
||||
`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
if (!albumItemId) {
|
||||
const row = await db`
|
||||
SELECT id FROM album_items
|
||||
WHERE slug = ${String(subf0ckId)}
|
||||
AND item_id = ${+itemid}
|
||||
LIMIT 1
|
||||
`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
}
|
||||
|
||||
let tags;
|
||||
if (albumItemId) {
|
||||
tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "album_items_tags_assign"
|
||||
left join "tags" on "tags".id = "album_items_tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "album_items_tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "album_items_tags_assign".album_item_id = ${+albumItemId}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
|
||||
// If album sub-item has no rating tag of its own, inherit the parent post's rating tag
|
||||
const hasSubRating = tags.some(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t.id === 1 || t.id === 2);
|
||||
if (!hasSubRating) {
|
||||
const parentRatingTags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
and ("tags".id in (1, 2) or "tags".normalized in ('sfw', 'nsfw', 'nsfl'))
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc
|
||||
limit 1
|
||||
`;
|
||||
if (parentRatingTags.length > 0) {
|
||||
tags.unshift(parentRatingTags[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
}
|
||||
let hasRating = false;
|
||||
const cleanTags = [];
|
||||
const excludedTagIds = (session && Array.isArray(session.excluded_tags)) ? session.excluded_tags : [];
|
||||
const canExclude = (session && !session.is_anon) ? true : canAnonDo('exclude_tags');
|
||||
async getTags(itemid, session = null) {
|
||||
const hasSession = !!(session && (typeof session === 'object' ? (session.id || session.user) : session));
|
||||
const tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
for (let t = 0; t < tags.length; t++) {
|
||||
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
|
||||
if (isRating) {
|
||||
if (hasRating) continue;
|
||||
hasRating = true;
|
||||
}
|
||||
tags[t].badge = this.getBadge(tags[t]);
|
||||
tags[t].is_excluded = !isRating && excludedTagIds.includes(tags[t].id);
|
||||
tags[t].can_exclude = canExclude;
|
||||
if (!hasSession) {
|
||||
delete tags[t].user;
|
||||
delete tags[t].display_name;
|
||||
}
|
||||
cleanTags.push(tags[t]);
|
||||
}
|
||||
return cleanTags;
|
||||
return tags;
|
||||
};
|
||||
getBadge(tagObj) {
|
||||
if (tagObj.tag.startsWith(">"))
|
||||
@@ -466,46 +367,13 @@ export default new class {
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
|
||||
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Registered account required" }), type: 'application/json' });
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout' && req.url.pathname !== '/settings') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
// Require a real registered user account (explicitly denies anonymous SSH identities)
|
||||
async registeredUser(req, res, next) {
|
||||
if (!req.session) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: "401 - Unauthorized"
|
||||
});
|
||||
}
|
||||
if (req.session.is_anon || (req.session.user && req.session.user.startsWith('anon_'))) {
|
||||
const pathname = req.url?.pathname || (typeof req.url === 'string' ? req.url.split('?')[0] : '');
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return res.reply({
|
||||
code: 403,
|
||||
body: JSON.stringify({ success: false, msg: "Action requires a registered account" }),
|
||||
type: 'application/json'
|
||||
});
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
if (req.session.force_password_change && req.url.pathname !== '/api/v2/settings/password' && req.url.pathname !== '/logout') {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "Password change required", force_password_change: true }), type: 'application/json' });
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
async loggedin(req, res, next) {
|
||||
// SSH header auth removed (hard cut) — anonymous users must use passkey sessions
|
||||
if (!req.session) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
@@ -544,27 +412,6 @@ export default new class {
|
||||
return next();
|
||||
};
|
||||
|
||||
async chanAuth(req, res, next) {
|
||||
const isApi = (req.url?.pathname && req.url.pathname.startsWith('/api/')) || req.headers['x-requested-with'] === 'XMLHttpRequest' || req.headers['accept']?.includes('application/json');
|
||||
if (!req.session || !req.session.user) {
|
||||
if (isApi) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: "Unauthorized" }), type: 'application/json' });
|
||||
}
|
||||
return res.redirect('/login');
|
||||
}
|
||||
const hasGroup = canUseChan(req.session);
|
||||
if (!hasGroup) {
|
||||
if (isApi) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: "4chan group required" }), type: 'application/json' });
|
||||
}
|
||||
return res.reply({
|
||||
code: 403,
|
||||
body: `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Access Denied</title><link rel="stylesheet" href="/s/css/f0ck.css"></head><body style="background:#111;color:#eee;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;font-family:sans-serif;"><div style="text-align:center;padding:30px;background:#1a1a1a;border-radius:12px;border:1px solid #333;max-width:460px;"><div style="font-size:3rem;color:#4ade80;margin-bottom:15px;">🍀</div><h2 style="margin:0 0 10px;">Access Restricted</h2><p style="color:#aaa;line-height:1.5;">This 4chan viewer is only accessible to users with the <strong>4chan</strong> group.</p><a href="/" style="display:inline-block;margin-top:15px;color:#4ade80;text-decoration:none;">← Return to Home</a></div></body></html>`
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
// Middleware: authenticate via X-Api-Key header (upload-only)
|
||||
async apiKeyAuth(req, res, next) {
|
||||
const key = req.headers['x-api-key'];
|
||||
@@ -663,15 +510,5 @@ export default new class {
|
||||
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
|
||||
return hostNoPort.endsWith('.onion');
|
||||
}
|
||||
|
||||
isLocalhostRequest(req) {
|
||||
if (!req) return false;
|
||||
const rawHost = req.headers?.['x-forwarded-host'] || req.headers?.['host'] || req.headers?.['x-forwarded-server'] || '';
|
||||
if (!rawHost) return false;
|
||||
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
|
||||
const firstHost = hostStr.split(',')[0].trim();
|
||||
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
|
||||
return hostNoPort === 'localhost' || hostNoPort === '127.0.0.1' || hostNoPort === '::1';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import db from './sql.mjs';
|
||||
import cfg from './config.mjs';
|
||||
import { removePrivateItem } from './private_items.mjs';
|
||||
|
||||
/**
|
||||
* Safely remove the media file for a deleted item.
|
||||
@@ -231,14 +230,12 @@ export async function purgeExpiredUploads() {
|
||||
if (item.dest) {
|
||||
await safeDeleteMediaFile(item.dest, item.id);
|
||||
}
|
||||
await safeDeleteAlbumFiles(item.id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
|
||||
if (item.mime && item.mime.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => {});
|
||||
}
|
||||
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
|
||||
removePrivateItem(item.id, item.dest);
|
||||
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
|
||||
} catch (e) {
|
||||
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
|
||||
@@ -250,23 +247,3 @@ export async function purgeExpiredUploads() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely delete all album image files associated with an item.
|
||||
* @param {number} itemId
|
||||
*/
|
||||
export async function safeDeleteAlbumFiles(itemId) {
|
||||
try {
|
||||
const albumRows = await db`SELECT dest FROM album_items WHERE item_id = ${itemId}`;
|
||||
for (const row of albumRows) {
|
||||
if (row.dest) {
|
||||
await safeDeleteMediaFile(row.dest, itemId);
|
||||
const thumbName = row.dest.replace(/\.[^.]+$/, '.webp');
|
||||
await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {});
|
||||
}
|
||||
}
|
||||
await db`DELETE FROM album_items WHERE item_id = ${itemId}`.catch(() => {});
|
||||
} catch (e) {
|
||||
console.error(`[DELETE] Failed to delete album files for item #${itemId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-54
@@ -59,7 +59,6 @@
|
||||
"comment_optional": "(optional)",
|
||||
"comment_placeholder": "Kommentar zum Upload hinzufügen...",
|
||||
"select_file": "Datei auswählen",
|
||||
"redirect_to_item": "Nach dem Upload zum Post weiterleiten",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"pending_approval_patient": "Upload wartet auf Freigabe, bitte haben Sie etwas Geduld",
|
||||
"remove_file": "Datei entfernen",
|
||||
@@ -159,7 +158,7 @@
|
||||
"alternative_steuerung": "Icon-Navigationsstil",
|
||||
"alternative_steuerung_hint": "Ersetzt die Text-Navigation (← zurück | Zufall | weiter →) durch kompakte Chevron-Icons",
|
||||
"f_for_fullscreen": "F is for Fullscreen",
|
||||
"f_for_fullscreen_hint": "Das Drücken von F schaltet das Vollbild im Videoplayer statt den Beitrag zu favorisieren (Shift+F zum Favorisieren).",
|
||||
"f_for_fullscreen_hint": "Das Drücken von F schaltet das Vollbild im Videoplayer statt den Beitrag zu favorisieren.",
|
||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||
"disable_autoplay_hint": "Verhindert die automatische Wiedergabe von Videos und Audio",
|
||||
"hide_item_ratings": "Item-Bewertungen ausblenden",
|
||||
@@ -271,12 +270,6 @@
|
||||
"start_export": "Export generieren (ZIP)"
|
||||
},
|
||||
"filter": {
|
||||
"excluded_tags": "Ausgeschlossene Tags",
|
||||
"exclude_tag": "Tag ausschließen",
|
||||
"unexclude_tag": "Ausgeschlossen (klicken zum Aufheben)",
|
||||
"no_tags_excluded": "Noch keine Tags ausgeschlossen",
|
||||
"tag_excluded_msg": "Tag '{tag}' zu ausgeschlossenen Tags hinzugefügt",
|
||||
"tag_unexcluded_msg": "Tag '{tag}' aus ausgeschlossenen Tags entfernt",
|
||||
"tag_placeholder": "Tag ausschließen",
|
||||
"random_mode": "RAND",
|
||||
"min_xd_score": "Min. xD-Score",
|
||||
@@ -284,8 +277,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Bild",
|
||||
"flash": "Flash",
|
||||
"grid_mode": "Thumbnailgröße"
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Tastaturkürzel",
|
||||
@@ -299,8 +291,6 @@
|
||||
"focus_comment": "Kommentarfeld fokussieren",
|
||||
"send_comment": "Kommentar senden",
|
||||
"flash_yank": "Flash-Yank ein-/ausschalten",
|
||||
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
|
||||
"open_settings": "Einstellungen öffnen/schließen",
|
||||
"tag_exclude": "Tag-Ausschluss öffnen",
|
||||
"tag_input": "Tag-Eingabe öffnen",
|
||||
"toggle_bg": "Hintergrund ein-/ausschalten",
|
||||
@@ -351,8 +341,7 @@
|
||||
"label": "Fehler",
|
||||
"post_not_visible": "Dieser Beitrag ist derzeit leider nicht sichtbar.",
|
||||
"filter_hint": "Dein aktueller Filter ist auf {mode} gesetzt.",
|
||||
"filter_hint_link": "Um diesen Inhalt zu sehen, ändere deinen Filter entsprechend.",
|
||||
"see_anyways": "Trotzdem ansehen"
|
||||
"filter_hint_link": "Um diesen Inhalt zu sehen, ändere deinen Filter entsprechend."
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Überall ablegen zum Hochladen"
|
||||
@@ -404,11 +393,7 @@
|
||||
"name": "Name",
|
||||
"description": "Beschreibung",
|
||||
"private": "Privat",
|
||||
"view": "Ansehen",
|
||||
"save": "Speichern",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"dismiss": "Schließen"
|
||||
"view": "Ansehen"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Aktion bestätigen",
|
||||
@@ -475,9 +460,6 @@
|
||||
"acknowledge": "Verstanden"
|
||||
},
|
||||
"sidebar": {
|
||||
"recent_comments": "Neueste Kommentare",
|
||||
"recommendations": "Empfehlungen",
|
||||
"recommended_videos": "Video-Empfehlungen",
|
||||
"loading_activity": "Aktivität wird geladen...",
|
||||
"no_activity": "Keine kürzliche Aktivität.",
|
||||
"failed_to_load": "Laden fehlgeschlagen.",
|
||||
@@ -486,10 +468,7 @@
|
||||
"view": "Ansehen",
|
||||
"read_more": "mehr sehen",
|
||||
"see_less": "weniger anzeigen",
|
||||
"show_full_comment": "ganzen Kommentar anzeigen",
|
||||
"loading_recommendations": "Lade Empfehlungen...",
|
||||
"no_recommendations": "Keine Empfehlungen gefunden.",
|
||||
"refresh_recommendations": "Empfehlungen aktualisieren"
|
||||
"show_full_comment": "ganzen Kommentar anzeigen"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Meine Abonnements",
|
||||
@@ -527,7 +506,6 @@
|
||||
"stat_favs": "Gesamt Favoriten",
|
||||
"stat_disk_usage": "Dateigröße Gesamt",
|
||||
"stat_users": "Gesamt Benutzer",
|
||||
"stat_anon_users": "Anonyme Benutzer",
|
||||
"most_favorited": "Meiste Favs",
|
||||
"favs": "Favs",
|
||||
"top_xd": "Top xD-Score"
|
||||
@@ -563,9 +541,6 @@
|
||||
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
||||
"no_uploads": "Keine Uploads gefunden",
|
||||
"no_favs": "Keine Favoriten",
|
||||
"guest_favs_saved": "Du hast {count} Gast-Favoriten auf diesem Gerät gespeichert.",
|
||||
"sync_guest_favs": "In Account importieren",
|
||||
"guest_favs_imported": "Favoriten erfolgreich in deinen Account importiert!",
|
||||
"private_favorites": "private Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
@@ -668,8 +643,6 @@
|
||||
"reason_required": "Grund ist erforderlich.",
|
||||
"reason_optional": "Grund (optional)",
|
||||
"reason_required_label": "Grund (erforderlich)",
|
||||
"captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
|
||||
"captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
|
||||
"processing": "Wird verarbeitet...",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
@@ -742,7 +715,6 @@
|
||||
"settings": "Einstellungen",
|
||||
"filters": "Filter",
|
||||
"volume": "Lautstärke",
|
||||
"clear_filter": "Filter löschen",
|
||||
"reset_all": "Alles zurücksetzen",
|
||||
"rating": "Bewertung",
|
||||
"all": "Alle",
|
||||
@@ -836,26 +808,5 @@
|
||||
"private": "Privat",
|
||||
"change_visibility": "Sichtbarkeit ändern"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"title": "Album",
|
||||
"multiple_selected": "Mehrere Dateien ausgewählt",
|
||||
"mode_album": "Album (1 Beitrag)",
|
||||
"mode_batch": "Einzelne Beiträge",
|
||||
"cover": "Titelbild",
|
||||
"pictures": "Subf0cks",
|
||||
"counter": "%s von %s",
|
||||
"prev": "Vorheriger Subf0ck",
|
||||
"next": "Nächster Subf0ck",
|
||||
"hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
|
||||
"move_left": "Nach links",
|
||||
"move_right": "Nach rechts",
|
||||
"remove_picture": "Subf0ck entfernen",
|
||||
"add_more": "Weitere Subf0cks hinzufügen",
|
||||
"drop_hint": "Wähle oder ziehe mehrere Dateien (Subf0cks) hierher, um ein Album zu erstellen",
|
||||
"select_pictures": "Dateien für Album auswählen",
|
||||
"min_pictures": "Mindestens 2 Subf0cks für ein Album erforderlich",
|
||||
"subf0ck_tags": "Tags für diesen Subf0ck",
|
||||
"subf0ck_tags_placeholder": "Tags für diesen Subf0ck (optional)"
|
||||
}
|
||||
}
|
||||
+12
-59
@@ -43,7 +43,7 @@
|
||||
"url_tab_yt": "URL / YouTube",
|
||||
"url_placeholder": "Paste a URL to download...",
|
||||
"url_placeholder_yt": "Paste a URL or YouTube link...",
|
||||
"url_placeholder_shitpost": "Paste a URL or YouTube link...",
|
||||
"url_placeholder_shitpost": "Paste multiple URLs here (one per line)...",
|
||||
"drop_here": "Drop your file here",
|
||||
"admin_boost": "Admin Boost",
|
||||
"custom_thumbnail": "Custom Thumbnail",
|
||||
@@ -59,7 +59,6 @@
|
||||
"comment_optional": "(optional)",
|
||||
"comment_placeholder": "Add a comment to your upload...",
|
||||
"select_file": "Select a file",
|
||||
"redirect_to_item": "Redirect to item after upload",
|
||||
"uploading": "Uploading...",
|
||||
"pending_approval_patient": "Upload awaits approval, please be patient",
|
||||
"remove_file": "Remove File",
|
||||
@@ -159,7 +158,7 @@
|
||||
"alternative_steuerung": "Icon nav style",
|
||||
"alternative_steuerung_hint": "Replace text navigation (← prev | random | next →) with compact chevron icons",
|
||||
"f_for_fullscreen": "F is for Fullscreen",
|
||||
"f_for_fullscreen_hint": "Pressing F will toggle fullscreen in the video player instead of favoriting the post (Shift+F to favorite).",
|
||||
"f_for_fullscreen_hint": "Pressing F will toggle fullscreen in the video player instead of favoriting the post.",
|
||||
"disable_autoplay": "Disable Autoplay",
|
||||
"disable_autoplay_hint": "Prevent videos and audio from playing automatically",
|
||||
"hide_item_ratings": "Hide Item Ratings",
|
||||
@@ -182,8 +181,8 @@
|
||||
"blur_nsfl_hint": "Blur NSFL-rated/shock thumbnails.",
|
||||
"blur_sfw": "Blur SFW",
|
||||
"blur_sfw_hint": "Blur SFW-rated thumbnails.",
|
||||
"blur_untagged": "Blur Unrated",
|
||||
"blur_untagged_hint": "Blur thumbnails with no rating.",
|
||||
"blur_untagged": "Blur Untagged",
|
||||
"blur_untagged_hint": "Blur thumbnails with no tags or rating.",
|
||||
"blur_detail": "Click to reveal item on detail page",
|
||||
"blur_detail_hint": "Require clicking to reveal blurred media on the post detail page.",
|
||||
"render_emojis": "Render emojis in quote replies",
|
||||
@@ -271,12 +270,6 @@
|
||||
"start_export": "Generate Export (ZIP)"
|
||||
},
|
||||
"filter": {
|
||||
"excluded_tags": "Excluded Tags",
|
||||
"exclude_tag": "Exclude tag",
|
||||
"unexclude_tag": "Excluded (click to unexclude)",
|
||||
"no_tags_excluded": "No tags excluded yet",
|
||||
"tag_excluded_msg": "Tag '{tag}' added to excluded tags",
|
||||
"tag_unexcluded_msg": "Tag '{tag}' removed from excluded tags",
|
||||
"tag_placeholder": "Tag to exclude",
|
||||
"random_mode": "RAND",
|
||||
"min_xd_score": "Min xD Score",
|
||||
@@ -284,8 +277,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Image",
|
||||
"flash": "Flash",
|
||||
"grid_mode": "Thumbnail size"
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
@@ -299,8 +291,6 @@
|
||||
"focus_comment": "focus comment input",
|
||||
"send_comment": "send comment",
|
||||
"flash_yank": "enable/disable flash yank",
|
||||
"square_clicker": "Square Clicker (audio / video items)",
|
||||
"open_settings": "open/close settings",
|
||||
"tag_exclude": "open tag exclude",
|
||||
"tag_input": "open tag input",
|
||||
"toggle_bg": "turns on/off the background",
|
||||
@@ -351,8 +341,7 @@
|
||||
"label": "Error",
|
||||
"post_not_visible": "Sorry, this post is currently not visible.",
|
||||
"filter_hint": "Your current filter is set to {mode}.",
|
||||
"filter_hint_link": "To view this content, change your filter accordingly.",
|
||||
"see_anyways": "See anyways"
|
||||
"filter_hint_link": "To view this content, change your filter accordingly."
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Drop anywhere to upload"
|
||||
@@ -407,8 +396,7 @@
|
||||
"view": "View",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"dismiss": "Dismiss"
|
||||
"edit": "Edit"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Confirm Action",
|
||||
@@ -467,7 +455,7 @@
|
||||
},
|
||||
"report": {
|
||||
"title": "Submit Report",
|
||||
"placeholder": "",
|
||||
"placeholder": "Please provide details for this report (required)...",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"account_warning": {
|
||||
@@ -476,10 +464,6 @@
|
||||
"acknowledge": "I Understand"
|
||||
},
|
||||
"sidebar": {
|
||||
"recent_comments": "Recent Comments",
|
||||
"recommendations": "Recommendations",
|
||||
"recommended_videos": "Recommended Videos",
|
||||
"no_recommendations": "No recommendations found.",
|
||||
"loading_activity": "Loading activity...",
|
||||
"no_activity": "No recent activity.",
|
||||
"failed_to_load": "Failed to load.",
|
||||
@@ -488,10 +472,7 @@
|
||||
"view": "View",
|
||||
"read_more": "read more",
|
||||
"see_less": "see less",
|
||||
"show_full_comment": "show full comment",
|
||||
"loading_recommendations": "Loading recommendations...",
|
||||
"no_recommendations": "No video recommendations found.",
|
||||
"refresh_recommendations": "Refresh recommendations"
|
||||
"show_full_comment": "show full comment"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "My Subscriptions",
|
||||
@@ -520,7 +501,7 @@
|
||||
"tag_stats": "Stats",
|
||||
"stat_total": "Total Items",
|
||||
"stat_tagged": "Tagged",
|
||||
"stat_untagged": "Unrated",
|
||||
"stat_untagged": "Untagged",
|
||||
"stat_sfw": "SFW content",
|
||||
"stat_nsfw": "NSFW content",
|
||||
"stat_nsfl": "NSFL content",
|
||||
@@ -529,7 +510,6 @@
|
||||
"stat_favs": "Total Favorites",
|
||||
"stat_disk_usage": "Total File Size",
|
||||
"stat_users": "Total Users",
|
||||
"stat_anon_users": "Anonymous Users",
|
||||
"most_favorited": "Most Favorited",
|
||||
"favs": "favs",
|
||||
"top_xd": "Top xD Scores"
|
||||
@@ -540,7 +520,7 @@
|
||||
"limit_reached": "Upload limit reached (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} uploads remaining",
|
||||
"auth_required_title": "Authentication Required",
|
||||
"auth_required_text": "You must be logged in to upload content.",
|
||||
"auth_required_text": "You must be logged in to upload content to w0bm.",
|
||||
"login_btn": "Login"
|
||||
},
|
||||
"messages": {
|
||||
@@ -565,9 +545,6 @@
|
||||
"subscribe_uploads_btn": "Subscribe user to uploads",
|
||||
"no_uploads": "no uploads found",
|
||||
"no_favs": "no favorites",
|
||||
"guest_favs_saved": "You have {count} guest favorites saved on this device.",
|
||||
"sync_guest_favs": "Import to Account",
|
||||
"guest_favs_imported": "Imported favorites to your account!",
|
||||
"private_favorites": "private favorites",
|
||||
"back_to_profile": "Back to Profile",
|
||||
"ban_modal_title": "Ban User",
|
||||
@@ -670,8 +647,6 @@
|
||||
"reason_required": "Reason is required.",
|
||||
"reason_optional": "Reason (optional)",
|
||||
"reason_required_label": "Reason (required)",
|
||||
"captcha_required": "Please complete the CAPTCHA.",
|
||||
"captcha_loading": "CAPTCHA is still loading. Please wait a moment.",
|
||||
"processing": "Processing...",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
@@ -742,11 +717,10 @@
|
||||
"settings": "Settings",
|
||||
"filters": "Filters",
|
||||
"volume": "Volume",
|
||||
"clear_filter": "Clear filter",
|
||||
"reset_all": "Reset all",
|
||||
"rating": "Rating",
|
||||
"all": "All",
|
||||
"untagged": "Unrated",
|
||||
"untagged": "Untagged",
|
||||
"media_type": "Media type",
|
||||
"video": "Video",
|
||||
"image": "Image",
|
||||
@@ -836,26 +810,5 @@
|
||||
"private": "Private",
|
||||
"change_visibility": "Change Visibility"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"title": "Album",
|
||||
"multiple_selected": "Multiple files selected",
|
||||
"mode_album": "Album (1 post)",
|
||||
"mode_batch": "Separate posts",
|
||||
"cover": "Cover",
|
||||
"pictures": "subf0cks",
|
||||
"counter": "%s of %s",
|
||||
"prev": "Previous subf0ck",
|
||||
"next": "Next subf0ck",
|
||||
"hotkey_tip": "Use [ and ] or hover to navigate subf0cks",
|
||||
"move_left": "Move left",
|
||||
"move_right": "Move right",
|
||||
"remove_picture": "Remove subf0ck",
|
||||
"add_more": "Add more subf0cks",
|
||||
"drop_hint": "Select or drop multiple files (subf0cks) to create an Album",
|
||||
"select_pictures": "Select files for album",
|
||||
"min_pictures": "Add at least 2 subf0cks for an album",
|
||||
"subf0ck_tags": "Tags for this subf0ck",
|
||||
"subf0ck_tags_placeholder": "Tags for this subf0ck (optional)"
|
||||
}
|
||||
}
|
||||
+6
-55
@@ -59,7 +59,6 @@
|
||||
"comment_optional": "(optioneel)",
|
||||
"comment_placeholder": "Voeg een opmerking toe aan je upload...",
|
||||
"select_file": "Selecteer een bestand",
|
||||
"redirect_to_item": "Na het uploaden naar het item doorsturen",
|
||||
"uploading": "Uploaden...",
|
||||
"pending_approval_patient": "Upload wacht op goedkeuring, even geduld alstublieft",
|
||||
"remove_file": "Bestand Verwijderen",
|
||||
@@ -159,7 +158,7 @@
|
||||
"alternative_steuerung": "Icoon-navigatiestijl",
|
||||
"alternative_steuerung_hint": "Vervangt tekstnavigatie (← terug | willekeurig | verder →) door compacte chevron-iconen",
|
||||
"f_for_fullscreen": "F is for Fullscreen",
|
||||
"f_for_fullscreen_hint": "Drukken op F schakelt het volledige scherm in de videospeler in plaats van de post te favorieten (Shift+F om te favorieten).",
|
||||
"f_for_fullscreen_hint": "Drukken op F schakelt het volledige scherm in de videospeler in plaats van de post te favorieten.",
|
||||
"disable_autoplay": "Automatisch afspelen uitschakelen",
|
||||
"disable_autoplay_hint": "Voorkomen dat video's en audio automatisch worden afgespeeld",
|
||||
"hide_item_ratings": "Itembeoordelingen verbergen",
|
||||
@@ -269,12 +268,6 @@
|
||||
"start_export": "Export genereren (ZIP)"
|
||||
},
|
||||
"filter": {
|
||||
"excluded_tags": "Uitgesloten Tags",
|
||||
"exclude_tag": "Tag uitsluiten",
|
||||
"unexclude_tag": "Uitgesloten (klik om te herstellen)",
|
||||
"no_tags_excluded": "Nog geen tags uitgesloten",
|
||||
"tag_excluded_msg": "Tag '{tag}' toegevoegd aan uitgesloten tags",
|
||||
"tag_unexcluded_msg": "Tag '{tag}' verwijderd uit uitgesloten tags",
|
||||
"tag_placeholder": "Tag om uit te sluiten",
|
||||
"random_mode": "WILLEKEURIG",
|
||||
"min_xd_score": "Min xD-score",
|
||||
@@ -282,8 +275,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Afbeelding",
|
||||
"flash": "Flash",
|
||||
"grid_mode": "Thumbnailgrootte"
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Sneltoetsen",
|
||||
@@ -297,8 +289,6 @@
|
||||
"focus_comment": "focus op commentaarinvoer",
|
||||
"send_comment": "opmerking verzenden",
|
||||
"flash_yank": "flash yank in/uitschakelen",
|
||||
"square_clicker": "Square Clicker (audio- / video-posts)",
|
||||
"open_settings": "instellingen openen/sluiten",
|
||||
"tag_exclude": "open tag uitsluiten",
|
||||
"tag_input": "open tag invoer",
|
||||
"toggle_bg": "turns on/off the background",
|
||||
@@ -349,8 +339,7 @@
|
||||
"label": "Fout",
|
||||
"post_not_visible": "Sorry, deze post is momenteel niet zichtbaar.",
|
||||
"filter_hint": "Je huidige filter is ingesteld op {mode}.",
|
||||
"filter_hint_link": "Om deze inhoud te bekijken, pas je filter aan.",
|
||||
"see_anyways": "Toch bekijken"
|
||||
"filter_hint_link": "Om deze inhoud te bekijken, pas je filter aan."
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Overal slepen om te uploaden"
|
||||
@@ -402,11 +391,7 @@
|
||||
"name": "Naam",
|
||||
"description": "Beschrijving",
|
||||
"private": "Privé",
|
||||
"view": "Bekijken",
|
||||
"save": "Opslaan",
|
||||
"delete": "Verwijderen",
|
||||
"edit": "Bewerken",
|
||||
"dismiss": "Sluiten"
|
||||
"view": "Bekijken"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Actie Bevestigen",
|
||||
@@ -473,9 +458,6 @@
|
||||
"acknowledge": "Ik Begrijp het"
|
||||
},
|
||||
"sidebar": {
|
||||
"recent_comments": "Recente reacties",
|
||||
"recommendations": "Aanbevelingen",
|
||||
"recommended_videos": "Aanbevolen video's",
|
||||
"loading_activity": "Activiteit laden...",
|
||||
"no_activity": "Geen recente activiteit.",
|
||||
"failed_to_load": "Laden mislukt.",
|
||||
@@ -484,10 +466,7 @@
|
||||
"view": "Bekijken",
|
||||
"read_more": "lees meer",
|
||||
"see_less": "zie minder",
|
||||
"show_full_comment": "volledig commentaar tonen",
|
||||
"loading_recommendations": "Aanbevelingen laden...",
|
||||
"no_recommendations": "Geen aanbevelingen gevonden.",
|
||||
"refresh_recommendations": "Aanbevelingen vernieuwen"
|
||||
"show_full_comment": "volledig commentaar tonen"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Mijn Abonnementen",
|
||||
@@ -525,7 +504,6 @@
|
||||
"stat_favs": "Totaal aantal favorieten",
|
||||
"stat_disk_usage": "Totale Bestandsgrootte",
|
||||
"stat_users": "Totaal Gebruikers",
|
||||
"stat_anon_users": "Anonieme gebruikers",
|
||||
"most_favorited": "Meest Gefavoriet",
|
||||
"favs": "favorieten",
|
||||
"top_xd": "Top xD-scores"
|
||||
@@ -536,7 +514,7 @@
|
||||
"limit_reached": "Uploadlimiet bereikt (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} uploads over",
|
||||
"auth_required_title": "Authenticatie Vereist",
|
||||
"auth_required_text": "Je moet ingelogd zijn om inhoud naar f0ckm te uploaden.",
|
||||
"auth_required_text": "Je moet ingelogd zijn om inhoud naar w0bm te uploaden.",
|
||||
"login_btn": "Inloggen"
|
||||
},
|
||||
"messages": {
|
||||
@@ -561,9 +539,6 @@
|
||||
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
||||
"no_uploads": "geen uploads gevonden",
|
||||
"no_favs": "geen favorieten",
|
||||
"guest_favs_saved": "Je hebt {count} gastfavorieten opgeslagen op dit apparaat.",
|
||||
"sync_guest_favs": "Importeren naar account",
|
||||
"guest_favs_imported": "Favorieten succesvol geïmporteerd naar je account!",
|
||||
"private_favorites": "privé favorieten",
|
||||
"back_to_profile": "Terug naar Profiel",
|
||||
"ban_modal_title": "Gebruiker Bannen",
|
||||
@@ -666,8 +641,6 @@
|
||||
"reason_required": "Reden is vereist.",
|
||||
"reason_optional": "Reden (optioneel)",
|
||||
"reason_required_label": "Reden (vereist)",
|
||||
"captcha_required": "Vul alstublieft de CAPTCHA in.",
|
||||
"captcha_loading": "CAPTCHA is nog aan het laden. Even geduld a.u.b.",
|
||||
"processing": "Verwerken...",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
@@ -738,7 +711,6 @@
|
||||
"settings": "Instellingen",
|
||||
"filters": "Filters",
|
||||
"volume": "Volume",
|
||||
"clear_filter": "Filter wissen",
|
||||
"reset_all": "Alles resetten",
|
||||
"rating": "Beoordeling",
|
||||
"all": "Alles",
|
||||
@@ -826,26 +798,5 @@
|
||||
"slot_refreshes_on": "slot vernieuwd op {date}",
|
||||
"slot_refreshed": "slot vernieuwd",
|
||||
"admin_desc": "Je bent admin, ga je gang."
|
||||
},
|
||||
"album": {
|
||||
"title": "Album",
|
||||
"multiple_selected": "Meerdere afbeeldingen geselecteerd",
|
||||
"mode_album": "Album (1 bericht)",
|
||||
"mode_batch": "Aparte berichten",
|
||||
"cover": "Omslag",
|
||||
"pictures": "afbeeldingen",
|
||||
"counter": "%s van %s",
|
||||
"prev": "Vorige afbeelding",
|
||||
"next": "Volgende afbeelding",
|
||||
"hotkey_tip": "Gebruik [ en ] of zweef over album om te navigeren",
|
||||
"move_left": "Naar links",
|
||||
"move_right": "Naar rechts",
|
||||
"remove_picture": "Afbeelding verwijderen",
|
||||
"add_more": "Meer afbeeldingen toevoegen",
|
||||
"drop_hint": "Selecteer of sleep meerdere afbeeldingen om een album te maken",
|
||||
"select_pictures": "Selecteer afbeeldingen voor album",
|
||||
"min_pictures": "Voeg minimaal 2 afbeeldingen toe voor een album",
|
||||
"subf0ck_tags": "Tags voor deze subf0ck",
|
||||
"subf0ck_tags_placeholder": "Tags voor deze subf0ck (optioneel)"
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,6 @@
|
||||
"comment_optional": "(optional)",
|
||||
"comment_placeholder": "Fügen Sie Ihrer Aufladierung doch einen Kommentar hinzu...",
|
||||
"select_file": "Datei auswählen",
|
||||
"redirect_to_item": "Nach Pfostieren zum Pfosten weiterleiten",
|
||||
"uploading": "Wird aufladiert...",
|
||||
"pending_approval_patient": "Die Ladung harrt der Absegnung, bitte haben Sie Geduld",
|
||||
"remove_file": "Datei entfernen",
|
||||
@@ -159,7 +158,7 @@
|
||||
"alternative_steuerung": "Icon-Navigationsstil",
|
||||
"alternative_steuerung_hint": "Ersetzt die Text-Navigation (← zurück | Zufall | weiter →) durch kompakte Chevron-Icons",
|
||||
"f_for_fullscreen": "F is for Fullscreen",
|
||||
"f_for_fullscreen_hint": "Das Drücken der F-Taste leitet die Vollbildlichkeit des Abspielers ein, statt den Pfosten zu favorisieren (Shift+F zur Favorisierung).",
|
||||
"f_for_fullscreen_hint": "Das Drücken der F-Taste leitet die Vollbildlichkeit des Abspielers ein, statt den Pfosten zu favorisieren.",
|
||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||
"disable_autoplay_hint": "Vermeiden Sie das automatische Abspielen von Videos und Tondateien",
|
||||
"hide_item_ratings": "Item-Bewertungen ausblenden",
|
||||
@@ -267,12 +266,6 @@
|
||||
"start_export": "Paket schnüren"
|
||||
},
|
||||
"filter": {
|
||||
"excluded_tags": "Ausgeschlossene Etiketten",
|
||||
"exclude_tag": "Etikett ausschließen",
|
||||
"unexclude_tag": "Ausgeschlossen (klicken zum Wiederherstellen)",
|
||||
"no_tags_excluded": "Noch keine Etiketten ausgeschlossen",
|
||||
"tag_excluded_msg": "Etikett '{tag}' zu ausgeschlossenen Etiketten hinzugefügt",
|
||||
"tag_unexcluded_msg": "Etikett '{tag}' aus ausgeschlossenen Etiketten entfernt",
|
||||
"tag_placeholder": "Auszuschließendes Etikett",
|
||||
"random_mode": "ZUFA",
|
||||
"min_xd_score": "Min. xD-Punktestand",
|
||||
@@ -280,8 +273,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Tondatei",
|
||||
"image": "Bild",
|
||||
"flash": "Blitz",
|
||||
"grid_mode": "Größe der Daumennägel"
|
||||
"flash": "Blitz"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Tastaturkürzel",
|
||||
@@ -295,8 +287,6 @@
|
||||
"focus_comment": "Kommentareingabe fokussieren",
|
||||
"send_comment": "Kommentar senden",
|
||||
"flash_yank": "Blitz-Rucken aktivieren/deaktivieren",
|
||||
"square_clicker": "Square Clicker (Audio- / Video-Posts)",
|
||||
"open_settings": "Einstellungen auf-/zuklappen",
|
||||
"tag_exclude": "Etiketten-Ausschluss öffnen",
|
||||
"tag_input": "Etiketten-Eingabe öffnen",
|
||||
"toggle_bg": "Hintergrund ein-/ausschalten",
|
||||
@@ -347,8 +337,7 @@
|
||||
"label": "Fehler",
|
||||
"post_not_visible": "Bedauerlicher Weise ist dieser Pfosten derzeit nicht sichtbar.",
|
||||
"filter_hint": "Ihr aktueller Filter ist auf {mode} eingestellt.",
|
||||
"filter_hint_link": "Um diesen Inhalt zu betrachten, stellen Sie Ihren Filter entsprechend um.",
|
||||
"see_anyways": "Dennoch betrachten"
|
||||
"filter_hint_link": "Um diesen Inhalt zu betrachten, stellen Sie Ihren Filter entsprechend um."
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Überall ablegen zum Aufladieren"
|
||||
@@ -402,8 +391,7 @@
|
||||
"view": "Ansehen",
|
||||
"save": "Speichern",
|
||||
"delete": "Löschen",
|
||||
"edit": "Editieren",
|
||||
"dismiss": "Weg damit"
|
||||
"edit": "Editieren"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Aktion bestätigen",
|
||||
@@ -471,9 +459,6 @@
|
||||
"acknowledge": "Ich verstehe"
|
||||
},
|
||||
"sidebar": {
|
||||
"recent_comments": "Frische Kommis",
|
||||
"recommendations": "Zufallskram",
|
||||
"recommended_videos": "Filmchen-Tipps",
|
||||
"loading_activity": "Aktivität wird geladen...",
|
||||
"no_activity": "Noch keine Aktivität",
|
||||
"failed_to_load": "Ladung gescheitert.",
|
||||
@@ -482,10 +467,7 @@
|
||||
"view": "Ansehen",
|
||||
"read_more": "mehr sehen",
|
||||
"see_less": "weniger sehen",
|
||||
"show_full_comment": "Kommentar vollständig ausklappen",
|
||||
"loading_recommendations": "Lade Empfehlungen...",
|
||||
"no_recommendations": "Nix am Start.",
|
||||
"refresh_recommendations": "Neu würfeln"
|
||||
"show_full_comment": "Kommentar vollständig ausklappen"
|
||||
},
|
||||
"subscriptions": {
|
||||
"title": "Meine Abonnements",
|
||||
@@ -523,7 +505,6 @@
|
||||
"stat_favs": "Gesamtanzahl Favoriten",
|
||||
"stat_disk_usage": "Dateigröße Gesamt",
|
||||
"stat_users": "Gesamt Benutzer",
|
||||
"stat_anon_users": "Anonymer Alkoholiker",
|
||||
"most_favorited": "Am häufigsten favorisiert",
|
||||
"favs": "Favoriten",
|
||||
"top_xd": "Beste xD-Punktestände"
|
||||
@@ -534,7 +515,7 @@
|
||||
"limit_reached": "Aufladierungsgrenze erreicht (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} Aufladierungen verbleibend",
|
||||
"auth_required_title": "Authentifizierung erforderlich",
|
||||
"auth_required_text": "Sie müssen angemeldet sein, um Inhalte hochzuladen.",
|
||||
"auth_required_text": "Sie müssen angemeldet sein, um Inhalte auf w0bm hochzuladen.",
|
||||
"login_btn": "Anmeldung"
|
||||
},
|
||||
"messages": {
|
||||
@@ -559,9 +540,6 @@
|
||||
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
||||
"no_uploads": "keine Aufladierungen gefunden",
|
||||
"no_favs": "keine Favoriten",
|
||||
"guest_favs_saved": "Du hast {count} Kaltgast-Favs auf diesem Gerät rumgammeln.",
|
||||
"sync_guest_favs": "In Account ballern",
|
||||
"guest_favs_imported": "Favs erfolgreich ins Konto geballert!",
|
||||
"private_favorites": "private Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
@@ -664,8 +642,6 @@
|
||||
"reason_required": "Grund ist erforderlich.",
|
||||
"reason_optional": "Grund (optional)",
|
||||
"reason_required_label": "Grund (erforderlich)",
|
||||
"captcha_required": "Bitte füllen Sie das CAPTCHA aus.",
|
||||
"captcha_loading": "CAPTCHA wird noch geladen. Bitte kurz warten.",
|
||||
"processing": "Verarbeitung wird durchgeführt...",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
@@ -738,7 +714,6 @@
|
||||
"settings": "Einstellungen",
|
||||
"filters": "Filter",
|
||||
"volume": "Lautstärke",
|
||||
"clear_filter": "Filter entfernen",
|
||||
"reset_all": "Alles zurücksetzen",
|
||||
"rating": "Bewertung",
|
||||
"all": "Alle",
|
||||
@@ -826,26 +801,5 @@
|
||||
"slot_refreshes_on": "Platz erneuert sich am {date}",
|
||||
"slot_refreshed": "Platz erneuert",
|
||||
"admin_desc": "Du bist Admin, mach weiter."
|
||||
},
|
||||
"album": {
|
||||
"title": "Album",
|
||||
"multiple_selected": "Mehrere Bildnisse ausgewählt",
|
||||
"mode_album": "Album (1 Einpfostung)",
|
||||
"mode_batch": "Vereinzelte Einpfostungen",
|
||||
"cover": "Deckblatt",
|
||||
"pictures": "Bildnisse",
|
||||
"counter": "%s von %s",
|
||||
"prev": "Vorheriges Bildnis",
|
||||
"next": "Nächstes Bildnis",
|
||||
"hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
|
||||
"move_left": "Nach links",
|
||||
"move_right": "Nach rechts",
|
||||
"remove_picture": "Bildnis entfernen",
|
||||
"add_more": "Weitere Bildnisse hinzufügen",
|
||||
"drop_hint": "Wähle oder droppe mehrere Bilder für 1 Album",
|
||||
"select_pictures": "Bildnisse fürs Album auswählen",
|
||||
"min_pictures": "Mindestens 2 Bildnisse fürs Album nötig",
|
||||
"subf0ck_tags": "Tags für dies Unterf0ck",
|
||||
"subf0ck_tags_placeholder": "Tags für dies Unterf0ck (optional)"
|
||||
}
|
||||
}
|
||||
+3
-20
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import db from "./sql.mjs";
|
||||
import cfg from "./config.mjs";
|
||||
import { getHwFingerprintEnabled } from "./settings.mjs";
|
||||
|
||||
/**
|
||||
* retention.mjs — automatic deletion of personal data after a configurable period.
|
||||
*
|
||||
* All periods are in days, configured under websrv.* (0 = keep forever):
|
||||
* retention_ip_days stored IPs: user_ips rows are deleted; IP columns on sessions, anon identities,
|
||||
* uploads, comments, reports and ToS acceptances are set to NULL
|
||||
* retention_activity_log_days anon_activity_log rows (action, IP, fingerprints) are deleted
|
||||
* retention_login_attempts_days login_attempts rows (hashed IP + attempted username) are deleted
|
||||
* retention_sessions_days sessions unused for this long are deleted (logs that device out)
|
||||
* retention_fingerprint_days hardware fingerprint is cleared from anonymous identities inactive this long
|
||||
*
|
||||
* With websrv.anon_hw_fingerprint: false, every stored device fingerprint (identities and activity log) is
|
||||
* cleared on each run, regardless of age.
|
||||
*
|
||||
* Active bans (banned_ips / banned_fingerprints / banned_hardware_fingerprints) are not touched: they are kept
|
||||
* until they expire or are lifted.
|
||||
*/
|
||||
|
||||
const DEFAULTS = {
|
||||
ip: 30,
|
||||
activity_log: 90,
|
||||
login_attempts: 30,
|
||||
sessions: 365,
|
||||
fingerprint: 365
|
||||
};
|
||||
|
||||
const days = (key) => {
|
||||
const v = cfg.websrv?.[`retention_${key}_days`];
|
||||
if (v === undefined || v === null || v === '') return DEFAULTS[key];
|
||||
const n = parseInt(v, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
};
|
||||
|
||||
export const getRetention = () => ({
|
||||
ip: days('ip'),
|
||||
activity_log: days('activity_log'),
|
||||
login_attempts: days('login_attempts'),
|
||||
sessions: days('sessions'),
|
||||
fingerprint: days('fingerprint')
|
||||
});
|
||||
|
||||
const RUN_INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
|
||||
export const runRetention = async () => {
|
||||
const r = getRetention();
|
||||
const nowSecs = ~~(Date.now() / 1e3);
|
||||
const before = (d) => new Date(Date.now() - d * 86400e3);
|
||||
const counts = {};
|
||||
const step = async (name, fn) => {
|
||||
try {
|
||||
const res = await fn();
|
||||
if (res?.count) counts[name] = res.count;
|
||||
} catch (e) {
|
||||
console.error(`[RETENTION] ${name} failed:`, e.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (r.ip) {
|
||||
const cutoff = before(r.ip);
|
||||
const cutoffSecs = nowSecs - r.ip * 86400;
|
||||
await step('user_ips', () => db`delete from user_ips where last_seen < ${cutoff}`);
|
||||
await step('sessions.ip', () => db`update user_sessions set ip = null where ip is not null and last_used < ${cutoffSecs}`);
|
||||
await step('anon.created_ip', () => db`update anon_identities set created_ip = null where created_ip is not null and created_at < ${cutoff}`);
|
||||
await step('anon.last_ip', () => db`update anon_identities set last_ip = null where last_ip is not null and last_seen < ${cutoff}`);
|
||||
await step('items.uploader_ip', () => db`update items set uploader_ip = null where uploader_ip is not null and stamp < ${cutoffSecs}`);
|
||||
await step('comments.ip', () => db`update comments set ip = null where ip is not null and created_at < ${cutoff}`);
|
||||
await step('reports.reporter_ip', () => db`update reports set reporter_ip = null where reporter_ip is not null and created_at < ${cutoff}`);
|
||||
await step('tos.accepted_ip', () => db`update user_tos_acceptance set accepted_ip = null where accepted_ip is not null and accepted_at < ${cutoff}`);
|
||||
}
|
||||
|
||||
if (r.activity_log) {
|
||||
await step('anon_activity_log', () => db`delete from anon_activity_log where created_at < ${before(r.activity_log)}`);
|
||||
}
|
||||
|
||||
if (r.login_attempts) {
|
||||
await step('login_attempts', () => db`delete from login_attempts where attempted_at < ${before(r.login_attempts)}`);
|
||||
}
|
||||
|
||||
if (r.sessions) {
|
||||
await step('user_sessions', () => db`delete from user_sessions where last_used < ${nowSecs - r.sessions * 86400}`);
|
||||
}
|
||||
|
||||
if (!getHwFingerprintEnabled()) {
|
||||
// Fingerprinting switched off: don't keep any fingerprints collected while it was on
|
||||
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null`);
|
||||
await step('activity.hw_fingerprint', () => db`update anon_activity_log set hw_fingerprint = null where hw_fingerprint is not null`);
|
||||
} else if (r.fingerprint) {
|
||||
await step('anon.hw_fingerprint', () => db`update anon_identities set hw_fingerprint = null where hw_fingerprint is not null and last_seen < ${before(r.fingerprint)}`);
|
||||
}
|
||||
|
||||
const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(', ');
|
||||
if (summary) console.log(`[RETENTION] Cleaned: ${summary}`);
|
||||
};
|
||||
|
||||
export const startRetention = () => {
|
||||
const r = getRetention();
|
||||
const fmt = (d) => d ? `${d}d` : 'forever';
|
||||
console.log(`[BOOT] Data retention: IPs ${fmt(r.ip)}, activity log ${fmt(r.activity_log)}, login attempts ${fmt(r.login_attempts)}, sessions ${fmt(r.sessions)}, device fingerprints ${getHwFingerprintEnabled() ? fmt(r.fingerprint) : 'disabled (purged)'}`);
|
||||
setTimeout(runRetention, 30_000);
|
||||
setInterval(runRetention, RUN_INTERVAL_MS);
|
||||
};
|
||||
+328
-2248
File diff suppressed because it is too large
Load Diff
+83
-733
@@ -12,8 +12,7 @@ import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
import crypto from "crypto";
|
||||
import path from "path";
|
||||
import YAML from "yaml";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs, getBrandImageUrl } from "../settings.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.get(/^\/login(\/)?$/, async (req, res) => {
|
||||
@@ -78,15 +77,7 @@ export default (router, tpl) => {
|
||||
} else {
|
||||
const reason = user[0].ban_reason || 'none';
|
||||
const expires = user[0].ban_expires ? new Date(user[0].ban_expires).toISOString().replace('T', ' ').substring(0, 16) : 'never';
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: false,
|
||||
banned: true,
|
||||
msg: `You are banned! reason: ${reason} expire: ${expires}`,
|
||||
redirect: '/banned'
|
||||
}));
|
||||
}
|
||||
return res.writeHead(302, { Location: '/banned' }).end();
|
||||
return fail(`You are banned! reason: ${reason} expire: ${expires}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +104,7 @@ export default (router, tpl) => {
|
||||
last_used: stamp,
|
||||
last_action: "/login",
|
||||
kmsi: typeof req.post.kmsi !== 'undefined' ? 1 : 0,
|
||||
ip: security.storableIP(ip)
|
||||
ip: ip
|
||||
};
|
||||
|
||||
await db`
|
||||
@@ -168,7 +159,7 @@ export default (router, tpl) => {
|
||||
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
|
||||
}
|
||||
|
||||
const user = (await db`select id, login, password from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0];
|
||||
const user = (await db`select id, login from "user" where lower(email) = lower(${email.trim()}) limit 1`)[0];
|
||||
const targetIdentity = user ? user.login : email;
|
||||
|
||||
// 2. Identity-based check
|
||||
@@ -182,8 +173,7 @@ export default (router, tpl) => {
|
||||
// but the user wants "maximum tries per day is 1", so we record it regardless of email existence)
|
||||
await security.recordAttempt(ip, targetIdentity, 'password_reset_request', true);
|
||||
|
||||
// Passkey-only accounts (password '!') can't get a password by mail: same neutral answer, no mail
|
||||
if (!user || user.password === '!') {
|
||||
if (!user) {
|
||||
const msg = "If an account with that email exists, we have sent a reset link.";
|
||||
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
|
||||
return res.reply({ body: tpl.render("forgot-password", { success: msg }) });
|
||||
@@ -260,7 +250,7 @@ export default (router, tpl) => {
|
||||
return res.reply({ code: 429, body: tpl.render("forgot-password", { error: msg }) });
|
||||
}
|
||||
|
||||
const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() and password <> '!' limit 1`)[0];
|
||||
const user = (await db`select id from "user" where reset_token = ${token} and reset_expires > now() limit 1`)[0];
|
||||
if (!user) {
|
||||
const msg = "Invalid or expired reset token.";
|
||||
if (isAJAX) return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
||||
@@ -287,29 +277,8 @@ export default (router, tpl) => {
|
||||
|
||||
router.get(/^\/admin(\/)?$/, lib.auth, async (req, res) => { // frontpage
|
||||
|
||||
// Dashboard counters (cheap aggregate queries; failures just show 0)
|
||||
const dash = { open_reports: 0, users: 0, anon_users: 0, trash: 0, invite_requests: 0 };
|
||||
try {
|
||||
const [[r], [u], [t], [ir]] = await Promise.all([
|
||||
db`SELECT count(*)::int AS n FROM reports WHERE status = 'pending'`,
|
||||
db`SELECT count(*) FILTER (WHERE NOT EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS users,
|
||||
count(*) FILTER (WHERE EXISTS (SELECT 1 FROM anon_identities ai WHERE ai.user_id = "user".id))::int AS anon
|
||||
FROM "user"`,
|
||||
db`SELECT count(*)::int AS n FROM items WHERE active = false AND is_deleted = true AND is_purged = false`,
|
||||
db`SELECT count(*)::int AS n FROM invite_requests WHERE status = 'pending'`
|
||||
]);
|
||||
dash.trash = t?.n || 0;
|
||||
dash.invite_requests = ir?.n || 0;
|
||||
dash.open_reports = r?.n || 0;
|
||||
dash.users = u?.users || 0;
|
||||
dash.anon_users = u?.anon || 0;
|
||||
} catch (e) {
|
||||
console.error('[ADMIN] dashboard counters failed:', e.message);
|
||||
}
|
||||
|
||||
res.reply({
|
||||
body: tpl.render("admin", {
|
||||
dash,
|
||||
totals: await lib.countf0cks(),
|
||||
session: req.session,
|
||||
manual_approval: getManualApproval(),
|
||||
@@ -322,7 +291,6 @@ export default (router, tpl) => {
|
||||
enable_cleanup: getEnableCleanup(),
|
||||
shitpost_mode: getShitpostMode(),
|
||||
enable_cleanup_config: cfg.websrv.enable_cleanup !== false,
|
||||
current_brand_image: getBrandImageUrl(),
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
@@ -355,208 +323,6 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/admin\/bans(\/)?$/, lib.modAuth, async (req, res) => {
|
||||
const bannedFingerprints = await db`
|
||||
select bf.*,
|
||||
u.login as anon_login, u.user as anon_user,
|
||||
admin.user as banned_by_user
|
||||
from banned_fingerprints bf
|
||||
left join anon_identities ai on ai.fingerprint = bf.fingerprint
|
||||
left join "user" u on u.id = ai.user_id
|
||||
left join "user" admin on admin.id = bf.banned_by
|
||||
order by bf.created_at desc
|
||||
`;
|
||||
|
||||
const bannedHardware = await db`
|
||||
select bh.*,
|
||||
admin.user as banned_by_user
|
||||
from banned_hardware_fingerprints bh
|
||||
left join "user" admin on admin.id = bh.banned_by
|
||||
order by bh.created_at desc
|
||||
`;
|
||||
|
||||
const bannedIps = await db`
|
||||
select bi.*,
|
||||
admin.user as banned_by_user
|
||||
from banned_ips bi
|
||||
left join "user" admin on admin.id = bi.banned_by
|
||||
order by bi.created_at desc
|
||||
`;
|
||||
|
||||
const anonIdentities = await db`
|
||||
select ai.*,
|
||||
u.id as user_id, u.login, u.user, u.banned as is_banned, u.ban_reason,
|
||||
bf.id as is_fp_banned,
|
||||
bh.id as is_hw_banned
|
||||
from anon_identities ai
|
||||
left join "user" u on u.id = ai.user_id
|
||||
left join banned_fingerprints bf on bf.fingerprint = ai.fingerprint
|
||||
left join banned_hardware_fingerprints bh on bh.hw_fingerprint = ai.hw_fingerprint
|
||||
order by ai.last_seen desc
|
||||
limit 100
|
||||
`;
|
||||
|
||||
const recentActivity = await db`
|
||||
select al.*,
|
||||
u.login, u.user
|
||||
from anon_activity_log al
|
||||
left join "user" u on u.id = al.user_id
|
||||
order by al.created_at desc
|
||||
limit 100
|
||||
`;
|
||||
|
||||
res.reply({
|
||||
body: tpl.render("admin/bans", {
|
||||
session: req.session,
|
||||
csrf_token: req.session ? req.session.csrf_token : '',
|
||||
bannedFingerprints,
|
||||
bannedHardware,
|
||||
bannedIps,
|
||||
anonIdentities,
|
||||
recentActivity,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/ban\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { fingerprint, reason, duration, ban_ips, user_id } = req.post || {};
|
||||
if (!fingerprint) throw new Error('Missing fingerprint');
|
||||
|
||||
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
|
||||
|
||||
const result = await security.banAnonymousUser({
|
||||
userId: user_id ? +user_id : null,
|
||||
fingerprint,
|
||||
bannedBy: req.session.id,
|
||||
reason: reason || 'Banned by moderator',
|
||||
expires,
|
||||
banIps: ban_ips !== false
|
||||
});
|
||||
|
||||
await audit.log(req.session.id, 'ban_fingerprint', 'fingerprint', null, { fingerprint, reason, duration });
|
||||
|
||||
return res.json({ success: true, result });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/bans\/fingerprint\/unban\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { fingerprint } = req.post || {};
|
||||
if (!fingerprint) throw new Error('Missing fingerprint');
|
||||
|
||||
await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${fingerprint}`;
|
||||
|
||||
// Also unban any shadow user associated with this fingerprint
|
||||
const ident = await db`SELECT user_id FROM anon_identities WHERE fingerprint = ${fingerprint}`;
|
||||
if (ident.length > 0) {
|
||||
await db`UPDATE "user" SET banned = false, ban_reason = null, ban_expires = null WHERE id = ${ident[0].user_id}`;
|
||||
}
|
||||
|
||||
await audit.log(req.session.id, 'unban_fingerprint', 'fingerprint', null, { fingerprint });
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/bans\/ip\/ban\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { ip, reason, duration } = req.post || {};
|
||||
if (!ip) throw new Error('Missing IP address');
|
||||
|
||||
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
|
||||
// Already a stored hash (banned from the IP list) or a raw address typed by the moderator
|
||||
const isHash = /^[a-f0-9]{64}$/i.test(ip);
|
||||
const ipHash = isHash ? ip.toLowerCase() : security.hashIP(ip);
|
||||
// With hashing on, the raw address is never persisted, not even in the ban list
|
||||
const ipStored = (isHash || cfg.websrv.hash_user_ips) ? ipHash : ip;
|
||||
|
||||
await db`
|
||||
INSERT INTO banned_ips (ip, ip_hash, banned_by, reason, expires_at)
|
||||
VALUES (${ipStored}, ${ipHash}, ${req.session.id}, ${reason || 'Banned by moderator'}, ${expires})
|
||||
ON CONFLICT (ip) DO UPDATE
|
||||
SET reason = EXCLUDED.reason,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
banned_by = EXCLUDED.banned_by,
|
||||
ip_hash = EXCLUDED.ip_hash
|
||||
`;
|
||||
|
||||
await audit.log(req.session.id, 'ban_ip', 'ip', null, { ip, reason, duration });
|
||||
|
||||
// Broadcast ban event via SSE
|
||||
await db.notify('bans', JSON.stringify({
|
||||
ip,
|
||||
ipHash,
|
||||
reason: (reason || 'Banned by moderator').substring(0, 300),
|
||||
expires
|
||||
})).catch(() => {});
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/bans\/ip\/unban\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { ip } = req.post || {};
|
||||
if (!ip) throw new Error('Missing IP');
|
||||
|
||||
await db`DELETE FROM banned_ips WHERE ip = ${ip} OR ip_hash = ${ip}`;
|
||||
|
||||
await audit.log(req.session.id, 'unban_ip', 'ip', null, { ip });
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/bans\/hardware\/ban\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { hw_fingerprint, reason, duration, ban_ips, user_id } = req.post || {};
|
||||
if (!hw_fingerprint) throw new Error('Missing hardware fingerprint');
|
||||
|
||||
const expires = duration === 'permanent' || !duration ? null : new Date(Date.now() + parseInt(duration, 10) * 3600000);
|
||||
|
||||
const result = await security.banAnonymousUser({
|
||||
userId: user_id ? +user_id : null,
|
||||
hwFingerprint: hw_fingerprint,
|
||||
bannedBy: req.session.id,
|
||||
reason: reason || 'Banned by moderator',
|
||||
expires,
|
||||
banIps: ban_ips !== false,
|
||||
banHardware: true
|
||||
});
|
||||
|
||||
await audit.log(req.session.id, 'ban_hardware', 'hardware', null, { hw_fingerprint, reason, duration });
|
||||
|
||||
return res.json({ success: true, result });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/bans\/hardware\/unban\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { hw_fingerprint } = req.post || {};
|
||||
if (!hw_fingerprint) throw new Error('Missing hardware fingerprint');
|
||||
|
||||
await db`DELETE FROM banned_hardware_fingerprints WHERE hw_fingerprint = ${hw_fingerprint}`;
|
||||
|
||||
await audit.log(req.session.id, 'unban_hardware', 'hardware', null, { hw_fingerprint });
|
||||
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
return res.json({ success: false, msg: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
|
||||
const userId = +req.params.userId;
|
||||
const user = await db`select "user", login from "user" where id = ${userId} limit 1`;
|
||||
@@ -672,154 +438,11 @@ export default (router, tpl) => {
|
||||
if (res.json) return res.json({ success: false });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false }));
|
||||
}
|
||||
|
||||
// Revoke any linked invite request and notify the user
|
||||
const [linkedRequest] = await db`
|
||||
SELECT ir.id, ir.user_id FROM invite_requests ir
|
||||
WHERE ir.token_id = ${req.post.id} AND ir.status = 'approved'
|
||||
`;
|
||||
if (linkedRequest) {
|
||||
await db`
|
||||
UPDATE invite_requests
|
||||
SET status = 'revoked', reviewed_at = NOW(), reviewed_by = ${req.session.id}
|
||||
WHERE id = ${linkedRequest.id}
|
||||
`;
|
||||
if (linkedRequest.user_id) {
|
||||
await db`
|
||||
INSERT INTO notifications (user_id, type, reference_id, data)
|
||||
VALUES (${linkedRequest.user_id}, 'invite_denied', 0, ${db.json({ revoked: true })})
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await db`delete from invite_tokens where id = ${req.post.id}`;
|
||||
if (res.json) return res.json({ success: true });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
|
||||
});
|
||||
|
||||
// ═══════════════════ Invite Requests (admin) ═══════════════════
|
||||
|
||||
// List all invite requests
|
||||
router.get(/^\/api\/v2\/admin\/invite-requests\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const requests = await db`
|
||||
SELECT ir.*,
|
||||
u_reviewer.user as reviewed_by_name,
|
||||
u_requester.login as requester_login,
|
||||
u_requester.user as requester_name
|
||||
FROM invite_requests ir
|
||||
LEFT JOIN "user" u_reviewer ON u_reviewer.id = ir.reviewed_by
|
||||
LEFT JOIN "user" u_requester ON u_requester.id = ir.user_id
|
||||
ORDER BY
|
||||
CASE WHEN ir.status = 'pending' THEN 0 ELSE 1 END,
|
||||
ir.created_at DESC
|
||||
LIMIT 200
|
||||
`;
|
||||
if (res.json) return res.json({ success: true, requests });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, requests }));
|
||||
} catch (err) {
|
||||
const msg = lib.logError(err, 'Failed to fetch invite requests');
|
||||
if (res.json) return res.json({ success: false, msg });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
||||
}
|
||||
});
|
||||
|
||||
// Approve an invite request — generates a token and notifies the anon user
|
||||
router.post(/^\/api\/v2\/admin\/invite-requests\/approve\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.post;
|
||||
if (!id) {
|
||||
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
|
||||
}
|
||||
|
||||
// Check request exists and is pending
|
||||
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
|
||||
if (!request) {
|
||||
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
|
||||
}
|
||||
|
||||
// Generate invite token
|
||||
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
|
||||
const [tokenRow] = await db`
|
||||
INSERT INTO invite_tokens (token, created_at, created_by)
|
||||
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${req.session.id})
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// Update request status
|
||||
await db`
|
||||
UPDATE invite_requests
|
||||
SET status = 'approved', token_id = ${tokenRow.id}, reviewed_by = ${req.session.id}, reviewed_at = NOW()
|
||||
WHERE id = ${+id}
|
||||
`;
|
||||
|
||||
// Find the anon user's real user_id via their fingerprint, and send them a notification
|
||||
const anonUser = await db`
|
||||
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
|
||||
`;
|
||||
if (anonUser.length > 0) {
|
||||
await db`
|
||||
INSERT INTO notifications (user_id, type, reference_id, data)
|
||||
VALUES (${anonUser[0].user_id}, 'invite_approved', 0, ${db.json({ token, request_id: +id })})
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`[INVITE-REQ] Approved request #${id} (fp: ${request.fingerprint.slice(0, 12)}…) → token ${token.slice(0, 8)}…`);
|
||||
|
||||
if (res.json) return res.json({ success: true, token });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, token }));
|
||||
} catch (err) {
|
||||
const msg = lib.logError(err, 'Failed to approve invite request');
|
||||
if (res.json) return res.json({ success: false, msg });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
||||
}
|
||||
});
|
||||
|
||||
// Deny an invite request
|
||||
router.post(/^\/api\/v2\/admin\/invite-requests\/deny\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.post;
|
||||
if (!id) {
|
||||
if (res.json) return res.json({ success: false, msg: 'Missing request id' });
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Missing request id' }));
|
||||
}
|
||||
|
||||
const [request] = await db`SELECT * FROM invite_requests WHERE id = ${+id} AND status = 'pending'`;
|
||||
if (!request) {
|
||||
if (res.json) return res.json({ success: false, msg: 'Request not found or already processed' });
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Request not found or already processed' }));
|
||||
}
|
||||
|
||||
await db`
|
||||
UPDATE invite_requests
|
||||
SET status = 'denied', reviewed_by = ${req.session.id}, reviewed_at = NOW()
|
||||
WHERE id = ${+id}
|
||||
`;
|
||||
|
||||
// Notify the anon user their request was denied
|
||||
const anonUser = await db`
|
||||
SELECT user_id FROM anon_identities WHERE fingerprint = ${request.fingerprint} LIMIT 1
|
||||
`;
|
||||
if (anonUser.length > 0) {
|
||||
await db`
|
||||
INSERT INTO notifications (user_id, type, reference_id, data)
|
||||
VALUES (${anonUser[0].user_id}, 'invite_denied', 0, ${db.json({ request_id: +id })})
|
||||
`;
|
||||
}
|
||||
|
||||
console.log(`[INVITE-REQ] Denied request #${id} (fp: ${request.fingerprint.slice(0, 12)}…)`);
|
||||
|
||||
if (res.json) return res.json({ success: true });
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true }));
|
||||
} catch (err) {
|
||||
const msg = lib.logError(err, 'Failed to deny invite request');
|
||||
if (res.json) return res.json({ success: false, msg });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/ban\/?$/, lib.modAuth, async (req, res) => {
|
||||
try {
|
||||
const { user_id, reason, duration } = req.post;
|
||||
@@ -858,29 +481,6 @@ export default (router, tpl) => {
|
||||
where id = ${+user_id}
|
||||
`;
|
||||
|
||||
// If this is an anonymous identity, cascade the ban to fingerprint and IPs
|
||||
const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`;
|
||||
if (anonIdent.length > 0 || (targetUser[0].login && targetUser[0].login.startsWith('anon_'))) {
|
||||
await security.banAnonymousUser({
|
||||
userId: +user_id,
|
||||
fingerprint: anonIdent[0]?.fingerprint || null,
|
||||
bannedBy: req.session.id,
|
||||
reason,
|
||||
expires,
|
||||
banIps: true
|
||||
});
|
||||
} else {
|
||||
// Broadcast ban to registered user's active SSE sessions
|
||||
const userIps = await db`SELECT distinct ip FROM user_ips WHERE user_id = ${+user_id}`;
|
||||
const ips = userIps.map(r => r.ip).filter(Boolean);
|
||||
await db.notify('bans', JSON.stringify({
|
||||
userId: +user_id,
|
||||
reason: (reason || 'Violation of community rules').substring(0, 300),
|
||||
expires,
|
||||
ips
|
||||
})).catch(() => {});
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
await audit.log(req.session.id, 'ban_user', 'user', +user_id, { reason, duration, target_user: targetUser[0].user });
|
||||
|
||||
@@ -916,12 +516,6 @@ export default (router, tpl) => {
|
||||
where id = ${+user_id}
|
||||
`;
|
||||
|
||||
// Clean up any banned fingerprint for this identity
|
||||
const anonIdent = await db`SELECT fingerprint FROM anon_identities WHERE user_id = ${+user_id} LIMIT 1`;
|
||||
if (anonIdent.length > 0) {
|
||||
await db`DELETE FROM banned_fingerprints WHERE fingerprint = ${anonIdent[0].fingerprint}`;
|
||||
}
|
||||
|
||||
// Log it in audit
|
||||
await audit.log(req.session.id, 'unban_user', 'user', +user_id);
|
||||
|
||||
@@ -1109,15 +703,6 @@ export default (router, tpl) => {
|
||||
// Write formatted JSON to config.json on disk
|
||||
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
|
||||
|
||||
// Also update config.yaml if it exists
|
||||
const configYamlPath = path.resolve(process.cwd(), "config.yaml");
|
||||
try {
|
||||
const yamlDoc = new YAML.Document(updatedConfig);
|
||||
await fs.writeFile(configYamlPath, String(yamlDoc), "utf-8");
|
||||
} catch (yamlErr) {
|
||||
console.warn('[ADMIN] Could not sync config.yaml:', yamlErr.message);
|
||||
}
|
||||
|
||||
// Mutate in-memory cfg object so changes apply immediately
|
||||
Object.assign(cfg, updatedConfig);
|
||||
|
||||
@@ -1315,179 +900,87 @@ export default (router, tpl) => {
|
||||
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
const rawStatus = (req.url.qs?.status || req.url.qs?.filter || '').toLowerCase().trim();
|
||||
const rawRole = (req.url.qs?.role || '').toLowerCase().trim();
|
||||
|
||||
const onlyLegacy = req.url.qs?.legacy === '1' || req.url.qs?.legacy === 'true' ||
|
||||
req.url.qs?.legacy_only === '1' || req.url.qs?.legacy_only === 'true' ||
|
||||
req.url.qs?.only_legacy === '1' || req.url.qs?.only_legacy === 'true' ||
|
||||
rawStatus === 'legacy';
|
||||
|
||||
const status = onlyLegacy ? '' : rawStatus;
|
||||
const role = onlyLegacy ? '' : rawRole;
|
||||
|
||||
let users;
|
||||
let total;
|
||||
|
||||
if (onlyLegacy) {
|
||||
users = await db`
|
||||
WITH ghost_users AS (
|
||||
SELECT
|
||||
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
|
||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, ARRAY[]::text[] as groups, true as activated,
|
||||
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
||||
FROM items i
|
||||
WHERE i.username IS NOT NULL AND i.username != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||
${q ? (exactMatch
|
||||
? db`AND lower(i.username) = lower(${q})`
|
||||
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
||||
) : db``}
|
||||
GROUP BY i.username
|
||||
),
|
||||
paginated_users AS (
|
||||
SELECT * FROM ghost_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
)
|
||||
SELECT
|
||||
pu.*,
|
||||
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
|
||||
COALESCE(ic.upload_count, 0) as upload_count,
|
||||
0::bigint as comment_count,
|
||||
0::bigint as failed_attempts
|
||||
FROM paginated_users pu
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as upload_count
|
||||
FROM items
|
||||
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
|
||||
) ic ON true
|
||||
`;
|
||||
|
||||
const totalCountGhost = await db`
|
||||
SELECT COUNT(DISTINCT i.username) as c
|
||||
const users = await db`
|
||||
WITH filtered_users AS (
|
||||
SELECT
|
||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.activated,
|
||||
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
|
||||
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
${q ? (exactMatch
|
||||
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
|
||||
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
|
||||
) : db``}
|
||||
),
|
||||
ghost_users AS (
|
||||
SELECT
|
||||
NULL::int as id, i.username as login, i.username as "user", 'Legacy Account' as email,
|
||||
to_timestamp(MIN(i.stamp)) as created_at, false as banned, false as is_moderator, false as admin, true as activated,
|
||||
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
|
||||
FROM items i
|
||||
WHERE i.username IS NOT NULL AND i.username != ''
|
||||
AND NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||
${q ? (exactMatch
|
||||
? db`AND lower(i.username) = lower(${q})`
|
||||
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
||||
) : db``}
|
||||
`;
|
||||
total = parseInt(totalCountGhost[0].c);
|
||||
} else {
|
||||
let qCond = null;
|
||||
if (q) {
|
||||
if (exactMatch) {
|
||||
qCond = db`(lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q}))`;
|
||||
} else {
|
||||
const pattern = '%' + lib.escapeLike(q) + '%';
|
||||
qCond = db`(u.login ILIKE ${pattern} OR u.user ILIKE ${pattern} OR u.email ILIKE ${pattern})`;
|
||||
}
|
||||
}
|
||||
GROUP BY i.username
|
||||
),
|
||||
all_users AS (
|
||||
SELECT * FROM filtered_users
|
||||
UNION ALL
|
||||
SELECT * FROM ghost_users
|
||||
),
|
||||
paginated_users AS (
|
||||
SELECT * FROM all_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
)
|
||||
SELECT
|
||||
pu.*,
|
||||
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
|
||||
COALESCE(ic.upload_count, 0) as upload_count,
|
||||
COALESCE(cc.comment_count, 0) as comment_count,
|
||||
COALESCE(la.failed_attempts, 0) as failed_attempts
|
||||
FROM paginated_users pu
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as upload_count
|
||||
FROM items
|
||||
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
|
||||
) ic ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as comment_count
|
||||
FROM comments
|
||||
WHERE user_id = pu.id AND is_deleted = false
|
||||
) cc ON pu.id IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as failed_attempts
|
||||
FROM login_attempts
|
||||
WHERE username = pu.login
|
||||
AND success = false
|
||||
AND type = 'login'
|
||||
AND attempted_at > now() - interval '10 hours'
|
||||
) la ON true
|
||||
`;
|
||||
|
||||
let statusCond = null;
|
||||
if (status === 'banned') {
|
||||
statusCond = db`u.banned = true`;
|
||||
} else if (status === 'active') {
|
||||
statusCond = db`(u.activated = true AND u.banned = false)`;
|
||||
} else if (status === 'pending') {
|
||||
statusCond = db`(u.activated = false AND u.banned = false)`;
|
||||
}
|
||||
|
||||
let roleCond = null;
|
||||
if (role === 'staff' || status === 'staff') {
|
||||
roleCond = db`(u.admin = true OR u.is_moderator = true)`;
|
||||
} else if (role === 'admin') {
|
||||
roleCond = db`u.admin = true`;
|
||||
} else if (role === 'mod') {
|
||||
roleCond = db`(u.is_moderator = true AND u.admin = false)`;
|
||||
} else if (role === 'user') {
|
||||
roleCond = db`(u.admin = false AND u.is_moderator = false)`;
|
||||
}
|
||||
|
||||
users = await db`
|
||||
WITH filtered_users AS (
|
||||
SELECT
|
||||
u.id, u.login, u.user, u.email, u.created_at, u.banned, u.is_moderator, u.admin, u.groups, u.activated,
|
||||
uo.avatar_file, uo.display_name, uo.force_comment_display_mode, uo.comment_display_mode,
|
||||
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
|
||||
FROM "user" u
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE true
|
||||
${qCond ? db`AND ${qCond}` : db``}
|
||||
${statusCond ? db`AND ${statusCond}` : db``}
|
||||
${roleCond ? db`AND ${roleCond}` : db``}
|
||||
),
|
||||
paginated_users AS (
|
||||
SELECT * FROM filtered_users
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
)
|
||||
SELECT
|
||||
pu.*,
|
||||
EXTRACT(DAY FROM (now() - pu.created_at)) as age_days,
|
||||
COALESCE(ic.upload_count, 0) as upload_count,
|
||||
COALESCE(cc.comment_count, 0) as comment_count,
|
||||
COALESCE(la.failed_attempts, 0) as failed_attempts
|
||||
FROM paginated_users pu
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as upload_count
|
||||
FROM items
|
||||
WHERE (username = pu.login OR username = pu.user) AND is_deleted = false
|
||||
) ic ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as comment_count
|
||||
FROM comments
|
||||
WHERE user_id = pu.id AND is_deleted = false
|
||||
) cc ON pu.id IS NOT NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*) as failed_attempts
|
||||
FROM login_attempts
|
||||
WHERE username = pu.login
|
||||
AND success = false
|
||||
AND type = 'login'
|
||||
AND attempted_at > now() - interval '10 hours'
|
||||
) la ON true
|
||||
`;
|
||||
|
||||
const totalCountActual = await db`
|
||||
SELECT COUNT(*) as c FROM "user" u
|
||||
WHERE true
|
||||
${qCond ? db`AND ${qCond}` : db``}
|
||||
${statusCond ? db`AND ${statusCond}` : db``}
|
||||
${roleCond ? db`AND ${roleCond}` : db``}
|
||||
`;
|
||||
total = parseInt(totalCountActual[0].c);
|
||||
}
|
||||
|
||||
let totalLabel = 'registered members';
|
||||
let emptyMsg = 'No users matched your search.';
|
||||
if (onlyLegacy) {
|
||||
totalLabel = 'legacy accounts';
|
||||
emptyMsg = 'No legacy users matched your search.';
|
||||
} else if (status === 'banned') {
|
||||
totalLabel = 'banned members';
|
||||
emptyMsg = 'No banned users found.';
|
||||
} else if (status === 'pending') {
|
||||
totalLabel = 'pending members';
|
||||
emptyMsg = 'No pending users found.';
|
||||
} else if (status === 'active') {
|
||||
totalLabel = 'active members';
|
||||
emptyMsg = 'No active users found.';
|
||||
} else if (role === 'staff' || status === 'staff') {
|
||||
totalLabel = 'staff members';
|
||||
emptyMsg = 'No staff members found.';
|
||||
} else if (role === 'admin') {
|
||||
totalLabel = 'admin members';
|
||||
emptyMsg = 'No admin users found.';
|
||||
} else if (role === 'mod') {
|
||||
totalLabel = 'moderator members';
|
||||
emptyMsg = 'No moderator users found.';
|
||||
} else if (role === 'user') {
|
||||
totalLabel = 'regular users';
|
||||
emptyMsg = 'No regular users found.';
|
||||
}
|
||||
const totalCountActual = await db`
|
||||
SELECT COUNT(*) as c FROM "user" u
|
||||
${q ? (exactMatch
|
||||
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
|
||||
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
|
||||
) : db``}
|
||||
`;
|
||||
const totalCountGhost = await db`
|
||||
SELECT COUNT(DISTINCT i.username) as c
|
||||
FROM items i
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
|
||||
${q ? (exactMatch
|
||||
? db`AND lower(i.username) = lower(${q})`
|
||||
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
|
||||
) : db``}
|
||||
`;
|
||||
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
|
||||
|
||||
const data = {
|
||||
session: req.session,
|
||||
@@ -1496,11 +989,6 @@ export default (router, tpl) => {
|
||||
page,
|
||||
total,
|
||||
hasMore: users.length === limit,
|
||||
onlyLegacy,
|
||||
status,
|
||||
role,
|
||||
totalLabel,
|
||||
emptyMsg,
|
||||
totals: await lib.countf0cks(),
|
||||
log_user_ips: getLogUserIps(),
|
||||
tmp: null
|
||||
@@ -1508,8 +996,6 @@ export default (router, tpl) => {
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
res.setHeader('X-Total-Count', total.toString());
|
||||
res.setHeader('X-Total-Label', totalLabel);
|
||||
res.setHeader('X-Empty-Msg', emptyMsg);
|
||||
res.setHeader('X-Has-More', (users.length === limit).toString());
|
||||
return res.reply({
|
||||
body: tpl.render("admin/users_list", data, req)
|
||||
@@ -1615,45 +1101,6 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/set-groups\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id, groups } = req.post;
|
||||
if (!user_id) throw new Error('Missing user_id');
|
||||
const target = await db`SELECT id, login FROM "user" WHERE id = ${+user_id} LIMIT 1`;
|
||||
if (!target.length) throw new Error('User not found.');
|
||||
|
||||
let groupsArr = [];
|
||||
if (Array.isArray(groups)) {
|
||||
groupsArr = groups.map(s => String(s).trim().toLowerCase()).filter(Boolean);
|
||||
} else if (typeof groups === 'string') {
|
||||
groupsArr = groups.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
|
||||
}
|
||||
|
||||
await db`
|
||||
UPDATE "user"
|
||||
SET groups = ${groupsArr}
|
||||
WHERE id = ${+user_id}
|
||||
`;
|
||||
|
||||
// Invalidate target user's session cache and session table so new groups load immediately
|
||||
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
|
||||
|
||||
await audit.log(req.session.id, 'admin_set_groups', 'user', +user_id, {
|
||||
target_login: target[0].login,
|
||||
groups: groupsArr
|
||||
});
|
||||
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
|
||||
success: true,
|
||||
groups: groupsArr,
|
||||
msg: `Groups for "${target[0].login}" updated to: ${groupsArr.join(', ') || 'none'}`
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[ADMIN] Set groups failed:', err);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
@@ -1690,26 +1137,6 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
// The system ghost that deleted users' content is reassigned to. It can never log in: password '!'
|
||||
// matches no hash, it is not activated and it is banned (the unban route refuses to touch it).
|
||||
const ensureGhostUser = async () => {
|
||||
let ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
|
||||
if (ghost.length) return ghost;
|
||||
await db`
|
||||
INSERT INTO "user" (login, "user", password, admin, is_moderator, activated, banned, ban_reason, created_at)
|
||||
VALUES ('deleted_user', 'deleted_user', '!', false, false, false, true, 'System account', now())
|
||||
ON CONFLICT (login) DO NOTHING
|
||||
`;
|
||||
ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
|
||||
if (!ghost.length) throw new Error('Could not create the "deleted_user" ghost account.');
|
||||
await db`
|
||||
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, display_name)
|
||||
VALUES (${ghost[0].id}, 0, 'amoled', 0, null, 'default.png', 'deleted user')
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
`;
|
||||
return ghost;
|
||||
};
|
||||
|
||||
router.post(/^\/api\/v2\/admin\/users\/delete\/?$/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
const { user_id } = req.post;
|
||||
@@ -1720,8 +1147,9 @@ export default (router, tpl) => {
|
||||
if (!target.length) throw new Error('User not found');
|
||||
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be deleted.');
|
||||
|
||||
// Get deleted_user info (created on first use; see migrations/add_deleted_user_ghost.sql)
|
||||
const ghost = await ensureGhostUser();
|
||||
// Get deleted_user info
|
||||
const ghost = await db`SELECT id FROM "user" WHERE login = 'deleted_user' LIMIT 1`;
|
||||
if (!ghost.length) throw new Error('Ghost account "deleted_user" not found. Please run migration.');
|
||||
|
||||
const targetId = target[0].id;
|
||||
const targetLogin = target[0].login;
|
||||
@@ -2327,83 +1755,5 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── Admin Bar: User Impersonation ────────────────────────────────────────────
|
||||
|
||||
// GET /api/v2/admin/users/search?q= — autocomplete for the admin bar "View as" input
|
||||
router.get(/^\/api\/v2\/admin\/users\/search\/?$/, lib.adminAuth, async (req, res) => {
|
||||
try {
|
||||
const q = (req.url.qs?.q || '').trim();
|
||||
if (!q || q.length < 1) {
|
||||
if (res.json) return res.json([]);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end('[]');
|
||||
}
|
||||
const escaped = lib.escapeLike(q);
|
||||
const users = await db`
|
||||
SELECT id, login as user
|
||||
FROM "user"
|
||||
WHERE login ILIKE ${'%' + escaped + '%'}
|
||||
AND activated = true
|
||||
AND banned = false
|
||||
ORDER BY login ASC
|
||||
LIMIT 10
|
||||
`;
|
||||
const result = users.map(u => ({ id: u.id, user: u.user }));
|
||||
if (res.json) return res.json(result);
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(result));
|
||||
} catch (e) {
|
||||
if (res.json) return res.json({ success: false, msg: e.message });
|
||||
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: e.message }));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v2/admin/impersonate — start impersonating a user
|
||||
router.post(/^\/api\/v2\/admin\/impersonate\/?$/, lib.adminAuth, async (req, res) => {
|
||||
try {
|
||||
const { username } = req.post;
|
||||
if (!username) throw new Error('Username required');
|
||||
|
||||
const target = await db`
|
||||
SELECT id, login as user
|
||||
FROM "user"
|
||||
WHERE login = ${username.toLowerCase().trim()}
|
||||
AND activated = true
|
||||
LIMIT 1
|
||||
`;
|
||||
if (target.length === 0) throw new Error('User not found');
|
||||
if (target[0].id === req.session.id) throw new Error('Cannot impersonate yourself');
|
||||
|
||||
// Build signed payload: base64(JSON) + "." + HMAC
|
||||
const crypto = (await import('crypto')).default || await import('crypto');
|
||||
const secret = cfg.main.secret || cfg.main.url.full || 'f0ckm-impersonate-secret';
|
||||
const payload = Buffer.from(JSON.stringify({
|
||||
uid: target[0].id,
|
||||
orig: lib.sha256(req.cookies.session),
|
||||
ts: Date.now()
|
||||
})).toString('base64url');
|
||||
const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
||||
const cookieVal = `${payload}.${sig}`;
|
||||
|
||||
const cookieOpts = lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': `impersonate=${cookieVal}; ${cookieOpts}`
|
||||
}).end(JSON.stringify({ success: true, username: target[0].user }));
|
||||
} catch (e) {
|
||||
if (res.json) return res.json({ success: false, msg: e.message });
|
||||
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: e.message }));
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v2/admin/stop-impersonate — exit impersonation
|
||||
router.post(/^\/api\/v2\/admin\/stop-impersonate\/?$/, async (req, res) => {
|
||||
// No auth guard needed — just clear the cookie
|
||||
const cookieOpts = lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT');
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/json',
|
||||
'Set-Cookie': `impersonate=; ${cookieOpts}`
|
||||
}).end(JSON.stringify({ success: true }));
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
|
||||
+50
-417
@@ -1,297 +1,9 @@
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import url from "url";
|
||||
import cfg from "../config.mjs";
|
||||
import { createI18n } from "../i18n.mjs";
|
||||
import { isAnonymizeSession, canAnonDo, getSessionOwnerName, isOnaraEnabledFor } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
// ── Merged random + item load: single request instead of two ────────────
|
||||
router.get(/^\/ajax\/item\/random/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
if (typeof req.url === 'string') {
|
||||
const parsedUrl = url.parse(req.url, true);
|
||||
query = parsedUrl.query;
|
||||
} else {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isPrefetch = !!(
|
||||
query.prefetch === '1' ||
|
||||
req.url?.qs?.prefetch === '1' ||
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
|
||||
const isGuest = !req.session || !req.session.user;
|
||||
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null));
|
||||
|
||||
const tag = query.tag || null;
|
||||
const hall = query.hall || null;
|
||||
const user = query.user || null;
|
||||
const userHall = query.userHall || null;
|
||||
const userHallOwner = query.userHallOwner || null;
|
||||
const isFav = query.fav === 'true';
|
||||
const isStrict = query.strict === '1';
|
||||
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
|
||||
const mime = (typeof query.mime !== 'undefined') ? (query.mime || null) : (cookieMime || null);
|
||||
|
||||
// Resolve random item ID
|
||||
const randomData = await f0cklib.getRandom({
|
||||
user, tag, hall, userHall, userHallOwner, mime,
|
||||
fav: isFav,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: req.session,
|
||||
exclude: req.session?.excluded_tags || [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin
|
||||
});
|
||||
const tRandom = Date.now();
|
||||
if (!randomData || !randomData.itemid) {
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: '', error: true, success: false, message: 'No items found' })
|
||||
});
|
||||
}
|
||||
|
||||
const itemid = String(randomData.itemid);
|
||||
|
||||
// Build context URL for the resolved item
|
||||
let contextUrl = `/${itemid}`;
|
||||
if (tag) contextUrl = `/tag/${encodeURIComponent(tag)}/${itemid}`;
|
||||
if (hall) contextUrl = `/h/${encodeURIComponent(hall)}/${itemid}`;
|
||||
if (userHall && userHallOwner) {
|
||||
contextUrl = `/user/${encodeURIComponent(userHallOwner)}/hall/${encodeURIComponent(userHall)}/${itemid}`;
|
||||
} else if (user) {
|
||||
contextUrl = isFav
|
||||
? `/user/${encodeURIComponent(user)}/favs/${itemid}`
|
||||
: `/user/${encodeURIComponent(user)}/${itemid}`;
|
||||
} else if (isFav) {
|
||||
contextUrl = `/favs/${itemid}`;
|
||||
}
|
||||
if (mime) {
|
||||
contextUrl = contextUrl.replace(new RegExp(`/${itemid}$`), `/${mime}/${itemid}`);
|
||||
}
|
||||
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX-RANDOM] Resolved random item ${itemid} in ${Date.now() - tAjaxStart}ms`);
|
||||
|
||||
// Now run the full item load pipeline (same as /ajax/item/:id)
|
||||
const bypassFilter = !!(query.bypass === '1');
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: itemid,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr,
|
||||
bypass_filter: bypassFilter,
|
||||
session: req.session,
|
||||
url: contextUrl,
|
||||
user: user,
|
||||
tag: tag,
|
||||
hall: hall,
|
||||
userHall: userHall,
|
||||
userHallOwner: userHallOwner,
|
||||
mime: mime,
|
||||
fav: isFav,
|
||||
ids: null,
|
||||
random: true,
|
||||
strict: isStrict || req.session?.strict_mode,
|
||||
explicitStrict: isStrict,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id,
|
||||
subf0ck: query.subf0ck || null
|
||||
});
|
||||
const tAjaxFetch = Date.now();
|
||||
|
||||
if (!data.success) {
|
||||
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 4: 'NSFL' };
|
||||
const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null;
|
||||
const errorHtml = tpl.render('error-partial', {
|
||||
message: tErr('error.post_not_visible'),
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
item_id: data.item?.id || itemid,
|
||||
item_slug: data.item?.slug || null,
|
||||
error_mode_label: errorModeLabel,
|
||||
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
|
||||
error_filter_hint_link: tErr('error.filter_hint_link'),
|
||||
error_see_anyways: tErr('error.see_anyways')
|
||||
}, req);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ html: errorHtml, pagination: '', error: true })
|
||||
});
|
||||
}
|
||||
|
||||
// xD Score + comments — parallelize subscription + comments fetch
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const [sub, commentsForScore] = await Promise.all([
|
||||
req.session ? f0cklib.getSubscriptionStatus(req.session.id, itemid) : false,
|
||||
f0cklib.getComments(itemid, 'old', false)
|
||||
]);
|
||||
data.isSubscribed = sub;
|
||||
const xdScore = f0cklib.computeXdScore(commentsForScore);
|
||||
const xdMeta = f0cklib.xdScoreMeta(xdScore);
|
||||
data.item.xd_score = xdScore;
|
||||
data.item.xd_tier = xdMeta.tier;
|
||||
data.item.xd_label = xdMeta.label;
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
} else {
|
||||
data.isSubscribed = false;
|
||||
data.commentsJSON = null;
|
||||
data.comments = [];
|
||||
data.item.xd_score = 0;
|
||||
data.item.xd_tier = 0;
|
||||
data.item.xd_label = '';
|
||||
}
|
||||
const tAjaxAux = Date.now();
|
||||
|
||||
// Session + template vars
|
||||
data.session = req.session ? { ...req.session } : false;
|
||||
data.url = { pathname: contextUrl };
|
||||
data.fullscreen = req.cookies.fullscreen || 0;
|
||||
data.hidePagination = true;
|
||||
|
||||
// Precompute hall display data
|
||||
if (data.item && data.item.halls && data.item.halls.length) {
|
||||
const currentHallSlug = data.tmp && data.tmp.hall
|
||||
? (typeof data.tmp.hall === 'object' ? data.tmp.hall.slug : data.tmp.hall)
|
||||
: null;
|
||||
data.item.primaryHall = data.item.halls.find(h => h.slug === currentHallSlug) || data.item.halls[0];
|
||||
data.item.otherHalls = data.item.halls.filter(h => h.slug !== data.item.primaryHall.slug);
|
||||
} else if (data.item) {
|
||||
data.item.primaryHall = null;
|
||||
data.item.otherHalls = [];
|
||||
}
|
||||
|
||||
// Precomputed template booleans
|
||||
if (data.item) {
|
||||
const session = data.session;
|
||||
const item = data.item;
|
||||
// Keep the real uploader for permission checks — anonymization below overwrites item.username
|
||||
const _realUsername = item.username;
|
||||
if (isAnonymizeSession(req.session)) {
|
||||
if (item.src) item.src = null;
|
||||
item.username = 'anonymous';
|
||||
item.author_banner_file = null;
|
||||
item.author_banner_position = null;
|
||||
item.author_banner_size = null;
|
||||
item.author_avatar = null;
|
||||
item.author_avatar_file = null;
|
||||
item.author_color = null;
|
||||
item.author_description = null;
|
||||
item.author_display_name = null;
|
||||
item.author_id = null;
|
||||
if (data.uploader) {
|
||||
data.uploader.name = 'anonymous';
|
||||
data.uploader.id = null;
|
||||
data.uploader.color = null;
|
||||
}
|
||||
if (Array.isArray(item.favorites)) {
|
||||
item.favorites = item.favorites.map(f => {
|
||||
const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id);
|
||||
if (isSelf) return f;
|
||||
return { user_id: null, user: 'anonymous', login: 'anonymous', display_name: 'Anonymous', avatar: null, avatar_file: null, username_color: null, hide_fav_badge: f.hide_fav_badge, is_anon: true };
|
||||
});
|
||||
}
|
||||
}
|
||||
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
||||
// Rating may also be changed by an anonymous uploader on their own item
|
||||
const _ownerName = getSessionOwnerName(session);
|
||||
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
|
||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||
data.item_rating_class = item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged'));
|
||||
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
|
||||
data.item_username_lower = (item.username || '').toLowerCase();
|
||||
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
|
||||
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
|
||||
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
|
||||
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
|
||||
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
|
||||
data.item_has_dimensions = !!(item.width && item.height);
|
||||
}
|
||||
|
||||
// Render
|
||||
const itemHtml = tpl.render('ajax-item', data, req);
|
||||
const paginationHtml = tpl.render('snippets/pagination', data, req);
|
||||
const tAjaxRender = Date.now();
|
||||
|
||||
// Detailed timing breakdown
|
||||
console.log(`[AJAX-RANDOM] ${itemid} total=${tAjaxRender - tAjaxStart}ms | getRandom=${tRandom - tAjaxStart}ms | getf0ck=${tAjaxFetch - tRandom}ms | aux=${tAjaxAux - tAjaxFetch}ms | render=${tAjaxRender - tAjaxAux}ms`);
|
||||
|
||||
let itemPage = null;
|
||||
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
|
||||
if (effectiveOnara) {
|
||||
try {
|
||||
itemPage = await f0cklib.getItemPage({
|
||||
targetItemId: data.item?.id || itemid,
|
||||
targetItemPinned: data.item?.is_pinned,
|
||||
user, tag, hall, userHall, userHallOwner, mime,
|
||||
fav: isFav,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
|
||||
strict: isStrict,
|
||||
session: req.session,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin,
|
||||
minXdScore: req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0),
|
||||
tagger: query.tagger || null
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const itemNumericId = data.item?.id ? String(data.item.id) : itemid;
|
||||
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
|
||||
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
|
||||
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
|
||||
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
|
||||
const itemUser = isAnon
|
||||
? 'anonymous'
|
||||
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
|
||||
const itemUsername = isAnon
|
||||
? 'anonymous'
|
||||
: (data.item?.username || 'anonymous');
|
||||
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
|
||||
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
|
||||
|
||||
res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
html: itemHtml,
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid,
|
||||
numeric_id: itemNumericId,
|
||||
slug: data.item?.slug || null,
|
||||
page: itemPage,
|
||||
thumb: itemThumb,
|
||||
mode: itemMode,
|
||||
tag_id: itemTagId,
|
||||
mime: itemMime,
|
||||
user: itemUser,
|
||||
username: itemUsername,
|
||||
dest: itemDest,
|
||||
is_random: true
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
|
||||
const tAjaxStart = Date.now();
|
||||
let query = {};
|
||||
@@ -303,14 +15,6 @@ export default (router, tpl) => {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isPrefetch = !!(
|
||||
query.prefetch === '1' ||
|
||||
req.url?.qs?.prefetch === '1' ||
|
||||
req.headers?.['x-purpose'] === 'prefetch' ||
|
||||
req.headers?.['purpose'] === 'prefetch' ||
|
||||
req.headers?.['sec-purpose'] === 'prefetch'
|
||||
);
|
||||
|
||||
let contextUrl = `/${req.params.itemid}`;
|
||||
if (query.tag) contextUrl = `/tag/${encodeURIComponent(query.tag)}/${req.params.itemid}`;
|
||||
if (query.hall) contextUrl = `/h/${encodeURIComponent(query.hall)}/${req.params.itemid}`;
|
||||
@@ -320,8 +24,6 @@ export default (router, tpl) => {
|
||||
contextUrl = query.fav === 'true'
|
||||
? `/user/${encodeURIComponent(query.user)}/favs/${req.params.itemid}`
|
||||
: `/user/${encodeURIComponent(query.user)}/${req.params.itemid}`;
|
||||
} else if (query.fav === 'true') {
|
||||
contextUrl = `/favs/${req.params.itemid}`;
|
||||
}
|
||||
if (query.mime) {
|
||||
contextUrl = contextUrl.replace(new RegExp(`/${req.params.itemid}$`), `/${query.mime}/${req.params.itemid}`);
|
||||
@@ -329,19 +31,16 @@ export default (router, tpl) => {
|
||||
|
||||
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
|
||||
|
||||
const isGuest = !req.session || !req.session.user;
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
const reqMode = isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode);
|
||||
const reqMode = query.mode !== undefined ? +query.mode : req.mode;
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = isGuest ? ['sfw'] : ((reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null));
|
||||
const ratingsArr = (reqMode === 2 || reqMode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||
|
||||
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
|
||||
const bypassFilter = !!(query.force === '1' || query.force === 'true' || query.allow === '1' || query.allow === 'true' || query.bypass === '1' || query.bypass === 'true' || query.see_anyways === '1');
|
||||
const data = await f0cklib.getf0ck({
|
||||
itemid: itemid,
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr,
|
||||
bypass_filter: bypassFilter,
|
||||
session: req.session,
|
||||
url: contextUrl,
|
||||
user: query.user,
|
||||
@@ -351,31 +50,26 @@ export default (router, tpl) => {
|
||||
userHallOwner: query.userHallOwner || null,
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
fav: query.fav === 'true',
|
||||
ids: query.ids || null,
|
||||
random: isRandom,
|
||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||
explicitStrict: query.strict === '1' || query.strict === 'true',
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id,
|
||||
subf0ck: query.subf0ck || null
|
||||
user_id: req.session?.id
|
||||
});
|
||||
const tAjaxFetch = Date.now();
|
||||
|
||||
if (!data.success) {
|
||||
const reqMode = (query.mode !== undefined ? +query.mode : req.mode) ?? 0;
|
||||
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Unrated', 4: 'NSFL' };
|
||||
const errorModeLabel = reqMode !== 3 ? (modeLabels[reqMode] || 'SFW') : null;
|
||||
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
|
||||
const errorModeLabel = (req.session && reqMode !== 3) ? (modeLabels[reqMode] || null) : null;
|
||||
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
|
||||
const errorHtml = tpl.render('error-partial', {
|
||||
message: tErr('error.post_not_visible'),
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
item_id: data.item?.id || itemid,
|
||||
item_slug: data.item?.slug || (typeof itemid === 'string' ? itemid : null),
|
||||
error_mode_label: errorModeLabel,
|
||||
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
|
||||
error_filter_hint_link: tErr('error.filter_hint_link'),
|
||||
error_see_anyways: tErr('error.see_anyways')
|
||||
error_filter_hint_link: tErr('error.filter_hint_link')
|
||||
}, req);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -387,8 +81,8 @@ export default (router, tpl) => {
|
||||
// Comments are always loaded async by the client via /api/comments/:id to avoid
|
||||
// blocking the browser's main thread on posts with huge comment payloads.
|
||||
if (req.session || !cfg.main.hide_comments_from_public) {
|
||||
// Mark notifications as read (only when actually viewed, not on prefetch)
|
||||
if (req.session?.id && !isPrefetch) {
|
||||
// Mark notifications as read
|
||||
if (req.session?.id) {
|
||||
f0cklib.markNotificationsRead(req.session.id, itemid).catch(() => {});
|
||||
}
|
||||
const sub = req.session ? await f0cklib.getSubscriptionStatus(req.session.id, req.params.itemid) : false;
|
||||
@@ -443,52 +137,10 @@ export default (router, tpl) => {
|
||||
if (data.item) {
|
||||
const session = data.session;
|
||||
const item = data.item;
|
||||
// Keep the real uploader for permission checks — anonymization below overwrites item.username
|
||||
const _realUsername = item.username;
|
||||
// When guest or anon anonymization is active, suppress uploader identity, banner, avatar, and source URL
|
||||
if (isAnonymizeSession(req.session)) {
|
||||
if (item.src) item.src = null;
|
||||
item.username = 'anonymous';
|
||||
item.author_banner_file = null;
|
||||
item.author_banner_position = null;
|
||||
item.author_banner_size = null;
|
||||
item.author_avatar = null;
|
||||
item.author_avatar_file = null;
|
||||
item.author_color = null;
|
||||
item.author_description = null;
|
||||
item.author_display_name = null;
|
||||
item.author_id = null;
|
||||
if (data.uploader) {
|
||||
data.uploader.name = 'anonymous';
|
||||
data.uploader.id = null;
|
||||
data.uploader.color = null;
|
||||
}
|
||||
if (Array.isArray(item.favorites)) {
|
||||
item.favorites = item.favorites.map(f => {
|
||||
const isSelf = session && session.id && f.user_id && Number(f.user_id) === Number(session.id);
|
||||
if (isSelf) return f;
|
||||
return {
|
||||
user_id: null,
|
||||
user: 'anonymous',
|
||||
login: 'anonymous',
|
||||
display_name: 'Anonymous',
|
||||
avatar: null,
|
||||
avatar_file: null,
|
||||
username_color: null,
|
||||
hide_fav_badge: f.hide_fav_badge,
|
||||
is_anon: true
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
const isAnon = !!(session && (session.is_anon || (session.user && (session.user === 'anonymous' || session.user.startsWith('anon_')))));
|
||||
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
|
||||
data.can_manage_item = !isAnon && !!(session && (session.admin || session.is_moderator || (session.user && item.username && session.user.toLowerCase() === item.username.toLowerCase())));
|
||||
// Rating may also be changed by an anonymous uploader on their own item
|
||||
const _ownerName = getSessionOwnerName(session);
|
||||
data.can_rate_item = data.can_manage_item || (isAnon && canAnonDo('rate_item') && !!(_ownerName && _realUsername && _ownerName.toLowerCase() === _realUsername.toLowerCase()));
|
||||
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
|
||||
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
|
||||
data.user_has_favorited = lib.userHasFavorited(session, item.favorites);
|
||||
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
|
||||
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
|
||||
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
|
||||
// Precomputed for template engine compatibility (avoids nested { } inside {{ }})
|
||||
@@ -513,48 +165,6 @@ export default (router, tpl) => {
|
||||
- Comments/Sub: ${tAjaxAux - tAjaxFetch}ms
|
||||
- Render: ${tAjaxRender - tAjaxAux}ms`);
|
||||
|
||||
let itemPage = null;
|
||||
const effectiveOnara = isOnaraEnabledFor(req, query.onara === '1');
|
||||
if (effectiveOnara || query.get_page === '1') {
|
||||
try {
|
||||
itemPage = await f0cklib.getItemPage({
|
||||
targetItemId: data.item?.id || itemid,
|
||||
targetItemPinned: data.item?.is_pinned,
|
||||
user: query.user,
|
||||
tag: query.tag,
|
||||
hall: query.hall,
|
||||
userHall: query.userHall,
|
||||
userHallOwner: query.userHallOwner,
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
fav: query.fav === 'true',
|
||||
mode: reqMode,
|
||||
ratings: ratingsArr,
|
||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||
session: req.session,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin,
|
||||
minXdScore: req.session?.min_xd_score || 0,
|
||||
tagger: query.tagger || null
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const itemNumericId = data.item?.id ? String(data.item.id) : (/^\d+$/.test(itemid) ? itemid : null);
|
||||
const itemMode = data.item?.is_nsfl ? 'nsfl' : (data.item?.is_nsfw ? 'nsfw' : (data.item?.is_sfw ? 'sfw' : 'null'));
|
||||
const itemTagId = data.item?.tag_id || (data.item?.is_nsfl ? nsflId : (data.item?.is_nsfw ? 2 : (data.item?.is_sfw ? 1 : null)));
|
||||
const itemThumb = data.item?.thumb || data.item?.thumbnail || (itemNumericId ? `/t/${itemNumericId}.webp` : null);
|
||||
const isAnon = !!(data.is_anonymized || isAnonymizeSession(req.session));
|
||||
const itemUser = isAnon
|
||||
? 'anonymous'
|
||||
: (data.item?.author_display_name || data.item?.display_name || data.item?.username || 'anonymous');
|
||||
const itemUsername = isAnon
|
||||
? 'anonymous'
|
||||
: (data.item?.username || 'anonymous');
|
||||
const itemMime = data.item?.matching_sub_mime || data.item?.mime || null;
|
||||
const itemDest = data.item?.matching_sub_dest || data.item?.dest || null;
|
||||
|
||||
res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
@@ -562,16 +172,7 @@ export default (router, tpl) => {
|
||||
pagination: paginationHtml,
|
||||
title: data.title,
|
||||
id: itemid,
|
||||
numeric_id: itemNumericId,
|
||||
slug: data.item?.slug || null,
|
||||
page: itemPage,
|
||||
thumb: itemThumb,
|
||||
mode: itemMode,
|
||||
tag_id: itemTagId,
|
||||
mime: itemMime,
|
||||
user: itemUser,
|
||||
username: itemUsername,
|
||||
dest: itemDest
|
||||
slug: data.item?.slug || null
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -604,11 +205,17 @@ export default (router, tpl) => {
|
||||
query = req.url.qs || {};
|
||||
}
|
||||
|
||||
const isGuest = !req.session || !req.session.user;
|
||||
const page = parseInt(query.page) || 1;
|
||||
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
|
||||
const ratingsRaw = req.cookies.ratings;
|
||||
const ratingsArr = isGuest ? ['sfw'] : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
|
||||
const ratingsRaw = query.ratings || req.cookies.ratings;
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
|
||||
const sortParam = query.sort || req.cookies.sort || 'newest';
|
||||
const timeframeParam = query.timeframe || req.cookies.timeframe || 'all';
|
||||
const ocParam = query.oc === '1' || query.oc === 'true';
|
||||
const pinnedParam = query.pinned === '1' || query.pinned === 'true';
|
||||
const hasCommentsParam = query.has_comments === '1' || query.has_comments === 'true' || query.hasComments === 'true';
|
||||
const layoutParam = query.layout || req.cookies.layout || 'grid';
|
||||
|
||||
const data = await f0cklib.getf0cks({
|
||||
page: page,
|
||||
@@ -618,15 +225,18 @@ export default (router, tpl) => {
|
||||
userHall: query.userHall || null,
|
||||
userHallOwner: query.userHallOwner || null,
|
||||
mime: query.mime || (req.cookies.mime || null),
|
||||
mode: isGuest ? 0 : (query.mode !== undefined ? +query.mode : req.mode),
|
||||
mode: query.mode !== undefined ? +query.mode : req.mode,
|
||||
sort: sortParam,
|
||||
timeframe: timeframeParam,
|
||||
oc: ocParam,
|
||||
pinned: pinnedParam,
|
||||
hasComments: hasCommentsParam,
|
||||
ratings: ratingsArr,
|
||||
session: req.session,
|
||||
user_id: req.session?.id,
|
||||
is_admin: req.session?.admin,
|
||||
exclude: req.session ? (req.session.excluded_tags || []) : [],
|
||||
fav: query.fav === 'true',
|
||||
ids: query.ids || null,
|
||||
total: query.total ? parseInt(query.total, 10) : undefined,
|
||||
random: isRandom,
|
||||
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
|
||||
explicitStrict: query.strict === '1' || query.strict === 'true',
|
||||
@@ -662,7 +272,8 @@ export default (router, tpl) => {
|
||||
// Render just the thumbnail items
|
||||
const itemsHtml = tpl.render('snippets/items-grid', {
|
||||
items: data.items,
|
||||
link: data.link
|
||||
link: data.link,
|
||||
layout: layoutParam
|
||||
}, req);
|
||||
|
||||
// Render pagination
|
||||
@@ -685,6 +296,27 @@ export default (router, tpl) => {
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false
|
||||
}, req);
|
||||
|
||||
// Render filter bar
|
||||
const activeRatings = ratingsArr || ['sfw', 'nsfw', 'untagged'];
|
||||
const activeMimes = (query.mime || req.cookies.mime || '').split(',').filter(Boolean);
|
||||
|
||||
const filterBarHtml = tpl.render('snippets/filter-bar', {
|
||||
layout: layoutParam,
|
||||
sort: sortParam,
|
||||
timeframe: timeframeParam,
|
||||
oc: ocParam ? '1' : '0',
|
||||
pinned: pinnedParam ? '1' : '0',
|
||||
hasComments: hasCommentsParam ? '1' : '0',
|
||||
rating_sfw: activeRatings.includes('sfw'),
|
||||
rating_nsfw: activeRatings.includes('nsfw'),
|
||||
rating_nsfl: activeRatings.includes('nsfl'),
|
||||
rating_untagged: activeRatings.includes('untagged'),
|
||||
mime_image: activeMimes.includes('image'),
|
||||
mime_video: activeMimes.includes('video'),
|
||||
mime_audio: activeMimes.includes('audio'),
|
||||
mime_flash: activeMimes.includes('flash')
|
||||
}, req);
|
||||
|
||||
const hasMore = data.pagination.next !== null;
|
||||
|
||||
return res.reply({
|
||||
@@ -698,6 +330,7 @@ export default (router, tpl) => {
|
||||
success: true,
|
||||
html: itemsHtml,
|
||||
titleHtml: titleHtml,
|
||||
filterBarHtml: filterBarHtml,
|
||||
pagination: paginationHtml,
|
||||
hasMore: hasMore,
|
||||
nextPage: data.pagination.next,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
};
|
||||
+85
-1147
File diff suppressed because it is too large
Load Diff
@@ -1,44 +1,16 @@
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import security from '../../security.mjs';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { canAnonDo, isAnonSession } from '../../settings.mjs';
|
||||
import {
|
||||
generateChallenge, consumeChallenge,
|
||||
verifyRegistration, verifyAuthentication,
|
||||
buildRegistrationOptions, buildAuthenticationOptions,
|
||||
base64url, getRpIdFromHost, aaguidProvider
|
||||
} from '../../webauthn.mjs';
|
||||
|
||||
// Passkey-only accounts store this instead of a hash; lib.verify never matches it
|
||||
const PASSWORD_DISABLED = '!';
|
||||
const LAST_PASSKEY_MSG = 'This is your last passkey and password login is disabled. Set a password first, or add another passkey.';
|
||||
const IN_USE_PASSKEY_MSG = 'This passkey is the one you are signed in with right now and cannot be removed from this session.';
|
||||
// The passkey the current session was opened with (null: password login or an older session)
|
||||
const sessionPasskey = async (req) => {
|
||||
if (!req.session?.sess_id) return null;
|
||||
const [row] = await db`select passkey_credential_id from user_sessions where id = ${+req.session.sess_id}`;
|
||||
return row?.passkey_credential_id || null;
|
||||
};
|
||||
// True when removing a passkey would leave a passkey-only account with no way to log in
|
||||
const isLastPasskeyOfPasskeyOnly = async (userId) => {
|
||||
const [row] = await db`
|
||||
select (u.password = ${PASSWORD_DISABLED}) as disabled,
|
||||
(select count(*)::int from passkey_credentials pc where pc.user_id = u.id) as n
|
||||
from "user" u where u.id = ${+userId}
|
||||
`;
|
||||
return !!row && row.disabled && row.n <= 1;
|
||||
};
|
||||
|
||||
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
|
||||
// These routes remain for other settings API endpoints
|
||||
|
||||
export default router => {
|
||||
router.group(/^\/api\/v2\/settings/, group => {
|
||||
group.put(/\/setAvatar/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/setAvatar/, lib.loggedin, async (req, res) => {
|
||||
if (!req.post.avatar) {
|
||||
return res.json({
|
||||
msg: 'no avatar provided',
|
||||
@@ -74,7 +46,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Switch to custom avatar (sets avatar ID to 0 so avatar_file is used)
|
||||
group.put(/\/useCustomAvatar/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/useCustomAvatar/, lib.loggedin, async (req, res) => {
|
||||
// Check if user has a custom avatar file
|
||||
const userOpts = (await db`
|
||||
select avatar_file from user_options where user_id = ${+req.session.id}
|
||||
@@ -102,9 +74,6 @@ export default router => {
|
||||
});
|
||||
|
||||
group.get(/\/excluded_tags/, lib.loggedin, async (req, res) => {
|
||||
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
|
||||
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
|
||||
}
|
||||
const tags = await db`
|
||||
select t.id, t.tag, t.normalized
|
||||
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
|
||||
@@ -114,32 +83,19 @@ export default router => {
|
||||
});
|
||||
|
||||
group.post(/\/excluded_tags/, lib.loggedin, async (req, res) => {
|
||||
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
|
||||
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
|
||||
}
|
||||
const tagname = req.post?.tagname || req.body?.tagname;
|
||||
const tagId = req.post?.tag_id || req.body?.tag_id;
|
||||
if (!tagname && !tagId) return res.json({ success: false, msg: 'No tag provided' }, 400);
|
||||
const tagname = req.post.tagname;
|
||||
if (!tagname) return res.json({ success: false, msg: 'No tag provided' }, 400);
|
||||
|
||||
const tag = tagId
|
||||
? (await db`select id, tag, normalized from tags where id = ${+tagId}`)[0]
|
||||
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagname}) or tag = ${tagname}`)[0];
|
||||
const tag = (await db`select id, tag, normalized from tags where normalized = slugify(${tagname})`)[0];
|
||||
|
||||
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set excluded_tags = array_append(coalesce(excluded_tags, '{}'), ${tag.id})
|
||||
where user_id = ${+req.session.id} and not (${tag.id} = any(coalesce(excluded_tags, '{}')))
|
||||
set excluded_tags = array_append(excluded_tags, ${tag.id})
|
||||
where user_id = ${+req.session.id} and not (${tag.id} = any(excluded_tags))
|
||||
`;
|
||||
|
||||
if (req.session) {
|
||||
if (!req.session.excluded_tags) req.session.excluded_tags = [];
|
||||
if (!req.session.excluded_tags.includes(tag.id)) {
|
||||
req.session.excluded_tags.push(tag.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated list
|
||||
const tags = await db`
|
||||
select t.id, t.tag, t.normalized
|
||||
@@ -147,42 +103,32 @@ export default router => {
|
||||
join tags t on t.id = et.id
|
||||
`;
|
||||
|
||||
return res.json({ success: true, tags, tag }, 200);
|
||||
return res.json({ success: true, tags }, 200);
|
||||
});
|
||||
|
||||
group.delete(/\/excluded_tags\/(?<tag>.+)/, lib.loggedin, async (req, res) => {
|
||||
if (isAnonSession(req.session) && !canAnonDo('exclude_tags')) {
|
||||
return res.json({ success: false, msg: 'Tag exclusion is disabled for anonymous users' }, 403);
|
||||
}
|
||||
const tagParam = decodeURIComponent(req.params.tag);
|
||||
const isNum = /^\d+$/.test(tagParam);
|
||||
const tag = isNum
|
||||
? (await db`select id, tag, normalized from tags where id = ${+tagParam}`)[0]
|
||||
: (await db`select id, tag, normalized from tags where normalized = slugify(${tagParam}) or tag = ${tagParam}`)[0];
|
||||
const tagname = decodeURIComponent(req.params.tag);
|
||||
const tag = (await db`select id from tags where normalized = slugify(${tagname})`)[0];
|
||||
|
||||
if (!tag) return res.json({ success: false, msg: 'Tag not found' }, 404);
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set excluded_tags = array_remove(coalesce(excluded_tags, '{}'), ${tag.id})
|
||||
set excluded_tags = array_remove(excluded_tags, ${tag.id})
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
if (req.session && req.session.excluded_tags) {
|
||||
req.session.excluded_tags = req.session.excluded_tags.filter(id => id !== tag.id);
|
||||
}
|
||||
|
||||
const tags = await db`
|
||||
select t.id, t.tag, t.normalized
|
||||
from unnest((select excluded_tags from user_options where user_id = ${+req.session.id})) as et(id)
|
||||
join tags t on t.id = et.id
|
||||
`;
|
||||
|
||||
return res.json({ success: true, tags, tag }, 200);
|
||||
return res.json({ success: true, tags }, 200);
|
||||
});
|
||||
|
||||
// Generic Token Generation (default type=discord if not specified, though frontend should specify)
|
||||
group.post(/\/link\/token/, lib.registeredUser, async (req, res) => {
|
||||
group.post(/\/link\/token/, lib.loggedin, async (req, res) => {
|
||||
// 6-char alphanumeric code
|
||||
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
const type = req.post.type || 'discord'; // Default to discord for backward compatibility if needed
|
||||
@@ -222,7 +168,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Get linked accounts (Discord & Matrix)
|
||||
group.get(/\/link\/accounts/, lib.registeredUser, async (req, res) => {
|
||||
group.get(/\/link\/accounts/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const aliases = await db`
|
||||
SELECT alias, type FROM user_alias
|
||||
@@ -242,7 +188,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Unlink account
|
||||
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
|
||||
group.delete(/\/link\/unlink\/(?<type>[a-z]+)\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const alias = decodeURIComponent(req.params.alias);
|
||||
const type = req.params.type;
|
||||
@@ -268,7 +214,7 @@ export default router => {
|
||||
|
||||
// Backward compatibility routes for Discord (Deprecated)
|
||||
// Discord Token Generation (Redirect to generic)
|
||||
group.post(/\/discord\/token/, lib.registeredUser, async (req, res) => {
|
||||
group.post(/\/discord\/token/, lib.loggedin, async (req, res) => {
|
||||
// Just call the logic inline
|
||||
const token = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
try {
|
||||
@@ -281,13 +227,13 @@ export default router => {
|
||||
});
|
||||
|
||||
// Get linked Discord accounts (Legacy)
|
||||
group.get(/\/discord\/linked/, lib.registeredUser, async (req, res) => {
|
||||
group.get(/\/discord\/linked/, lib.loggedin, async (req, res) => {
|
||||
const aliases = await db`SELECT alias FROM user_alias WHERE userid = ${req.session.id} AND type = 'discord'`;
|
||||
return res.json({ success: true, aliases: aliases.map(a => ({ alias: a.alias })) }, 200);
|
||||
});
|
||||
|
||||
// Unlink Discord account (Legacy)
|
||||
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.registeredUser, async (req, res) => {
|
||||
group.delete(/\/discord\/unlink\/(?<alias>.+)/, lib.loggedin, async (req, res) => {
|
||||
const alias = decodeURIComponent(req.params.alias);
|
||||
await db`DELETE FROM user_alias WHERE lower(alias) = lower(${alias}) AND userid = ${req.session.id} AND type = 'discord'`;
|
||||
return res.json({ success: true, msg: 'Account unlinked' }, 200);
|
||||
@@ -408,7 +354,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Update Default Upload Visibility preference
|
||||
group.put(/\/default_upload_visibility/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
|
||||
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
|
||||
}
|
||||
@@ -432,7 +378,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Update Username Color preference
|
||||
group.put(/\/username_color/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
|
||||
const { color } = req.post;
|
||||
|
||||
if (!color || !/^#([0-9A-F]{3}){1,2}$/i.test(color)) {
|
||||
@@ -462,36 +408,8 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/password/disable
|
||||
* Passkey-only account: switch off password login. Needs the current password (a hijacked session
|
||||
* alone can't lock the owner out) and at least one passkey. Setting a new password re-enables it.
|
||||
*/
|
||||
group.post(/\/password\/disable$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const { current_password } = req.post || {};
|
||||
const user = (await db`select password from "user" where id = ${+req.session.id}`)[0];
|
||||
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
|
||||
if (user.password === PASSWORD_DISABLED) return res.json({ success: false, msg: 'Password login is already disabled' }, 400);
|
||||
if (!current_password || !(await lib.verify(current_password, user.password))) {
|
||||
return res.json({ success: false, msg: 'Incorrect current password' }, 401);
|
||||
}
|
||||
const [{ n }] = await db`select count(*)::int as n from passkey_credentials where user_id = ${+req.session.id}`;
|
||||
if (n < 1) return res.json({ success: false, msg: 'Add a passkey first, otherwise you could not log in anymore' }, 400);
|
||||
|
||||
await db`update "user" set password = ${PASSWORD_DISABLED}, force_password_change = false where id = ${+req.session.id}`;
|
||||
// Sessions elsewhere may have been opened with the password: end them, keep this one
|
||||
await db`delete from "user_sessions" where user_id = ${+req.session.id} and id != ${+req.session.sess_id}`;
|
||||
await db`delete from login_attempts where username = ${req.session.login}`.catch(() => {});
|
||||
return res.json({ success: true, msg: 'Password login disabled. Use your passkey to sign in.' });
|
||||
} catch (err) {
|
||||
console.error('[SETTINGS] Disable password error:', err);
|
||||
return res.json({ success: false, msg: 'Failed to disable password' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Update password
|
||||
group.put(/\/password/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/password/, lib.loggedin, async (req, res) => {
|
||||
const { current_password, new_password, new_password_confirm } = req.post;
|
||||
|
||||
if (!new_password || !new_password_confirm) {
|
||||
@@ -501,9 +419,7 @@ export default router => {
|
||||
const user = (await db`select password, force_password_change from "user" where id = ${+req.session.id}`)[0];
|
||||
if (!user) return res.json({ success: false, msg: 'User not found' }, 404);
|
||||
|
||||
// Passkey-only accounts have no current password: setting one turns password login back on
|
||||
const passwordDisabled = user.password === PASSWORD_DISABLED;
|
||||
if (!user.force_password_change && !passwordDisabled) {
|
||||
if (!user.force_password_change) {
|
||||
if (!current_password) {
|
||||
return res.json({ success: false, msg: 'Current password is required' }, 400);
|
||||
}
|
||||
@@ -534,7 +450,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Update email
|
||||
group.put(/\/email/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/email/, lib.loggedin, async (req, res) => {
|
||||
const { email } = req.post;
|
||||
if (!email || !email.trim()) return res.json({ success: false, msg: 'Email is required' }, 400);
|
||||
const cleanEmail = email.trim();
|
||||
@@ -557,7 +473,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Update Display Name
|
||||
group.put(/\/display_name/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/display_name/, lib.loggedin, async (req, res) => {
|
||||
const { display_name } = req.post;
|
||||
|
||||
if (display_name !== undefined && typeof display_name !== 'string') {
|
||||
@@ -590,7 +506,7 @@ export default router => {
|
||||
});
|
||||
|
||||
// Update Description
|
||||
group.put(/\/description/, lib.registeredUser, async (req, res) => {
|
||||
group.put(/\/description/, lib.loggedin, async (req, res) => {
|
||||
if (!cfg.websrv.enable_profile_description) {
|
||||
return res.json({ success: false, msg: 'Profile descriptions are disabled' }, 403);
|
||||
}
|
||||
@@ -919,7 +835,7 @@ export default router => {
|
||||
|
||||
// GET /api/v2/settings/api-key
|
||||
// Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview).
|
||||
group.get(/\/api-key$/, lib.registeredUser, async (req, res) => {
|
||||
group.get(/\/api-key$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_api_keys === false) {
|
||||
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
||||
}
|
||||
@@ -949,7 +865,7 @@ export default router => {
|
||||
|
||||
// POST /api/v2/settings/api-key/regenerate
|
||||
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
|
||||
group.post(/\/api-key\/regenerate$/, lib.registeredUser, async (req, res) => {
|
||||
group.post(/\/api-key\/regenerate$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_api_keys === false) {
|
||||
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
||||
}
|
||||
@@ -977,7 +893,7 @@ export default router => {
|
||||
|
||||
// DELETE /api/v2/settings/api-key
|
||||
// Revokes (deletes) the user's API key.
|
||||
group.delete(/\/api-key$/, lib.registeredUser, async (req, res) => {
|
||||
group.delete(/\/api-key$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_api_keys === false) {
|
||||
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
|
||||
}
|
||||
@@ -1001,7 +917,7 @@ export default router => {
|
||||
|
||||
// GET /api/v2/settings/api-key/sharex-config
|
||||
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
|
||||
group.get(/\/api-key\/sharex-config$/, lib.registeredUser, async (req, res) => {
|
||||
group.get(/\/api-key\/sharex-config$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_api_keys === false) {
|
||||
return res.status(403).reply({ body: 'API keys are disabled' });
|
||||
}
|
||||
@@ -1078,7 +994,7 @@ export default router => {
|
||||
|
||||
// GET /api/v2/settings/invites
|
||||
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
|
||||
group.get(/\/invites$/, lib.registeredUser, async (req, res) => {
|
||||
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_invites === false) {
|
||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||
}
|
||||
@@ -1158,7 +1074,7 @@ export default router => {
|
||||
|
||||
// POST /api/v2/settings/invites/create
|
||||
// Generates a new invite token if eligible and slots remain.
|
||||
group.post(/\/invites\/create$/, lib.registeredUser, async (req, res) => {
|
||||
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_invites === false) {
|
||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||
}
|
||||
@@ -1224,7 +1140,7 @@ export default router => {
|
||||
|
||||
// POST /api/v2/settings/invites/delete
|
||||
// Deletes an unused invite token owned by the calling user.
|
||||
group.post(/\/invites\/delete$/, lib.registeredUser, async (req, res) => {
|
||||
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
|
||||
if (cfg.websrv.enable_user_invites === false) {
|
||||
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
|
||||
}
|
||||
@@ -1251,386 +1167,8 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── Passkey Management (registered users) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* GET /api/v2/settings/passkeys
|
||||
* List the current user's registered passkeys.
|
||||
*/
|
||||
group.get(/\/passkeys$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const rows = await db`
|
||||
SELECT id, credential_id, name, aaguid, created_at, last_used
|
||||
FROM passkey_credentials
|
||||
WHERE user_id = ${req.session.id}
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
const inUse = await sessionPasskey(req);
|
||||
return res.json({
|
||||
success: true,
|
||||
passkeys: rows.map(r => ({ ...r, provider: aaguidProvider(r.aaguid), current: !!inUse && r.credential_id === inUse }))
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] List error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/register/begin
|
||||
* Generate WebAuthn registration options for a logged-in user.
|
||||
*/
|
||||
group.post(/\/passkeys\/register\/begin$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
// Get existing credentials to exclude (prevent re-registering same authenticator)
|
||||
const existing = await db`
|
||||
SELECT credential_id FROM passkey_credentials
|
||||
WHERE user_id = ${req.session.id}
|
||||
`;
|
||||
const excludeCredentials = existing.map(r => ({ id: r.credential_id, type: 'public-key' }));
|
||||
|
||||
const challenge = generateChallenge({ type: 'user-register', userId: req.session.id });
|
||||
|
||||
// User handle: SHA256 of user_id encoded as base64url (stable, opaque)
|
||||
const userHandle = base64url(
|
||||
crypto.createHash('sha256').update(String(req.session.id)).digest().slice(0, 16)
|
||||
);
|
||||
|
||||
const body = req.post || req.body || {};
|
||||
const passkeyName = (body.name || '').trim().slice(0, 64) || null;
|
||||
|
||||
const options = buildRegistrationOptions({
|
||||
challenge,
|
||||
userId: userHandle,
|
||||
userName: req.session.login || req.session.user,
|
||||
displayName: req.session.display_name || req.session.user,
|
||||
excludeCredentials,
|
||||
rpId: getRpIdFromHost(req.headers.host)
|
||||
});
|
||||
|
||||
// Stash the intended passkey name in challenge metadata
|
||||
if (passkeyName) {
|
||||
// Re-consume and re-store with name (challenges are stored by their value)
|
||||
// Simpler: store name in a temporary Map keyed by challenge
|
||||
options._passkeyName = passkeyName;
|
||||
}
|
||||
|
||||
return res.json({ success: true, options, passkey_name: passkeyName });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] register/begin error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/register/finish
|
||||
* Verify attestation and store new passkey for the logged-in user.
|
||||
*/
|
||||
group.post(/\/passkeys\/register\/finish$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, attestationObject, credentialId, name } = body;
|
||||
|
||||
if (!challenge || !clientDataJSON || !attestationObject || !credentialId) {
|
||||
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
||||
}
|
||||
|
||||
// Consume and verify challenge
|
||||
let challengeMeta;
|
||||
try {
|
||||
challengeMeta = consumeChallenge(challenge);
|
||||
} catch (e) {
|
||||
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
|
||||
}
|
||||
if (challengeMeta.type !== 'user-register' || challengeMeta.userId !== req.session.id) {
|
||||
return res.json({ success: false, msg: 'Challenge mismatch' }, 400);
|
||||
}
|
||||
|
||||
// Verify attestation
|
||||
let regResult;
|
||||
try {
|
||||
regResult = await verifyRegistration({ challenge, clientDataJSON, attestationObject, credentialId, rpId: getRpIdFromHost(req.headers.host) });
|
||||
} catch (e) {
|
||||
console.warn('[PASSKEYS] Registration verification failed:', e.message);
|
||||
return res.json({ success: false, msg: `Registration failed: ${e.message}` }, 400);
|
||||
}
|
||||
|
||||
const passkeyName = ((name || '').trim().slice(0, 64)) || 'Passkey';
|
||||
|
||||
await db`
|
||||
INSERT INTO passkey_credentials (user_id, credential_id, public_key_spki, sign_count, aaguid, name)
|
||||
VALUES (${req.session.id}, ${credentialId}, ${regResult.spki}, ${regResult.signCount}, ${regResult.aaguid || null}, ${passkeyName})
|
||||
ON CONFLICT (credential_id) DO UPDATE
|
||||
SET sign_count = ${regResult.signCount}, last_used = NOW(), name = ${passkeyName}
|
||||
`;
|
||||
|
||||
return res.json({ success: true, credential_id: credentialId, name: passkeyName });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] register/finish error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/delete
|
||||
* Remove a passkey credential owned by the current user.
|
||||
* Uses POST + JSON body to avoid URL-encoding issues with credential IDs.
|
||||
*/
|
||||
group.post(/\/passkeys\/delete$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const body = req.post || req.body || {};
|
||||
const credentialId = body.credential_id;
|
||||
if (!credentialId) return res.json({ success: false, msg: 'Missing credential_id' }, 400);
|
||||
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
|
||||
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
|
||||
const result = await db`
|
||||
DELETE FROM passkey_credentials
|
||||
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
|
||||
RETURNING id
|
||||
`;
|
||||
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] Delete error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v2/settings/passkeys/:id
|
||||
* Legacy path — kept for compatibility.
|
||||
*/
|
||||
group.delete(/\/passkeys\/([^/]+)$/, lib.registeredUser, async (req, res) => {
|
||||
try {
|
||||
const credentialId = decodeURIComponent(req.url.pathname.split('/').pop());
|
||||
if (credentialId === await sessionPasskey(req)) return res.json({ success: false, msg: IN_USE_PASSKEY_MSG }, 400);
|
||||
if (await isLastPasskeyOfPasskeyOnly(req.session.id)) return res.json({ success: false, msg: LAST_PASSKEY_MSG }, 400);
|
||||
const result = await db`
|
||||
DELETE FROM passkey_credentials
|
||||
WHERE credential_id = ${credentialId} AND user_id = ${req.session.id}
|
||||
RETURNING id
|
||||
`;
|
||||
if (result.length === 0) return res.json({ success: false, msg: 'Passkey not found' }, 404);
|
||||
return res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] Delete error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/login/begin
|
||||
* Start a passkey login challenge for a registered user (no session required).
|
||||
*/
|
||||
group.post(/\/passkeys\/login\/begin$/, async (req, res) => {
|
||||
try {
|
||||
const challenge = generateChallenge({ type: 'user-login' });
|
||||
const options = buildAuthenticationOptions({ challenge, allowCredentials: [], rpId: getRpIdFromHost(req.headers.host) });
|
||||
return res.json({ success: true, options });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] login/begin error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v2/settings/passkeys/login/finish
|
||||
* Verify assertion and create a full registered-user session.
|
||||
*/
|
||||
group.post(/\/passkeys\/login\/finish$/, async (req, res) => {
|
||||
try {
|
||||
const body = req.post || req.body || {};
|
||||
const { challenge, clientDataJSON, authenticatorData, signature, credentialId } = body;
|
||||
|
||||
if (!challenge || !clientDataJSON || !authenticatorData || !signature || !credentialId) {
|
||||
return res.json({ success: false, msg: 'Missing required WebAuthn fields' }, 400);
|
||||
}
|
||||
|
||||
// Consume challenge
|
||||
let challengeMeta;
|
||||
try {
|
||||
challengeMeta = consumeChallenge(challenge);
|
||||
} catch (e) {
|
||||
return res.json({ success: false, msg: 'Challenge expired or invalid' }, 400);
|
||||
}
|
||||
if (challengeMeta.type !== 'user-login') {
|
||||
return res.json({ success: false, msg: 'Wrong challenge type' }, 400);
|
||||
}
|
||||
|
||||
// Look up credential — must belong to a registered (non-anon) user
|
||||
const credRows = await db`
|
||||
SELECT pc.user_id, pc.public_key_spki, pc.sign_count,
|
||||
u.login, u.user, u.activated, u.banned, u.ban_reason, u.ban_expires, u.force_password_change
|
||||
FROM passkey_credentials pc
|
||||
JOIN "user" u ON u.id = pc.user_id
|
||||
WHERE pc.credential_id = ${credentialId}
|
||||
AND u.login IS NOT NULL
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (credRows.length === 0) {
|
||||
return res.json({ success: false, msg: 'No registered account found for this passkey.' }, 401);
|
||||
}
|
||||
|
||||
const row = credRows[0];
|
||||
|
||||
if (row.banned) {
|
||||
return res.json({ success: false, msg: 'Account is banned: ' + (row.ban_reason || '') }, 403);
|
||||
}
|
||||
|
||||
if (!row.activated) {
|
||||
return res.json({ success: false, msg: 'This account is not activated. Please check your email.' }, 403);
|
||||
}
|
||||
|
||||
// Verify the assertion
|
||||
try {
|
||||
await verifyAuthentication({
|
||||
challenge,
|
||||
clientDataJSON,
|
||||
authenticatorData,
|
||||
signature,
|
||||
spki: row.public_key_spki,
|
||||
storedSignCount: row.sign_count,
|
||||
rpId: getRpIdFromHost(req.headers.host)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[PASSKEYS] login/finish verification failed:', e.message);
|
||||
return res.json({ success: false, msg: `Authentication failed: ${e.message}` }, 401);
|
||||
}
|
||||
|
||||
// Update sign count
|
||||
await db`
|
||||
UPDATE passkey_credentials SET sign_count = sign_count + 1, last_used = NOW()
|
||||
WHERE credential_id = ${credentialId}
|
||||
`;
|
||||
|
||||
// Create a full user session (same as normal login)
|
||||
const stamp = Math.floor(Date.now() / 1000);
|
||||
const ip = security.storableIP(security.getRealIP(req));
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex');
|
||||
const csrfToken = crypto.randomBytes(32).toString('hex');
|
||||
const sessRecord = {
|
||||
user_id: row.user_id,
|
||||
session: lib.sha256(sessionToken),
|
||||
csrf_token: csrfToken,
|
||||
browser: req.headers['user-agent'] || '',
|
||||
created_at: stamp,
|
||||
last_used: stamp,
|
||||
last_action: '/passkey-login',
|
||||
kmsi: 1,
|
||||
ip,
|
||||
passkey_credential_id: credentialId
|
||||
};
|
||||
await db`INSERT INTO "user_sessions" ${db(sessRecord, 'user_id', 'session', 'csrf_token', 'browser', 'created_at', 'last_used', 'last_action', 'kmsi', 'ip', 'passkey_credential_id')}`;
|
||||
|
||||
res.setHeader('Set-Cookie', `session=${sessionToken}; ${lib.getCookieOptions('Fri, 31 Dec 9999 23:59:59 GMT')}`);
|
||||
return res.json({ success: true, user: row.user, login: row.login, force_password_change: row.force_password_change || false });
|
||||
} catch (err) {
|
||||
console.error('[PASSKEYS] login/finish error:', err);
|
||||
return res.json({ success: false, msg: err.message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
// ═══════════════════ Invite Request (anon user) ═══════════════════
|
||||
|
||||
// Submit an invite request (requires a session — anon or registered, but mainly for anon)
|
||||
router.post(/^\/api\/v2\/invite-request\/?$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
if (!req.session.is_anon) {
|
||||
return res.json({ success: false, msg: 'You already have a registered account' }, 400);
|
||||
}
|
||||
|
||||
const fingerprint = req.session.fingerprint;
|
||||
if (!fingerprint) {
|
||||
return res.json({ success: false, msg: 'No passkey identity found' }, 400);
|
||||
}
|
||||
|
||||
// Check if there's already a pending request for this fingerprint
|
||||
const [existing] = await db`
|
||||
SELECT id, status FROM invite_requests
|
||||
WHERE fingerprint = ${fingerprint} AND status = 'pending'
|
||||
LIMIT 1
|
||||
`;
|
||||
if (existing) {
|
||||
return res.json({ success: false, msg: 'You already have a pending invite request' }, 409);
|
||||
}
|
||||
|
||||
const reason = (req.post.reason || '').trim().slice(0, 500);
|
||||
const ip = security.getRealIP(req);
|
||||
const ipHash = ip ? crypto.createHash('sha256').update(ip).digest('hex').slice(0, 16) : null;
|
||||
|
||||
await db`
|
||||
INSERT INTO invite_requests (user_id, fingerprint, ip_hash, reason)
|
||||
VALUES (${req.session.id}, ${fingerprint}, ${ipHash}, ${reason})
|
||||
`;
|
||||
|
||||
// Notify all admin users
|
||||
const anonLogin = req.session.login || req.session.user || 'anonymous';
|
||||
const admins = await db`SELECT id FROM "user" WHERE admin = true`;
|
||||
if (admins.length > 0) {
|
||||
const notifications = admins.map(a => ({
|
||||
user_id: a.id,
|
||||
type: 'invite_request',
|
||||
reference_id: 0,
|
||||
data: db.json({ username: anonLogin, reason: reason.slice(0, 100) })
|
||||
}));
|
||||
await db`INSERT INTO notifications ${db(notifications)}`;
|
||||
}
|
||||
|
||||
console.log(`[INVITE-REQ] New request from fp: ${fingerprint.slice(0, 12)}…`);
|
||||
|
||||
return res.json({ success: true, msg: 'Invite request submitted! An admin will review it shortly.' });
|
||||
} catch (err) {
|
||||
console.error('[INVITE-REQ] Submit error:', err);
|
||||
return res.json({ success: false, msg: 'Failed to submit request' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Check status of current invite request
|
||||
router.get(/^\/api\/v2\/invite-request\/status\/?$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const fingerprint = req.session.fingerprint;
|
||||
if (!fingerprint) {
|
||||
return res.json({ success: true, status: null });
|
||||
}
|
||||
|
||||
const [request] = await db`
|
||||
SELECT ir.id, ir.status, ir.created_at, ir.reviewed_at,
|
||||
it.token, it.is_used
|
||||
FROM invite_requests ir
|
||||
LEFT JOIN invite_tokens it ON it.id = ir.token_id
|
||||
WHERE ir.fingerprint = ${fingerprint}
|
||||
ORDER BY ir.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
if (!request) {
|
||||
return res.json({ success: true, status: null });
|
||||
}
|
||||
|
||||
// Auto-revoke if token was deleted or already used but status still says approved
|
||||
let status = request.status;
|
||||
if (status === 'approved' && (!request.token || request.is_used)) {
|
||||
await db`UPDATE invite_requests SET status = 'revoked' WHERE id = ${request.id}`;
|
||||
status = 'revoked';
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
status,
|
||||
created_at: request.created_at,
|
||||
reviewed_at: request.reviewed_at,
|
||||
token: status === 'approved' ? request.token : undefined
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[INVITE-REQ] Status check error:', err);
|
||||
return res.json({ success: false, msg: 'Failed to check status' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -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
@@ -5,263 +5,8 @@ import queue from "../../queue.mjs";
|
||||
import cfg from "../../config.mjs";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { logAnonActivity } from "../../anon_auth.mjs";
|
||||
import { canAnonDo, isAnonSession, getSessionOwnerName } from "../../settings.mjs";
|
||||
|
||||
export default router => {
|
||||
router.post(/^\/api\/v2\/tags\/bulk\/?$/, lib.loggedin, async (req, res) => {
|
||||
const isModOrAdmin = !!(req.session?.admin || req.session?.is_moderator);
|
||||
if (!isModOrAdmin) {
|
||||
return res.json({ success: false, msg: 'Bulk selection is only available to administrators and moderators' }, 403);
|
||||
}
|
||||
|
||||
const action = req.body?.action || req.post?.action || 'add';
|
||||
const rawIds = req.body?.item_ids || req.post?.item_ids;
|
||||
if (!rawIds || !Array.isArray(rawIds) || rawIds.length === 0) {
|
||||
return res.json({ success: false, msg: 'No items selected' }, 400);
|
||||
}
|
||||
|
||||
// Sanitize item IDs
|
||||
const itemIds = [...new Set(rawIds.map(id => +id).filter(id => Number.isInteger(id) && id > 0))].slice(0, 500);
|
||||
if (itemIds.length === 0) {
|
||||
return res.json({ success: false, msg: 'Invalid item IDs' }, 400);
|
||||
}
|
||||
|
||||
// 1. ADD TAGS
|
||||
if (action === 'add') {
|
||||
if (isAnonSession(req.session) && !canAnonDo('tag')) {
|
||||
return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
|
||||
}
|
||||
|
||||
let rawTags = req.body?.tags || req.post?.tags;
|
||||
if (typeof rawTags === 'string') {
|
||||
rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
if (!Array.isArray(rawTags) || rawTags.length === 0) {
|
||||
return res.json({ success: false, msg: 'No tags provided' }, 400);
|
||||
}
|
||||
|
||||
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
|
||||
const validTags = [...new Set(
|
||||
rawTags
|
||||
.map(t => (typeof t === 'string' ? t.trim() : ''))
|
||||
.filter(t => t.length > 0 && t.length <= 85 && !protectedTags.includes(t.toLowerCase()))
|
||||
)];
|
||||
|
||||
if (validTags.length === 0) {
|
||||
return res.json({ success: false, msg: 'Tags invalid or contain only reserved names' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const tagIds = [];
|
||||
for (const tagname of validTags) {
|
||||
let tagRow = await db`
|
||||
SELECT id FROM "tags"
|
||||
WHERE normalized = slugify(${tagname})
|
||||
LIMIT 1
|
||||
`;
|
||||
let tagid = tagRow?.[0]?.id;
|
||||
if (!tagid) {
|
||||
const created = await db`
|
||||
INSERT INTO "tags" ${db({ tag: tagname })}
|
||||
RETURNING id
|
||||
`;
|
||||
tagid = created?.[0]?.id;
|
||||
}
|
||||
if (tagid) tagIds.push({ id: +tagid, name: tagname });
|
||||
}
|
||||
|
||||
// Insert assignments
|
||||
for (const postid of itemIds) {
|
||||
for (const { id: tagid } of tagIds) {
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO "tags_assign" (tag_id, item_id, user_id)
|
||||
VALUES (${tagid}, ${postid}, ${+req.session.id})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
} catch (assignErr) {
|
||||
// Ignore duplicate errors if table has constraint
|
||||
if (assignErr.code !== '23505') {
|
||||
console.warn(`[BULK_TAG_ASSIGN_WARN] Item ${postid}, tag ${tagid}:`, assignErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await audit.log(req.session.id, 'bulk_add_tags', 'items', null, { item_ids: itemIds, tags: validTags }).catch(() => {});
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'bulk_tag',
|
||||
targetId: itemIds[0],
|
||||
details: { item_ids: itemIds, tags: validTags }
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Notify affected items asynchronously
|
||||
(async () => {
|
||||
for (const postid of itemIds) {
|
||||
try {
|
||||
const freshTags = await lib.getTags(postid);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
} catch {}
|
||||
}
|
||||
})().catch(() => {});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
count: itemIds.length,
|
||||
tags: validTags,
|
||||
msg: `Successfully added ${validTags.length} tag(s) to ${itemIds.length} item(s)`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BULK_ADD_TAGS_ERROR]', err);
|
||||
return res.json({ success: false, msg: 'Failed to add tags' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. REMOVE TAGS
|
||||
if (action === 'remove') {
|
||||
const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||
|
||||
let rawTags = req.body?.tags || req.post?.tags;
|
||||
if (typeof rawTags === 'string') {
|
||||
rawTags = rawTags.split(/[\n,]+/).map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
if (!Array.isArray(rawTags) || rawTags.length === 0) {
|
||||
return res.json({ success: false, msg: 'No tags provided to remove' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve tags to remove
|
||||
const tagRows = await db`
|
||||
SELECT id, tag, normalized FROM "tags"
|
||||
WHERE normalized IN ${db(rawTags.map(t => t.trim().toLowerCase()))}
|
||||
`;
|
||||
if (tagRows.length === 0) {
|
||||
return res.json({ success: false, msg: 'Tags not found' }, 404);
|
||||
}
|
||||
const tagIds = tagRows.map(r => r.id);
|
||||
|
||||
if (isModOrAdmin) {
|
||||
await db`
|
||||
DELETE FROM "tags_assign"
|
||||
WHERE item_id IN ${db(itemIds)}
|
||||
AND tag_id IN ${db(tagIds)}
|
||||
`;
|
||||
} else {
|
||||
// Normal user can only remove tags from items they own
|
||||
await db`
|
||||
DELETE FROM "tags_assign"
|
||||
WHERE item_id IN ${db(itemIds)}
|
||||
AND tag_id IN ${db(tagIds)}
|
||||
AND item_id IN (
|
||||
SELECT id FROM items WHERE username = ${getSessionOwnerName(req.session)}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
await audit.log(req.session.id, 'bulk_remove_tags', 'items', null, { item_ids: itemIds, tags: tagRows.map(r => r.tag) }).catch(() => {});
|
||||
|
||||
// Realtime notifications
|
||||
(async () => {
|
||||
for (const postid of itemIds) {
|
||||
try {
|
||||
const freshTags = await lib.getTags(postid);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
} catch {}
|
||||
}
|
||||
})().catch(() => {});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
count: itemIds.length,
|
||||
msg: `Successfully removed tag(s) from ${itemIds.length} item(s)`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BULK_REMOVE_TAGS_ERROR]', err);
|
||||
return res.json({ success: false, msg: 'Failed to remove tags' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. SET RATING (sfw / nsfw / nsfl)
|
||||
if (action === 'set_rating') {
|
||||
if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
|
||||
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
|
||||
}
|
||||
|
||||
const rating = (req.body?.rating || req.post?.rating || '').toLowerCase();
|
||||
if (!['sfw', 'nsfw', 'nsfl'].includes(rating)) {
|
||||
return res.json({ success: false, msg: 'Invalid rating. Choose sfw, nsfw, or nsfl' }, 400);
|
||||
}
|
||||
|
||||
const isModOrAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||
const targetTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : nsflId);
|
||||
|
||||
try {
|
||||
let eligibleIds = itemIds;
|
||||
if (!isModOrAdmin) {
|
||||
const ownedItems = await db`
|
||||
SELECT id FROM items
|
||||
WHERE id IN ${db(itemIds)}
|
||||
AND username = ${getSessionOwnerName(req.session)}
|
||||
AND active = true AND is_deleted = false
|
||||
`;
|
||||
eligibleIds = ownedItems.map(r => r.id);
|
||||
}
|
||||
|
||||
if (eligibleIds.length === 0) {
|
||||
return res.json({ success: false, msg: 'You do not have permission to rate the selected items' }, 403);
|
||||
}
|
||||
|
||||
await db.begin(async sql => {
|
||||
// Delete existing rating tags
|
||||
await sql`
|
||||
DELETE FROM tags_assign
|
||||
WHERE item_id IN ${sql(eligibleIds)}
|
||||
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
|
||||
`;
|
||||
|
||||
// Insert new rating tags
|
||||
for (const id of eligibleIds) {
|
||||
await sql`
|
||||
INSERT INTO tags_assign (item_id, tag_id, user_id)
|
||||
VALUES (${id}, ${targetTagId}, ${+req.session.id})
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
await audit.log(req.session.id, 'bulk_set_rating', 'items', null, { item_ids: eligibleIds, rating }).catch(() => {});
|
||||
|
||||
// Queue blur thumbnail verification where needed
|
||||
(async () => {
|
||||
for (const id of eligibleIds) {
|
||||
try {
|
||||
const blurPath = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||
await fs.promises.access(blurPath).catch(() => queue.genBlurredThumbnail(id, false));
|
||||
const freshTags = await lib.getTags(id);
|
||||
await db.notify('tags', JSON.stringify({ item_id: id, fresh: true, tags: freshTags }));
|
||||
} catch {}
|
||||
}
|
||||
})().catch(() => {});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
count: eligibleIds.length,
|
||||
rating,
|
||||
msg: `Successfully set rating to ${rating.toUpperCase()} for ${eligibleIds.length} item(s)`
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[BULK_SET_RATING_ERROR]', err);
|
||||
return res.json({ success: false, msg: 'Failed to update rating' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return res.json({ success: false, msg: 'Unknown bulk action' }, 400);
|
||||
});
|
||||
|
||||
router.group(/^\/api\/v2\/tags\/(?<postid>\d+)/, group => {
|
||||
group.get(/$/, lib.loggedin, async (req, res) => {
|
||||
// get tags
|
||||
@@ -272,18 +17,14 @@ export default router => {
|
||||
});
|
||||
}
|
||||
|
||||
const subf0ck = req.url?.qs?.subf0ck_id || req.url?.qs?.subf0ck || null;
|
||||
return res.json({
|
||||
success: true,
|
||||
tags: await lib.getTags(+req.params.postid, req.session, subf0ck)
|
||||
tags: await lib.getTags(+req.params.postid, req.session)
|
||||
});
|
||||
});
|
||||
|
||||
group.post(/$/, lib.loggedin, async (req, res) => {
|
||||
// assign and/or create tag
|
||||
if (isAnonSession(req.session) && !canAnonDo('tag')) {
|
||||
return res.json({ success: false, msg: 'Anonymous tagging is disabled' }, 403);
|
||||
}
|
||||
const rawTagname = req.post?.tagname || req.body?.tagname;
|
||||
if (!req.params.postid || !rawTagname) {
|
||||
return res.json({
|
||||
@@ -310,22 +51,6 @@ export default router => {
|
||||
});
|
||||
}
|
||||
|
||||
const subf0ck = req.post?.subf0ck_id || req.body?.subf0ck_id || req.post?.subf0ck || req.body?.subf0ck || null;
|
||||
let albumItemId = null;
|
||||
if (subf0ck) {
|
||||
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
|
||||
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
if (!albumItemId) {
|
||||
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
}
|
||||
|
||||
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||
|
||||
try {
|
||||
let tagid = (await db`
|
||||
select id
|
||||
@@ -334,257 +59,91 @@ export default router => {
|
||||
`)?.[0]?.id;
|
||||
|
||||
if (!tagid) { // create new tag
|
||||
try {
|
||||
tagid = (await db`
|
||||
insert into "tags" ${db({
|
||||
tag: tagname
|
||||
})
|
||||
}
|
||||
returning id
|
||||
`)[0].id;
|
||||
} catch (e) {
|
||||
tagid = (await db`
|
||||
select id
|
||||
from "tags"
|
||||
where normalized = slugify(${tagname})
|
||||
`)?.[0]?.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (albumItemId) {
|
||||
await db`
|
||||
INSERT INTO "album_items_tags_assign" (album_item_id, tag_id, user_id)
|
||||
VALUES (${+albumItemId}, ${+tagid}, ${+req.session.id})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
await db`
|
||||
INSERT INTO "tags_assign" (item_id, tag_id, user_id)
|
||||
VALUES (${+postid}, ${+tagid}, ${+req.session.id})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
|
||||
// Ensure this album item also inherits parent's rating tag if it doesn't have one
|
||||
const subRating = await db`
|
||||
SELECT tag_id FROM album_items_tags_assign
|
||||
WHERE album_item_id = ${+albumItemId} AND tag_id IN (1, 2, ${nsflId})
|
||||
LIMIT 1
|
||||
`;
|
||||
if (subRating.length === 0) {
|
||||
const parentRating = await db`
|
||||
SELECT tag_id FROM tags_assign
|
||||
WHERE item_id = ${+postid} AND tag_id IN (1, 2, ${nsflId})
|
||||
LIMIT 1
|
||||
`;
|
||||
if (parentRating.length > 0) {
|
||||
await db`
|
||||
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
|
||||
VALUES (${+albumItemId}, ${parentRating[0].tag_id}, ${+req.session.id})
|
||||
ON CONFLICT DO NOTHING
|
||||
`.catch(() => {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
tag_id: +tagid,
|
||||
item_id: +postid,
|
||||
user_id: +req.session.id
|
||||
tagid = (await db`
|
||||
insert into "tags" ${db({
|
||||
tag: tagname
|
||||
})
|
||||
}
|
||||
`;
|
||||
returning id
|
||||
`)[0].id;
|
||||
}
|
||||
|
||||
await db`
|
||||
insert into "tags_assign" ${db({
|
||||
tag_id: +tagid,
|
||||
item_id: +postid,
|
||||
user_id: +req.session.id
|
||||
})
|
||||
}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error('[ADD_TAG_ERROR]', err);
|
||||
const isDuplicate = err.code === '23505' || err.constraint?.includes('tags_assign');
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: isDuplicate ? 'Tag already exists' : 'Failed to add tag',
|
||||
tags: await lib.getTags(postid, req.session, subf0ck)
|
||||
tags: await lib.getTags(postid)
|
||||
});
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(postid, req.session, subf0ck);
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'tag',
|
||||
targetId: postid,
|
||||
details: { tag: tagname, subf0ck_id: subf0ck }
|
||||
});
|
||||
}
|
||||
console.log(`[API] Notifying 'tags' for item ${postid} (subf0ck: ${subf0ck || 'none'}) with ${freshTags.length} tags`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, subf0ck_id: subf0ck, fresh: true, tags: freshTags }));
|
||||
const freshTags = await lib.getTags(postid);
|
||||
console.log(`[API] Notifying 'tags' for item ${postid} with ${freshTags.length} tags`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
postid: postid,
|
||||
subf0ck_id: subf0ck,
|
||||
tag: tagname,
|
||||
tags: freshTags
|
||||
});
|
||||
});
|
||||
|
||||
group.put(/\/cycle-rating$/, lib.loggedin, async (req, res) => {
|
||||
if (isAnonSession(req.session) && !canAnonDo('rate_item')) {
|
||||
return res.json({ success: false, msg: 'Anonymous rating is disabled' }, 403);
|
||||
}
|
||||
group.put(/\/cycle-rating$/, lib.modAuth, async (req, res) => {
|
||||
if (!req.params.postid) return res.json({ success: false, msg: 'missing postid' });
|
||||
|
||||
const postid = +req.params.postid;
|
||||
const item = await db`
|
||||
SELECT id, username, active, is_deleted
|
||||
FROM items
|
||||
WHERE id = ${postid} AND active = true AND is_deleted = false
|
||||
LIMIT 1
|
||||
`;
|
||||
if (item.length === 0) {
|
||||
return res.json({ success: false, msg: 'Item not found' }, 404);
|
||||
}
|
||||
|
||||
const ownerName = getSessionOwnerName(req.session);
|
||||
const isOwner = !!(item[0].username && ownerName && item[0].username.toLowerCase() === ownerName.toLowerCase());
|
||||
const isAdmin = !!(req.session.admin || req.session.is_moderator);
|
||||
if (!isOwner && !isAdmin) {
|
||||
return res.json({ success: false, msg: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||
const nsflId = cfg.nsfl_tag_id || 3;
|
||||
// Cycle: SFW(1) → NSFW(2) → NSFL(nsflId) → SFW(1); untagged items jump straight to SFW
|
||||
const cycle = [1, 2, nsflId];
|
||||
let nextTagId;
|
||||
let ratingTagId = 0;
|
||||
const currentTags = await lib.getTags(postid);
|
||||
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
|
||||
|
||||
const subf0ck = req.body?.subf0ck_id || req.post?.subf0ck_id || req.url?.qs?.subf0ck_id || null;
|
||||
let albumItemId = null;
|
||||
if (subf0ck) {
|
||||
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
|
||||
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
if (!albumItemId) {
|
||||
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
let nextTagId;
|
||||
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
|
||||
if (reqRating === 'sfw') {
|
||||
nextTagId = 1;
|
||||
} else if (reqRating === 'nsfw') {
|
||||
nextTagId = 2;
|
||||
} else if (reqRating === 'nsfl') {
|
||||
nextTagId = nsflId;
|
||||
} else {
|
||||
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
|
||||
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
try {
|
||||
await db.begin(async sql => {
|
||||
// Lock the item row exclusively
|
||||
await sql`SELECT id FROM items WHERE id = ${postid} FOR UPDATE`;
|
||||
// Remove any existing rating tag
|
||||
await db`DELETE FROM tags_assign WHERE item_id = ${postid} AND tag_id = ANY(ARRAY[1, 2, ${nsflId}]::int[])`;
|
||||
if (nextTagId > 0) {
|
||||
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
|
||||
}
|
||||
|
||||
if (albumItemId) {
|
||||
const existingRating = await sql`
|
||||
SELECT tag_id FROM album_items_tags_assign
|
||||
WHERE album_item_id = ${albumItemId}
|
||||
AND tag_id IN (1, 2, ${nsflId})
|
||||
LIMIT 1
|
||||
`;
|
||||
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
|
||||
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
|
||||
if (reqRating === 'sfw') {
|
||||
nextTagId = 1;
|
||||
} else if (reqRating === 'nsfw') {
|
||||
nextTagId = 2;
|
||||
} else if (reqRating === 'nsfl') {
|
||||
nextTagId = nsflId;
|
||||
} else {
|
||||
const cycleIdx = cycle.indexOf(ratingTagId);
|
||||
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
await sql`
|
||||
DELETE FROM album_items_tags_assign
|
||||
WHERE album_item_id = ${albumItemId}
|
||||
AND tag_id IN (1, 2, ${nsflId})
|
||||
`;
|
||||
if (nextTagId > 0) {
|
||||
await sql`
|
||||
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
|
||||
VALUES (${albumItemId}, ${nextTagId}, ${+req.session.id})
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
const existingRating = await sql`
|
||||
SELECT tag_id FROM tags_assign
|
||||
WHERE item_id = ${postid}
|
||||
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
|
||||
ORDER BY tag_id DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
ratingTagId = existingRating.length > 0 ? existingRating[0].tag_id : 0;
|
||||
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
|
||||
if (reqRating === 'sfw') {
|
||||
nextTagId = 1;
|
||||
} else if (reqRating === 'nsfw') {
|
||||
nextTagId = 2;
|
||||
} else if (reqRating === 'nsfl') {
|
||||
nextTagId = nsflId;
|
||||
} else {
|
||||
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (−1+1)%3 = 0 → SFW
|
||||
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
|
||||
}
|
||||
|
||||
// Remove ALL existing rating tags for this item atomically
|
||||
await sql`
|
||||
DELETE FROM tags_assign
|
||||
WHERE item_id = ${postid}
|
||||
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
|
||||
`;
|
||||
|
||||
if (nextTagId > 0) {
|
||||
await sql`
|
||||
INSERT INTO tags_assign (item_id, tag_id, user_id)
|
||||
VALUES (${postid}, ${nextTagId}, ${+req.session.id})
|
||||
`;
|
||||
}
|
||||
|
||||
// Propagate rating to all album sub-items if this is an album
|
||||
const albumCheck = await sql`SELECT id FROM items WHERE id = ${postid} AND is_album = true LIMIT 1`;
|
||||
if (albumCheck.length > 0) {
|
||||
const subItems = await sql`SELECT id FROM album_items WHERE item_id = ${postid}`;
|
||||
for (const sub of subItems) {
|
||||
await sql`
|
||||
DELETE FROM album_items_tags_assign
|
||||
WHERE album_item_id = ${sub.id}
|
||||
AND tag_id IN (1, 2, ${nsflId})
|
||||
`;
|
||||
if (nextTagId > 0) {
|
||||
await sql`
|
||||
INSERT INTO album_items_tags_assign (album_item_id, tag_id, user_id)
|
||||
VALUES (${sub.id}, ${nextTagId}, ${+req.session.id})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Automatically generate/verify blurred thumbnail on cycle
|
||||
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
|
||||
try {
|
||||
await fs.promises.access(blurPath);
|
||||
} catch {
|
||||
await queue.genBlurredThumbnail(postid, false);
|
||||
}
|
||||
});
|
||||
// Automatically generate/verify blurred thumbnail on cycle
|
||||
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
|
||||
try {
|
||||
await fs.promises.access(blurPath);
|
||||
} catch {
|
||||
await queue.genBlurredThumbnail(postid, false);
|
||||
}
|
||||
|
||||
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
|
||||
const { label, cls } = labels[nextTagId] || { label: 'SFW', cls: 'sfw' };
|
||||
const { label, cls } = labels[nextTagId];
|
||||
|
||||
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId }).catch(() => {});
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'cycle_rating',
|
||||
targetId: postid,
|
||||
details: { from: ratingTagId, to: nextTagId, rating_label: label }
|
||||
});
|
||||
}
|
||||
await audit.log(req.session.id, 'cycle_rating', 'item', postid, { from: ratingTagId, to: nextTagId });
|
||||
const freshTags = await lib.getTags(postid);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags })).catch(() => {});
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
return res.json({ success: true, rating_tag_id: nextTagId, rating_label: label, rating_class: cls });
|
||||
} catch (err) {
|
||||
console.error('[CYCLE_RATING_ERROR]', err);
|
||||
return res.json({ success: false, msg: 'Failed to update rating' });
|
||||
}
|
||||
});
|
||||
@@ -662,83 +221,39 @@ export default router => {
|
||||
const postid = +req.params.postid;
|
||||
const tagname = decodeURIComponent(req.params.tagname);
|
||||
|
||||
const subf0ck = req.post?.subf0ck_id || req.body?.subf0ck_id || req.url?.qs?.subf0ck_id || req.url?.qs?.subf0ck || null;
|
||||
let albumItemId = null;
|
||||
if (subf0ck) {
|
||||
if (Number.isInteger(+subf0ck) && +subf0ck > 0) {
|
||||
const row = await db`SELECT id FROM album_items WHERE (id = ${+subf0ck} OR (item_id = ${postid} AND order_index = ${+subf0ck})) AND item_id = ${postid} LIMIT 1`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
if (!albumItemId) {
|
||||
const row = await db`SELECT id FROM album_items WHERE slug = ${String(subf0ck)} AND item_id = ${postid} LIMIT 1`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
}
|
||||
const tags = await lib.getTags(postid);
|
||||
|
||||
const tags = await lib.getTags(postid, req.session, subf0ck);
|
||||
|
||||
const tagid = tags.filter(t => t.tag === tagname || t.normalized === tagname.toLowerCase())[0]?.id ?? null;
|
||||
const tagid = tags.filter(t => t.tag === tagname)[0]?.id ?? null;
|
||||
|
||||
if (!tagid) {
|
||||
return res.json({
|
||||
success: false,
|
||||
msg: 'tag is not assigned',
|
||||
tags: await lib.getTags(postid, req.session, subf0ck)
|
||||
tags: await lib.getTags(postid)
|
||||
});
|
||||
}
|
||||
|
||||
let reply = false;
|
||||
if (albumItemId) {
|
||||
const q = await db`
|
||||
DELETE FROM album_items_tags_assign
|
||||
WHERE album_item_id = ${+albumItemId}
|
||||
AND tag_id = ${+tagid}
|
||||
`;
|
||||
reply = !!q;
|
||||
|
||||
const otherUses = await db`
|
||||
SELECT 1 FROM album_items_tags_assign aita
|
||||
JOIN album_items ai ON ai.id = aita.album_item_id
|
||||
WHERE ai.item_id = ${postid} AND aita.tag_id = ${+tagid}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (otherUses.length === 0) {
|
||||
await db`
|
||||
DELETE FROM tags_assign
|
||||
WHERE item_id = ${postid} AND tag_id = ${+tagid}
|
||||
`.catch(() => {});
|
||||
}
|
||||
} else {
|
||||
let q = await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = ${+tagid}
|
||||
and item_id = ${+postid}
|
||||
`;
|
||||
reply = !!q;
|
||||
}
|
||||
let q = await db`
|
||||
delete from "tags_assign"
|
||||
where tag_id = ${+tagid}
|
||||
and item_id = ${+postid}
|
||||
`;
|
||||
const reply = !!q;
|
||||
|
||||
if (reply) {
|
||||
const reason = req.post?.reason || req.url?.qs?.reason || 'No reason provided';
|
||||
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, subf0ck_id: subf0ck, reason });
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'delete_tag',
|
||||
targetId: postid,
|
||||
details: { tag: tagname, subf0ck_id: subf0ck, reason }
|
||||
});
|
||||
}
|
||||
const reason = req.post.reason || req.url.qs.reason || 'No reason provided';
|
||||
await audit.log(req.session.id, 'delete_tag', 'item', postid, { tag: tagname, reason });
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(postid, req.session, subf0ck);
|
||||
console.log(`[API] Notifying 'tags' (delete) for item ${postid} (subf0ck: ${subf0ck || 'none'})`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, subf0ck_id: subf0ck, fresh: true, tags: freshTags }));
|
||||
const freshTags = await lib.getTags(postid);
|
||||
console.log(`[API] Notifying 'tags' (delete) for item ${postid}`);
|
||||
await db.notify('tags', JSON.stringify({ item_id: postid, fresh: true, tags: freshTags }));
|
||||
|
||||
return res.json({
|
||||
success: reply,
|
||||
tagid,
|
||||
subf0ck_id: subf0ck,
|
||||
tags: freshTags
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -3,13 +3,11 @@ import { spawn as _spawnRaw } from 'child_process';
|
||||
import db from '../../sql.mjs';
|
||||
import lib from '../../lib.mjs';
|
||||
import cfg from '../../config.mjs';
|
||||
import { getEnableItemSlugs, canAnonDo, isAnonSession } from '../../settings.mjs';
|
||||
import { getEnableItemSlugs } from '../../settings.mjs';
|
||||
import { applyWordFilter } from '../../wordfilter.mjs';
|
||||
import queue from '../../queue.mjs';
|
||||
import path from "path";
|
||||
import f0cklib from "../../routeinc/f0cklib.mjs";
|
||||
import { addPrivateItem } from "../../private_items.mjs";
|
||||
import { TRANSCODE_TO_MP4, transcodeToMp4 } from "../../transcode.mjs";
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
// In-memory job progress map (keyed by jobId string)
|
||||
@@ -233,21 +231,9 @@ const collectBody = (req) => {
|
||||
|
||||
export default router => {
|
||||
router.group(/^\/api\/v2/, group => {
|
||||
const uploadApiAuth = (req, res, next) => {
|
||||
if (!req.session) {
|
||||
return res.json({ success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
if (isAnonSession(req.session)) {
|
||||
if (!canAnonDo('upload')) {
|
||||
return res.json({ success: false, msg: 'Action requires a registered account or anonymous upload permission' }, 403);
|
||||
}
|
||||
return next();
|
||||
}
|
||||
return lib.registeredUser(req, res, next);
|
||||
};
|
||||
|
||||
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
|
||||
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, uploadApiAuth, (req, res) => {
|
||||
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.loggedin, (req, res) => {
|
||||
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
|
||||
const state = progressMap.get(jobId);
|
||||
res.setHeader?.('Cache-Control', 'no-store');
|
||||
@@ -320,7 +306,7 @@ export default router => {
|
||||
return [...new Set(tags)];
|
||||
};
|
||||
|
||||
group.get(/\/meta\/extract-url$/, uploadApiAuth, async (req, res) => {
|
||||
group.get(/\/meta\/extract-url$/, lib.loggedin, async (req, res) => {
|
||||
const url = req.url.qs?.url;
|
||||
if (!url) return res.json({ success: false, msg: 'URL required' }, 400);
|
||||
|
||||
@@ -371,7 +357,7 @@ export default router => {
|
||||
}
|
||||
});
|
||||
|
||||
group.post(/\/upload-url$/, uploadApiAuth, async (req, res) => {
|
||||
group.post(/\/upload-url$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
if (!cfg.websrv.web_url_upload) {
|
||||
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
|
||||
@@ -477,16 +463,10 @@ export default router => {
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (targetVisibility === 2) {
|
||||
addPrivateItem(itemid, filename, req.session.user);
|
||||
}
|
||||
|
||||
// Auto-subscribe uploader
|
||||
if (cfg.enable_comments !== false) {
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
|
||||
}
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${req.session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) { console.error('[UPLOAD-URL] Auto-subscribe error:', err); }
|
||||
|
||||
// Download YouTube thumbnail as our thumbnail
|
||||
try {
|
||||
@@ -537,9 +517,7 @@ export default router => {
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility,
|
||||
is_album: false,
|
||||
album_count: 0
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] YouTube new_item notify failed:', err);
|
||||
@@ -735,13 +713,6 @@ export default router => {
|
||||
source = source.replace(/\.mkv$/, '.mp4');
|
||||
mime = 'video/mp4';
|
||||
}
|
||||
if (TRANSCODE_TO_MP4.has(mime)) { // .mpg etc.: browsers can't play it, re-encode
|
||||
const converted = source.replace(/\.[^./]+$/, '') + '.conv.mp4';
|
||||
await transcodeToMp4(queue, source, converted);
|
||||
await fs.unlink(source).catch(() => {});
|
||||
source = converted;
|
||||
mime = 'video/mp4';
|
||||
}
|
||||
if (source.match(/\.opus$/)) {
|
||||
await queue.spawn('ffmpeg', ['-i', source, '-codec', 'copy', source.replace(/\.opus$/, '.ogg')]);
|
||||
await fs.unlink(source).catch(() => {});
|
||||
@@ -826,30 +797,16 @@ export default router => {
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (targetVisibility === 2) {
|
||||
addPrivateItem(itemid, filename, session.user);
|
||||
}
|
||||
|
||||
if (cfg.enable_comments !== false) {
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) { }
|
||||
}
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT DO NOTHING`;
|
||||
} catch (err) { }
|
||||
|
||||
try {
|
||||
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
|
||||
if (mime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
await queue.genBlurredThumbnail(itemid, isApprovalRequired);
|
||||
} catch (err) {
|
||||
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
|
||||
const outPath = path.join(tDir, `${itemid}.webp`);
|
||||
if (mime.startsWith('audio/')) {
|
||||
await queue.genAudioPlaceholder(outPath).catch(() => {});
|
||||
} else {
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', outPath]).catch(() => {});
|
||||
}
|
||||
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
|
||||
}
|
||||
|
||||
// Assign rating tag (only if a rating was selected)
|
||||
@@ -883,9 +840,7 @@ export default router => {
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!is_oc,
|
||||
slug: itemSlug,
|
||||
visibility: targetVisibility,
|
||||
is_album: false,
|
||||
album_count: 0
|
||||
visibility: targetVisibility
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[UPLOAD-URL] new_item notify failed:', err);
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 >>123456
|
||||
formatted = formatted.replace(/(?<!>)>>(\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">>>$1</a>');
|
||||
// 6. Format quote lines (greentext)
|
||||
formatted = formatted.replace(/(^|<br\s*\/?>)>(?!>)([^\n<]+)/g, '$1<span class="chan-quote">>$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(/(?<!>)>>(\d+)\b/g)) {
|
||||
quotes.add(Number(m[1]));
|
||||
}
|
||||
for (const m of com.matchAll(/(?<![>\w])>>(\d+)\b/g)) {
|
||||
quotes.add(Number(m[1]));
|
||||
}
|
||||
return Array.from(quotes);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 0. GET /4 -> redirect to default board catalogue
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/?$/, lib.chanAuth, (req, res) => res.redirect('/4/wsg/catalogue'));
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1. GET /4/:board/catalogue (and alias /4/:board/catalog)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?:catalogue|catalog)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
|
||||
try {
|
||||
const pages = await fetchWithProxy(`https://a.4cdn.org/${board}/catalog.json`);
|
||||
const threads = [];
|
||||
for (const page of pages) {
|
||||
for (const t of (page.threads || [])) {
|
||||
threads.push({
|
||||
no: t.no,
|
||||
sub: t.sub || '',
|
||||
com: t.com ? t.com.replace(/<br\s*\/?>/gi, ' ').replace(/<[^>]+>/g, '').slice(0, 180) : '',
|
||||
replies: t.replies || 0,
|
||||
images: t.images || 0,
|
||||
tim: t.tim,
|
||||
ext: t.ext,
|
||||
sticky: !!t.sticky,
|
||||
closed: !!t.closed,
|
||||
time: t.time,
|
||||
thumb: t.tim ? `/api/v2/scroller/external/4chan/${board}/media/${t.tim}s.jpg` : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const opUrls = [];
|
||||
threads.forEach(t => {
|
||||
if (t.tim && t.ext) {
|
||||
const ext = (t.ext || '').toLowerCase();
|
||||
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${t.ext}`);
|
||||
opUrls.push(`https://i.4cdn.org/${board}/${t.tim}${ext}`);
|
||||
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${t.ext}`);
|
||||
opUrls.push(`http://i.4cdn.org/${board}/${t.tim}${ext}`);
|
||||
opUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${t.tim}${ext}`);
|
||||
}
|
||||
});
|
||||
const rehosts = {};
|
||||
const rehostPaths = {};
|
||||
if (opUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${opUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehostPaths[r.id] = itemPath(r.id, r.slug);
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
threads.forEach(t => {
|
||||
t.local_id = (t.tim && rehosts[t.tim]) || (t.tim && t.ext && rehosts[`https://i.4cdn.org/${board}/${t.tim}${t.ext}`]) || null;
|
||||
t.local_path = t.local_id ? rehostPaths[t.local_id] : null;
|
||||
});
|
||||
|
||||
const data = {
|
||||
board,
|
||||
threads,
|
||||
common_boards: COMMON_BOARDS,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
page_meta: {
|
||||
title: `/${board}/ - Catalogue`,
|
||||
description: `4chan /${board}/ thread catalogue`,
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/catalogue`
|
||||
}
|
||||
};
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('chan/catalogue', data, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to fetch catalogue for /${board}/:`, err.message);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load catalogue for /${board}/. The board may not exist or 4chan is currently unreachable.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function formatPosts(posts, board, rehostMap, userFavSet, targetPostNo = null, lang = 'en') {
|
||||
// Build reply mapping (targetPostNo -> array of replier post numbers)
|
||||
const postNoSet = new Set(posts.map(p => p.no));
|
||||
const replyMap = new Map();
|
||||
|
||||
for (const p of posts) {
|
||||
if (!p.com) continue;
|
||||
const quotes = extractQuotes(p.com);
|
||||
for (const qNo of quotes) {
|
||||
if (postNoSet.has(qNo) && qNo !== p.no) {
|
||||
if (!replyMap.has(qNo)) replyMap.set(qNo, []);
|
||||
replyMap.get(qNo).push(p.no);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return posts.map(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
const externalMediaUrl = p.tim ? `https://i.4cdn.org/${board}/${p.tim}${ext}` : null;
|
||||
const rehostInfo = (p.tim && rehostMap[p.tim]) || (externalMediaUrl && rehostMap[externalMediaUrl]) || null;
|
||||
const localId = rehostInfo ? rehostInfo.id : null;
|
||||
const effectiveTime = (localId && rehostInfo?.stamp) ? Number(rehostInfo.stamp) : p.time;
|
||||
const isoStr = new Date(effectiveTime * 1000).toISOString();
|
||||
const postReplies = replyMap.get(p.no) || [];
|
||||
|
||||
return {
|
||||
no: p.no,
|
||||
sub: p.sub || '',
|
||||
com_raw: p.com || '',
|
||||
com_formatted: formatComment(p.com || ''),
|
||||
name: p.name || 'Anonymous',
|
||||
trip: p.trip || '',
|
||||
time: effectiveTime,
|
||||
timeago: lib.timeAgo(effectiveTime * 1000, lang),
|
||||
date_str: (localId && rehostInfo?.stamp) ? new Date(effectiveTime * 1000).toLocaleString() : (p.now || new Date(effectiveTime * 1000).toLocaleString()),
|
||||
iso_str: isoStr,
|
||||
has_media: !!(p.tim && p.ext),
|
||||
tim: p.tim,
|
||||
ext: ext,
|
||||
filename: p.filename ? `${p.filename}${ext}` : null,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
fsize: p.fsize ? lib.formatSize(p.fsize) : null,
|
||||
dest: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}` : null,
|
||||
thumb: p.tim ? `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg` : null,
|
||||
external_media_url: externalMediaUrl,
|
||||
is_video: isVideo,
|
||||
is_image: isImage,
|
||||
local_id: localId,
|
||||
local_path: rehostInfo ? rehostInfo.path : null,
|
||||
rehosted: !!localId,
|
||||
user_has_favorited: localId ? userFavSet.has(Number(localId)) : false,
|
||||
is_onara_active: targetPostNo ? p.no === targetPostNo : false,
|
||||
replies: postReplies,
|
||||
replies_count: postReplies.length
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 2. GET /4/:board/:thread — Overview of all posts in that thread
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
|
||||
try {
|
||||
const data = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = data.posts || [];
|
||||
if (!posts.length) throw new Error('Empty thread data');
|
||||
|
||||
const op = posts[0];
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
|
||||
// Check which media URLs are already rehosted on this platform
|
||||
const rehosts = {};
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const rehostMap = {};
|
||||
const rehostPaths = {};
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
|
||||
rehostPaths[r.id] = info.path;
|
||||
rehosts[r.src] = r.id;
|
||||
rehostMap[r.src] = info;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) {
|
||||
rehosts[m[1]] = r.id;
|
||||
rehostMap[m[1]] = info;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Rehost check error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Find which rehosted items the current user has favorited
|
||||
const userFavSet = new Set();
|
||||
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
|
||||
if (req.session?.id && rehostedIdList.length > 0) {
|
||||
try {
|
||||
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
|
||||
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Format posts for template
|
||||
const formattedPosts = formatPosts(posts, board, rehostMap, userFavSet, null, req.lang || 'en');
|
||||
|
||||
const firstMediaPost = formattedPosts.find(p => p.has_media);
|
||||
|
||||
const viewData = {
|
||||
board,
|
||||
tid,
|
||||
op,
|
||||
posts: formattedPosts,
|
||||
media_count: mediaPosts.length,
|
||||
first_media_no: firstMediaPost ? firstMediaPost.no : null,
|
||||
rehosts_count: Object.keys(rehosts).length,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
csrf_token: req.session?.csrf_token || '',
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
page_meta: {
|
||||
title: `/${board}/${tid} - ${op.sub || 'Thread Overview'}`,
|
||||
description: op.sub || (op.com ? op.com.replace(/<[^>]+>/g, '').slice(0, 160) : `4chan /${board}/ thread ${tid}`),
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/${tid}`
|
||||
}
|
||||
};
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('chan/thread', viewData, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to load thread /${board}/${tid}:`, err.message);
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load thread /${board}/${tid}. It may be archived or 4chan is currently unreachable.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 3. GET /4/:board/:thread/:post — Show media item in normal item view
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/4\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/(?<post>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
const postNo = Number(req.params.post);
|
||||
|
||||
try {
|
||||
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = threadData.posts || [];
|
||||
if (!posts.length) throw new Error('Empty thread data');
|
||||
|
||||
const op = posts[0];
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
if (!mediaPosts.length) {
|
||||
// No media at all in thread, redirect to thread overview
|
||||
return res.redirect(`/4/${board}/${tid}`);
|
||||
}
|
||||
|
||||
// Find target post
|
||||
let targetPost = mediaPosts.find(p => p.no === postNo);
|
||||
if (!targetPost) {
|
||||
// Post has no media or doesn't exist; pick closest or first media post
|
||||
targetPost = mediaPosts[0];
|
||||
}
|
||||
|
||||
// Check rehost status in DB
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const rehosts = {};
|
||||
const rehostMap = {};
|
||||
const rehostPaths = {};
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src, stamp, slug FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
const info = { id: r.id, stamp: r.stamp, path: itemPath(r.id, r.slug) };
|
||||
rehostPaths[r.id] = info.path;
|
||||
rehosts[r.src] = r.id;
|
||||
rehostMap[r.src] = info;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) {
|
||||
rehosts[m[1]] = r.id;
|
||||
rehostMap[m[1]] = info;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Rehost check error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaIndex = mediaPosts.findIndex(p => p.no === targetPost.no);
|
||||
const prevMedia = mediaIndex > 0 ? mediaPosts[mediaIndex - 1] : null;
|
||||
const nextMedia = mediaIndex < mediaPosts.length - 1 ? mediaPosts[mediaIndex + 1] : null;
|
||||
|
||||
const ext = (targetPost.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const isImage = ['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext);
|
||||
const externalMediaUrl = targetPost.tim ? `https://i.4cdn.org/${board}/${targetPost.tim}${ext}` : null;
|
||||
const localId = (targetPost.tim && rehosts[targetPost.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null;
|
||||
|
||||
let itemRating = null;
|
||||
let itemTags = [];
|
||||
let canManage = false;
|
||||
let localDest = null;
|
||||
let localThumb = null;
|
||||
let localTitle = null;
|
||||
let localStamp = null;
|
||||
let itemFavorites = [];
|
||||
let userHasFavorited = false;
|
||||
if (localId) {
|
||||
try {
|
||||
const localItem = await db`SELECT id, username, dest, title, mime, width, height, stamp FROM items WHERE id = ${localId} LIMIT 1`;
|
||||
if (localItem.length > 0) {
|
||||
const li = localItem[0];
|
||||
localStamp = li.stamp;
|
||||
if (li.title) localTitle = li.title;
|
||||
if (li.dest) {
|
||||
localDest = `/b/${li.dest}`;
|
||||
localThumb = `/t/${li.id}.webp`;
|
||||
}
|
||||
if (req.session) {
|
||||
canManage = !!((li.username && getSessionOwnerName(req.session) && li.username.toLowerCase() === getSessionOwnerName(req.session).toLowerCase()) ||
|
||||
req.session.admin || req.session.is_moderator);
|
||||
}
|
||||
}
|
||||
itemTags = await lib.getTags(localId, req.session);
|
||||
const ratingTag = itemTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
if (ratingTag) itemRating = ratingTag.normalized;
|
||||
itemFavorites = await db`
|
||||
select "favorites".user_id, "user".user, "user".login, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge, "anon_identities".fingerprint as anon_fingerprint
|
||||
from "favorites"
|
||||
left join "user" on "user".id = "favorites".user_id
|
||||
left join "user_options" on "user_options".user_id = "favorites".user_id
|
||||
left join "anon_identities" on "anon_identities".user_id = "favorites".user_id
|
||||
where "favorites".item_id = ${localId}
|
||||
`;
|
||||
userHasFavorited = lib.userHasFavorited(req.session, itemFavorites);
|
||||
} catch (e) {
|
||||
console.error('[CHAN] Failed to fetch tags for rehosted item:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Construct item matching f0ckm's standard item view expectations
|
||||
const effectiveStamp = (localId && localStamp) ? Number(localStamp) : targetPost.time;
|
||||
const item = {
|
||||
id: targetPost.no,
|
||||
slug: null,
|
||||
title: localTitle || targetPost.sub || op.sub || `/${board}/${tid} #${targetPost.no}`,
|
||||
dest: localDest || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}${ext}`,
|
||||
thumbnail: localThumb || `/api/v2/scroller/external/4chan/${board}/media/${targetPost.tim}s.jpg`,
|
||||
mime: isVideo ? (ext === '.mp4' ? 'video/mp4' : 'video/webm') : (ext === '.gif' ? 'image/gif' : (ext === '.png' ? 'image/png' : 'image/jpeg')),
|
||||
width: targetPost.w || null,
|
||||
height: targetPost.h || null,
|
||||
size: targetPost.fsize ? lib.formatSize(targetPost.fsize) : '0 B',
|
||||
username: targetPost.name || 'Anonymous',
|
||||
stamp: effectiveStamp,
|
||||
timestamp: {
|
||||
timeago: lib.timeAgo(effectiveStamp * 1000, req.lang || 'en'),
|
||||
timefull: new Date(effectiveStamp * 1000).toISOString()
|
||||
},
|
||||
comment: targetPost.com ? targetPost.com.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '') : '',
|
||||
is_chan: true,
|
||||
external_board: board,
|
||||
external_tid: tid,
|
||||
external_id: targetPost.no,
|
||||
external_thread_url: `https://boards.4chan.org/${board}/thread/${tid}#p${targetPost.no}`,
|
||||
external_media_url: externalMediaUrl,
|
||||
original_filename: targetPost.filename ? `${targetPost.filename}${ext}` : null,
|
||||
local_id: localId,
|
||||
local_path: localId ? (rehostPaths[localId] || localId) : null,
|
||||
rehosted: !!localId,
|
||||
is_sfw: itemRating === 'sfw',
|
||||
is_nsfw: itemRating === 'nsfw',
|
||||
is_nsfl: itemRating === 'nsfl',
|
||||
is_untagged: !itemRating,
|
||||
favorites: itemFavorites,
|
||||
user_has_favorited: userHasFavorited,
|
||||
halls: [],
|
||||
user_halls: [],
|
||||
tags: itemTags
|
||||
};
|
||||
|
||||
// F0ckm convention: Next post (right arrow / D) corresponds to pagination.prev,
|
||||
// Previous post (left arrow / A) corresponds to pagination.next.
|
||||
const pagination = {
|
||||
prev: nextMedia ? nextMedia.no : null,
|
||||
next: prevMedia ? prevMedia.no : null
|
||||
};
|
||||
|
||||
const link = {
|
||||
main: `/4/${board}/${tid}/`,
|
||||
mainDisplay: `/4/${board}/${tid}/`,
|
||||
suffix: ''
|
||||
};
|
||||
|
||||
// Find which rehosted items the current user has favorited
|
||||
const userFavSet = new Set();
|
||||
const rehostedIdList = Object.values(rehosts).map(Number).filter(Boolean);
|
||||
if (req.session?.id && rehostedIdList.length > 0) {
|
||||
try {
|
||||
const userFavs = await db`SELECT item_id FROM favorites WHERE user_id = ${req.session.id} AND item_id = ANY(${rehostedIdList})`;
|
||||
userFavs.forEach(f => userFavSet.add(Number(f.item_id)));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const chanMediaList = mediaPosts.map((p, idx) => {
|
||||
const pExt = (p.ext || '').toLowerCase();
|
||||
const pVid = ['.webm', '.mp4'].includes(pExt);
|
||||
const pUrl = `https://i.4cdn.org/${board}/${p.tim}${pExt}`;
|
||||
const pLocalId = (p.tim && rehosts[p.tim]) || (pUrl && rehosts[pUrl]) || null;
|
||||
return {
|
||||
no: p.no,
|
||||
tim: p.tim,
|
||||
ext: pExt,
|
||||
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${pExt}`,
|
||||
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
|
||||
is_video: pVid,
|
||||
is_image: !pVid,
|
||||
is_active: p.no === targetPost.no,
|
||||
index: idx + 1,
|
||||
local_id: pLocalId,
|
||||
local_path: pLocalId ? (rehostPaths[pLocalId] || pLocalId) : null,
|
||||
rehosted: !!pLocalId,
|
||||
user_has_favorited: pLocalId ? userFavSet.has(Number(pLocalId)) : false,
|
||||
width: p.w || null,
|
||||
height: p.h || null
|
||||
};
|
||||
});
|
||||
|
||||
const chanThreadMeta = {
|
||||
board,
|
||||
tid,
|
||||
subject: op.sub || `Thread #${tid}`,
|
||||
active_post: targetPost.no,
|
||||
active_index: mediaIndex + 1,
|
||||
media_count: mediaPosts.length,
|
||||
media_posts: chanMediaList
|
||||
};
|
||||
|
||||
const data = {
|
||||
item,
|
||||
pagination,
|
||||
link,
|
||||
chan_thread: chanThreadMeta,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
csrf_token: req.session?.csrf_token || '',
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
enable_item_title: true,
|
||||
enable_item_slugs: false,
|
||||
user_alternative_steuerung: req.session?.user_alternative_steuerung,
|
||||
user_alternative_infobox: req.session?.user_alternative_infobox,
|
||||
can_manage_item: canManage,
|
||||
can_rate_item: canManage,
|
||||
can_extract_meta: !!(canManage && localId && item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]))),
|
||||
user_has_favorited: userHasFavorited,
|
||||
isSubscribed: false,
|
||||
item_username_lower: (item.username || '').toLowerCase(),
|
||||
item_rating_class: item.is_nsfl ? 'is-nsfl' : (item.is_nsfw ? 'is-nsfw' : (item.is_sfw ? 'is-sfw' : 'is-untagged')),
|
||||
item_rating_label: item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?')),
|
||||
is_flash_item: false,
|
||||
is_archive_item: false,
|
||||
item_has_dimensions: !!(item.width && item.height),
|
||||
is_mod_or_admin: !!(req.session && (req.session.admin || req.session.is_moderator)),
|
||||
halls_enabled: false,
|
||||
default_layout: cfg.websrv?.default_layout || 'legacy',
|
||||
page_meta: {
|
||||
title: `/${board}/${tid}/${targetPost.no} - ${item.title}`,
|
||||
description: item.comment || `4chan media post #${targetPost.no} in /${board}/${tid}`,
|
||||
url: `https://${cfg.main.url.domain}/4/${board}/${tid}/${targetPost.no}`
|
||||
}
|
||||
};
|
||||
|
||||
// Handle AJAX requests from loadItemAjax
|
||||
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
|
||||
req.url.qs?.ajax === '1' ||
|
||||
(req.headers.accept && req.headers.accept.includes('application/json'));
|
||||
|
||||
if (isAjax) {
|
||||
const isModern = req.session?.use_new_layout;
|
||||
const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy';
|
||||
const html = tpl.render(partialTpl, data, req);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
html,
|
||||
item,
|
||||
pagination,
|
||||
chan_thread: chanThreadMeta
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Full page render: if Onara is active, render thread page with onara modal open
|
||||
const isOnara = isOnaraEnabledFor(req);
|
||||
|
||||
if (isOnara) {
|
||||
const isModern = req.session?.use_new_layout;
|
||||
const partialTpl = isModern ? 'item-partial-modern' : 'item-partial-legacy';
|
||||
const itemHtml = tpl.render(partialTpl, data, req);
|
||||
|
||||
const formattedPosts = formatPosts(posts, board, rehostMap, userFavSet, targetPost.no, req.lang || 'en');
|
||||
const firstMediaPost = formattedPosts.find(p => p.has_media);
|
||||
|
||||
const threadViewData = {
|
||||
board,
|
||||
tid,
|
||||
op,
|
||||
posts: formattedPosts,
|
||||
media_count: mediaPosts.length,
|
||||
first_media_no: firstMediaPost ? firstMediaPost.no : null,
|
||||
rehosts_count: Object.keys(rehosts).length,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
csrf_token: req.session?.csrf_token || '',
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
page_meta: data.page_meta,
|
||||
is_onara_item: true,
|
||||
onara: true,
|
||||
onara_item_html: itemHtml,
|
||||
item: data.item
|
||||
};
|
||||
|
||||
return res.reply({
|
||||
body: tpl.render('chan/thread', threadViewData, req)
|
||||
});
|
||||
}
|
||||
|
||||
// Standard full page render (when Onara is disabled)
|
||||
return res.reply({
|
||||
body: tpl.render('item', data, req)
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Failed to load item /4/${board}/${tid}/${postNo}:`, err.message);
|
||||
const isAjax = req.headers['x-requested-with'] === 'XMLHttpRequest' ||
|
||||
req.url.qs?.ajax === '1' ||
|
||||
(req.headers.accept && req.headers.accept.includes('application/json'));
|
||||
if (isAjax) {
|
||||
return res.reply({
|
||||
code: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
message: `Could not load media post #${postNo} in /${board}/${tid}: ${err.message}`
|
||||
})
|
||||
});
|
||||
}
|
||||
return res.reply({
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: `Could not load media post #${postNo} in /${board}/${tid}.`,
|
||||
domain: cfg.main.url.domain,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 4. GET /api/v2/chan/:board/:thread/media — Media list for thread (sidebar)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
router.get(/^\/api\/v2\/chan\/(?<board>[a-z0-9]+)\/(?<thread>\d+)\/media\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const board = req.params.board.toLowerCase();
|
||||
const tid = Number(req.params.thread);
|
||||
|
||||
try {
|
||||
const threadData = await fetchWithProxy(`https://a.4cdn.org/${board}/thread/${tid}.json`);
|
||||
const posts = threadData.posts || [];
|
||||
const op = posts[0] || {};
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
|
||||
const cdnUrls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdnUrls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdnUrls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const rehosts = {};
|
||||
const rehostPaths = {};
|
||||
if (cdnUrls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdnUrls})`;
|
||||
rows.forEach(r => {
|
||||
rehostPaths[r.id] = itemPath(r.id, r.slug);
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const media = mediaPosts.map((p, idx) => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
const isVideo = ['.webm', '.mp4'].includes(ext);
|
||||
const externalMediaUrl = `https://i.4cdn.org/${board}/${p.tim}${ext}`;
|
||||
return {
|
||||
no: p.no,
|
||||
tim: p.tim,
|
||||
ext,
|
||||
filename: p.filename ? `${p.filename}${ext}` : null,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
dest: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`,
|
||||
thumb: `/api/v2/scroller/external/4chan/${board}/media/${p.tim}s.jpg`,
|
||||
is_video: isVideo,
|
||||
is_image: !isVideo,
|
||||
index: idx + 1,
|
||||
local_id: (p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]) || null,
|
||||
local_path: rehostPaths[(p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl])] || null,
|
||||
rehosted: !!((p.tim && rehosts[p.tim]) || (externalMediaUrl && rehosts[externalMediaUrl]))
|
||||
};
|
||||
});
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'max-age=60' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
board,
|
||||
tid,
|
||||
subject: op.sub || `Thread #${tid}`,
|
||||
total: media.length,
|
||||
items: media,
|
||||
media
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[CHAN] Media API error for /${board}/${tid}:`, err.message);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: 'Failed to fetch thread media' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
+18
-104
@@ -7,12 +7,11 @@ import audit from "../audit.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import { applyWordFilter } from "../wordfilter.mjs";
|
||||
import path from "path";
|
||||
import { resolveAuditIP, logAnonActivity } from "../anon_auth.mjs";
|
||||
import { getEnableAnonymousAccess, canAnonDo, getAnonAllowedModes, isAnonSession, isAnonymizeSession } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
|
||||
|
||||
// Get comments for an item
|
||||
router.get(/\/api\/comments\/(?<itemid>\d+)/, async (req, res) => {
|
||||
const itemId = req.params.itemid;
|
||||
@@ -64,22 +63,11 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
// Transform for frontend if needed, or send as is
|
||||
const anonymize = isAnonymizeSession(req.session);
|
||||
const outComments = anonymize
|
||||
? comments.map(c => ({
|
||||
...c,
|
||||
username: 'anonymous',
|
||||
display_name: null,
|
||||
username_color: null,
|
||||
avatar: null,
|
||||
avatar_file: null
|
||||
}))
|
||||
: comments;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
comments: outComments,
|
||||
comments,
|
||||
is_subscribed,
|
||||
is_locked,
|
||||
user_id: req.session ? req.session.id : null,
|
||||
@@ -134,9 +122,6 @@ export default (router, tpl) => {
|
||||
|
||||
// Browse User Comments
|
||||
router.get(/\/user\/(?<user>[^\/]+)\/comments/, async (req, res) => {
|
||||
if (isAnonymizeSession(req.session)) {
|
||||
return req.session ? res.redirect('/') : res.redirect('/login');
|
||||
}
|
||||
const user = decodeURIComponent(req.params.user);
|
||||
|
||||
try {
|
||||
@@ -171,15 +156,7 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
|
||||
let globalfilterTags = [...(cfg.nsfp || [])];
|
||||
if (req.session && isAnonSession(req.session)) {
|
||||
const allowedModes = getAnonAllowedModes();
|
||||
if (allowedModes.includes('nsfw')) globalfilterTags = globalfilterTags.filter(id => id !== 2);
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (allowedModes.includes('nsfl')) globalfilterTags = globalfilterTags.filter(id => id !== nsflId);
|
||||
} else if (cfg.websrv.public_nsfw) {
|
||||
globalfilterTags = globalfilterTags.filter(id => id !== 2);
|
||||
}
|
||||
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
|
||||
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
/* <mode-override> */
|
||||
@@ -205,7 +182,7 @@ export default (router, tpl) => {
|
||||
WHERE c.user_id = ${userId} AND c.is_deleted = false
|
||||
AND i.active = true AND i.is_deleted = false
|
||||
AND ${db.unsafe(modequery)}
|
||||
${(!req.session || isAnonSession(req.session)) && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
@@ -398,14 +375,8 @@ export default (router, tpl) => {
|
||||
|
||||
// Post a comment
|
||||
router.post('/api/comments', async (req, res) => {
|
||||
// Anonymous users must have an active passkey session — no SSH header fallback (hard cut)
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false, message: "Unauthorized" }) });
|
||||
|
||||
|
||||
if (isAnonSession(req.session) && !canAnonDo('comment')) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Anonymous commenting is disabled" }) });
|
||||
}
|
||||
|
||||
// Rate limit regular users (admins and mods are exempt)
|
||||
if (!req.session.admin && !req.session.is_moderator) {
|
||||
if (isCommentRateLimited(req.session.id)) {
|
||||
@@ -450,13 +421,11 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
|
||||
const auditIp = resolveAuditIP(req);
|
||||
const insertData = {
|
||||
item_id,
|
||||
user_id: req.session.id,
|
||||
parent_id: parent_id || null,
|
||||
content: content || '',
|
||||
ip: auditIp
|
||||
content: content || ''
|
||||
};
|
||||
if (video_time !== null) insertData.video_time = video_time;
|
||||
|
||||
@@ -467,14 +436,6 @@ export default (router, tpl) => {
|
||||
|
||||
const commentId = parseInt(newComment[0].id, 10);
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'comment',
|
||||
targetId: commentId,
|
||||
details: { item_id, parent_id }
|
||||
});
|
||||
}
|
||||
|
||||
// Link uploaded files to this comment (if any)
|
||||
let activityFiles = [];
|
||||
const fileIdsRaw = body.file_ids || '';
|
||||
@@ -613,9 +574,6 @@ export default (router, tpl) => {
|
||||
const itemQuery = await db`
|
||||
SELECT
|
||||
i.slug,
|
||||
i.mime,
|
||||
i.dest,
|
||||
i.has_coverart,
|
||||
i.xd_score,
|
||||
COALESCE(i.visibility, 0) as visibility,
|
||||
(SELECT ta.tag_id FROM tags_assign ta
|
||||
@@ -655,13 +613,10 @@ export default (router, tpl) => {
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
|
||||
created_at: new Date().toISOString(),
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.is_anon ? 'Anonymous' : (req.session.display_name || null),
|
||||
display_name: req.session.display_name || null,
|
||||
xd_score: xdRow?.xd_score ?? null,
|
||||
video_time: newComment[0]?.video_time ?? null,
|
||||
files: activityFiles,
|
||||
is_anon: !!req.session.is_anon,
|
||||
anon_fingerprint: req.session.fingerprint || null,
|
||||
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
|
||||
files: activityFiles
|
||||
};
|
||||
|
||||
// 1. Thread live update
|
||||
@@ -676,10 +631,6 @@ export default (router, tpl) => {
|
||||
db.notify('activity', JSON.stringify({
|
||||
user_id: req.session.id,
|
||||
item_id: item_id,
|
||||
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
|
||||
mime: itemQuery[0]?.mime || null,
|
||||
dest: itemQuery[0]?.dest || null,
|
||||
has_coverart: !!itemQuery[0]?.has_coverart,
|
||||
type: 'comment',
|
||||
body: notifyBody,
|
||||
id: commentId,
|
||||
@@ -691,14 +642,11 @@ export default (router, tpl) => {
|
||||
banner_position: bannerOpt.banner_position || req.session.banner_position || null,
|
||||
banner_size: bannerOpt.banner_size || req.session.banner_size || null,
|
||||
banner_repeat: bannerOpt.banner_repeat || req.session.banner_repeat || null,
|
||||
username: req.session.is_anon ? 'anonymous' : req.session.user,
|
||||
username: req.session.user,
|
||||
username_color: req.session.username_color,
|
||||
display_name: req.session.is_anon ? 'Anonymous' : (req.session.display_name || null),
|
||||
display_name: req.session.display_name || null,
|
||||
files: activityFiles,
|
||||
is_long: activityIsLong,
|
||||
is_anon: !!req.session.is_anon,
|
||||
anon_fingerprint: req.session.fingerprint || null,
|
||||
anon_short_fingerprint: req.session.fingerprint ? req.session.fingerprint.slice(7, 15) : null
|
||||
is_long: activityIsLong
|
||||
}));
|
||||
|
||||
// Automatically subscribe user to the thread
|
||||
@@ -795,14 +743,6 @@ export default (router, tpl) => {
|
||||
old_content: comment[0].content
|
||||
});
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'comment_delete',
|
||||
targetId: commentId,
|
||||
details: { item_id: comment[0].item_id }
|
||||
});
|
||||
}
|
||||
|
||||
// Handle attachments cleanup
|
||||
const files = await db`SELECT id, dest, checksum FROM comment_files WHERE comment_id = ${commentId}`;
|
||||
for (const f of files) {
|
||||
@@ -1076,16 +1016,8 @@ export default (router, tpl) => {
|
||||
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
|
||||
|
||||
// Build mode SQL — replace items.id alias with i.id used in the activity query
|
||||
const modequery = f0cklib.computeBaseMode(mode, ratingsArr, req.session).replace(/items\.id/g, 'i.id');
|
||||
let globalfilterTags = [...(cfg.nsfp || [])];
|
||||
if (req.session && isAnonSession(req.session)) {
|
||||
const allowedModes = getAnonAllowedModes();
|
||||
if (allowedModes.includes('nsfw')) globalfilterTags = globalfilterTags.filter(id => id !== 2);
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (allowedModes.includes('nsfl')) globalfilterTags = globalfilterTags.filter(id => id !== nsflId);
|
||||
} else if (cfg.websrv.public_nsfw) {
|
||||
globalfilterTags = globalfilterTags.filter(id => id !== 2);
|
||||
}
|
||||
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
|
||||
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
|
||||
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
|
||||
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
|
||||
|
||||
@@ -1099,8 +1031,6 @@ export default (router, tpl) => {
|
||||
? db`AND (COALESCE(i.visibility, 0) = 0 OR LOWER(i.username) = ${sessionUser} OR c.user_id = ${sessionUserId})`
|
||||
: db`AND COALESCE(i.visibility, 0) = 0`);
|
||||
|
||||
const { mimeSQL: activityMimeSQL } = f0cklib.resolveMimeSQL(req.url.qs?.mime || (req.cookies?.mime || null), req.session, 'i');
|
||||
|
||||
const comments = await db`
|
||||
SELECT
|
||||
c.*,
|
||||
@@ -1108,7 +1038,6 @@ export default (router, tpl) => {
|
||||
i.id as item_id,
|
||||
i.slug as item_slug,
|
||||
i.dest as item_dest,
|
||||
i.has_coverart,
|
||||
(SELECT ta.tag_id FROM tags_assign ta
|
||||
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
|
||||
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id,
|
||||
@@ -1128,10 +1057,9 @@ export default (router, tpl) => {
|
||||
WHERE c.is_deleted = false
|
||||
AND i.active = true
|
||||
AND i.is_deleted = false
|
||||
${activityMimeSQL}
|
||||
${visibilityFilter}
|
||||
AND ${db.unsafe(modequery)}
|
||||
${(!req.session || isAnonSession(req.session)) && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
|
||||
|
||||
ORDER BY c.created_at DESC
|
||||
@@ -1207,7 +1135,6 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
|
||||
const isAnonymized = isAnonymizeSession(req.session);
|
||||
const processedComments = comments.map(c => {
|
||||
let ratingLabel = '?';
|
||||
let ratingClass = 'untagged';
|
||||
@@ -1217,6 +1144,9 @@ export default (router, tpl) => {
|
||||
|
||||
const commentContent = (c.content || '').trim();
|
||||
const commentFiles = filesMap.get(c.id) || [];
|
||||
// Compute overflow hint: true if content is likely taller than the 80px clamp.
|
||||
// ~120 chars ≈ 2-3 wrapped lines in the sidebar; any newlines > 2 or file
|
||||
// attachments (images/video) will also push height above the limit.
|
||||
const isLong = commentContent.length > 120
|
||||
|| commentContent.split('\n').length > 2
|
||||
|| commentFiles.length > 0
|
||||
@@ -1224,22 +1154,17 @@ export default (router, tpl) => {
|
||||
|
||||
return {
|
||||
...c,
|
||||
username: isAnonymized ? 'anonymous' : c.username,
|
||||
display_name: isAnonymized ? null : c.display_name,
|
||||
username_color: isAnonymized ? null : c.username_color,
|
||||
avatar: isAnonymized ? null : c.avatar,
|
||||
avatar_file: isAnonymized ? null : c.avatar_file,
|
||||
banner_file: isAnonymized ? null : c.banner_file,
|
||||
content: commentContent,
|
||||
username_color: c.username_color,
|
||||
item_rating_class: ratingClass,
|
||||
item_rating_label: ratingLabel,
|
||||
files: commentFiles,
|
||||
poll: pollMap.get(c.id) || null,
|
||||
is_long: isLong
|
||||
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
if (req.url.qs?.json === 'true' || req.headers['x-requested-with'] === 'XMLHttpRequest') {
|
||||
return res.reply({
|
||||
headers: {
|
||||
@@ -1443,9 +1368,6 @@ export default (router, tpl) => {
|
||||
// POST /api/polls/:pollId/vote — cast or change vote
|
||||
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
if (isAnonSession(req.session) && !canAnonDo('poll_vote')) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Anonymous poll voting is disabled' }) });
|
||||
}
|
||||
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
|
||||
|
||||
const pollId = req.params.pollId;
|
||||
@@ -1484,14 +1406,6 @@ export default (router, tpl) => {
|
||||
`;
|
||||
}
|
||||
|
||||
if (req.session?.is_anon) {
|
||||
await logAnonActivity(req, {
|
||||
action: 'poll_vote',
|
||||
targetId: pollId,
|
||||
details: { option_id: optionId }
|
||||
});
|
||||
}
|
||||
|
||||
// Return updated tally
|
||||
const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
|
||||
const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true;
|
||||
|
||||
+80
-272
@@ -2,18 +2,9 @@ import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
import { chanCurl } from "../chan_http.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { getManualApproval, getBypassDuplicateCheck, canUseChan, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
|
||||
|
||||
// Link path for a local item: its slug when slugs are enabled, else the numeric id
|
||||
const itemPath = async (id, slug) => {
|
||||
if (!getEnableItemSlugs()) return id;
|
||||
if (slug === undefined) slug = (await db`SELECT slug FROM items WHERE id = ${id} LIMIT 1`)[0]?.slug;
|
||||
return slug || id;
|
||||
};
|
||||
import { applyWordFilter } from "../wordfilter.mjs";
|
||||
import { getManualApproval, getBypassDuplicateCheck } from "../settings.mjs";
|
||||
|
||||
/**
|
||||
* external.mjs — External source handlers (4chan threads, etc.)
|
||||
@@ -53,8 +44,18 @@ export default (router) => {
|
||||
* This ensures we respect the SOCKS5 proxy for all external 4chan requests.
|
||||
*/
|
||||
async function fetchWithProxy(url, asBuffer = false) {
|
||||
const { bin, args } = chanCurl(url, ['--max-time', '30']);
|
||||
const { stdout } = await queue.spawn(bin, args, { encoding: asBuffer ? 'buffer' : 'utf8' });
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L',
|
||||
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'--max-time', '30',
|
||||
url
|
||||
];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
|
||||
const { stdout } = await queue.spawn('curl', curlArgs, { encoding: asBuffer ? 'buffer' : 'utf8' });
|
||||
if (asBuffer) return stdout;
|
||||
const text = typeof stdout === 'string' ? stdout.trim() : stdout.toString().trim();
|
||||
if (!text.startsWith('{') && !text.startsWith('[')) {
|
||||
@@ -66,7 +67,7 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/:tid
|
||||
// Proxies 4chan thread JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/(?<tid>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, tid } = req.params || {};
|
||||
|
||||
@@ -84,26 +85,12 @@ export default (router) => {
|
||||
|
||||
// Check which media URLs are already rehosted on this platform
|
||||
const rehosts = {};
|
||||
const rehostPaths = {};
|
||||
const mediaPosts = posts.filter(p => p.tim && p.ext);
|
||||
const cdn4Urls = [];
|
||||
mediaPosts.forEach(p => {
|
||||
const ext = (p.ext || '').toLowerCase();
|
||||
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdn4Urls.push(`https://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
cdn4Urls.push(`http://i.4cdn.org/${board}/${p.tim}${ext}`);
|
||||
cdn4Urls.push(`/api/v2/scroller/external/4chan/${board}/media/${p.tim}${ext}`);
|
||||
});
|
||||
const cdn4Urls = mediaPosts.map(p => `https://i.4cdn.org/${board}/${p.tim}${p.ext}`);
|
||||
if (cdn4Urls.length > 0) {
|
||||
try {
|
||||
const rows = await db`SELECT id, src, slug FROM items WHERE src = ANY(${cdn4Urls})`;
|
||||
for (const r of rows) rehostPaths[r.id] = await itemPath(r.id, r.slug);
|
||||
rows.forEach(r => {
|
||||
rehosts[r.src] = r.id;
|
||||
const m = r.src.match(/(\d{13,20})/);
|
||||
if (m) rehosts[m[1]] = r.id;
|
||||
});
|
||||
const rows = await db`SELECT id, src FROM items WHERE src IN (${cdn4Urls})`;
|
||||
rows.forEach(r => { rehosts[r.src] = r.id; });
|
||||
} catch (e) {
|
||||
console.error('[EXTERNAL] DB src check error:', e.message);
|
||||
}
|
||||
@@ -111,7 +98,7 @@ export default (router) => {
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache' },
|
||||
body: JSON.stringify({ success: true, posts, board, tid, rehosts, rehost_paths: rehostPaths })
|
||||
body: JSON.stringify({ success: true, posts, board, tid, rehosts })
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
@@ -172,7 +159,7 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/catalog
|
||||
// Proxies 4chan board catalog JSON
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.chanAuth, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/catalog\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board } = req.params || {};
|
||||
if (!board) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
@@ -210,7 +197,7 @@ export default (router) => {
|
||||
|
||||
// GET /api/v2/scroller/external/4chan/:board/find/:postno
|
||||
// Resolves a post number to its parent thread ID
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/find\/(?<postno>\d+)\/?$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, postno } = req.params || {};
|
||||
if (!board || !postno) return res.reply({ code: 400, body: JSON.stringify({ success: false }) });
|
||||
@@ -272,7 +259,7 @@ export default (router) => {
|
||||
// F-001: Allowed file extensions for the media proxy (prevents abuse as generic proxy)
|
||||
const ALLOWED_MEDIA_EXTS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'webm', 'mp4'];
|
||||
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.chanAuth, async (req, res) => {
|
||||
router.get(/^\/api\/v2\/scroller\/external\/4chan\/(?<board>[a-z0-9]+)\/media\/(?<file>[^/]+)$/, lib.loggedin, async (req, res) => {
|
||||
if (!proxyRateLimit(req, res)) return;
|
||||
const { board, file } = req.params || {};
|
||||
|
||||
@@ -295,10 +282,19 @@ export default (router) => {
|
||||
};
|
||||
const contentType = mimes[ext] || 'application/octet-stream';
|
||||
|
||||
const { bin: curlBin, args: curlArgs } = chanCurl(url, ['--max-time', '60']);
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L',
|
||||
'-A', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
||||
'--max-time', '60',
|
||||
url
|
||||
];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
const curl = spawn(curlBin, curlArgs);
|
||||
const curl = spawn('curl', curlArgs);
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': contentType,
|
||||
@@ -322,8 +318,8 @@ export default (router) => {
|
||||
|
||||
// POST /api/v2/scroller/rehost
|
||||
// Downloads an external item and adds it to the platform
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename, width: postWidth, height: postHeight } = req.post || {};
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
|
||||
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename } = req.post || {};
|
||||
|
||||
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
|
||||
|
||||
@@ -339,29 +335,34 @@ export default (router) => {
|
||||
|| url.match(/\/4chan\/([a-z0-9]+)\/media\//)?.[1]
|
||||
|| null;
|
||||
|
||||
// Rating is optional: do not force sfw/nsfw based on board
|
||||
const validRatings = ['sfw', 'nsfw', 'nsfl'];
|
||||
const rating = (initialRating && validRatings.includes(String(initialRating).toLowerCase()))
|
||||
? String(initialRating).toLowerCase()
|
||||
: null;
|
||||
let rating = initialRating;
|
||||
if (board === 'gif') rating = 'nsfw';
|
||||
else if (board === 'wsg') rating = 'sfw';
|
||||
|
||||
if (!rating || !['sfw', 'nsfw', 'nsfl'].includes(rating)) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Rating is required' }) });
|
||||
}
|
||||
|
||||
const session = req.session;
|
||||
// Anon sessions carry user = 'anonymous'; credit the upload to the real shadow account
|
||||
const ownerName = getSessionOwnerName(session);
|
||||
|
||||
try {
|
||||
const uuid = await queue.genuuid();
|
||||
const tmpPath = path.join(cfg.paths.tmp, `${uuid}.tmp`);
|
||||
|
||||
// Download via curl (lightweight)
|
||||
const { bin: curlBin, args: curlArgs } = chanCurl(url, [
|
||||
'-o', tmpPath,
|
||||
const curlArgs = [
|
||||
'-s', '-f', '-L', url, '-o', tmpPath,
|
||||
'--max-filesize', `${cfg.main.maxfilesize || 100 * 1024 * 1024}`,
|
||||
'--connect-timeout', '30',
|
||||
'--max-time', '300'
|
||||
]);
|
||||
'--max-time', '300',
|
||||
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
];
|
||||
if (cfg.main.socks && cfg.main.socks !== 'undefined' && cfg.main.socks !== '') {
|
||||
const proxyHost = cfg.main.socks.includes('://') ? cfg.main.socks.split('://')[1] : cfg.main.socks;
|
||||
curlArgs.push('--socks5-hostname', proxyHost);
|
||||
}
|
||||
|
||||
await queue.spawn(curlBin, curlArgs);
|
||||
await queue.spawn('curl', curlArgs);
|
||||
|
||||
// Detect MIME
|
||||
const mime = (await queue.spawn('file', ['--mime-type', '-b', tmpPath])).stdout.trim();
|
||||
@@ -381,16 +382,14 @@ export default (router) => {
|
||||
if (repost) {
|
||||
await fs.unlink(finalTmp).catch(() => {});
|
||||
// Auto-subscribe user to the existing item they attempted to rehost
|
||||
if (cfg.enable_comments !== false) {
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
|
||||
}
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${repost}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (repost) error:', e); }
|
||||
|
||||
return res.reply({
|
||||
code: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, repost: true, item_id: repost, item_path: await itemPath(repost), msg: 'Already on site' })
|
||||
body: JSON.stringify({ success: true, repost: true, item_id: repost, msg: 'Already on site' })
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -403,43 +402,18 @@ export default (router) => {
|
||||
if (phashMatch) {
|
||||
await fs.unlink(finalTmp).catch(() => {});
|
||||
// Auto-subscribe user to the existing item they attempted to rehost (visual match)
|
||||
if (cfg.enable_comments !== false) {
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
|
||||
}
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${phashMatch}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (phash repost) error:', e); }
|
||||
|
||||
return res.reply({
|
||||
code: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, item_path: await itemPath(phashMatch), msg: 'Already on site (visual match)' })
|
||||
body: JSON.stringify({ success: true, repost: true, item_id: phashMatch, msg: 'Already on site (visual match)' })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let itemWidth = Number(postWidth) || null;
|
||||
let itemHeight = Number(postHeight) || null;
|
||||
if (!itemWidth || !itemHeight) {
|
||||
try {
|
||||
if (mime.startsWith('image/')) {
|
||||
const { stdout: magickOut } = await queue.spawn('magick', [
|
||||
'identify', '-format', '%wx%h\n', finalTmp + '[0]'
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
const m = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
|
||||
if (m) { itemWidth = parseInt(m[1], 10); itemHeight = parseInt(m[2], 10); }
|
||||
} else if (mime.startsWith('video/')) {
|
||||
const { stdout: probeOut } = await queue.spawn('ffprobe', [
|
||||
'-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', finalTmp
|
||||
], { quiet: true, ignoreExitCode: true });
|
||||
const parts = probeOut.trim().split(',');
|
||||
if (parts.length >= 2) {
|
||||
const w = parseInt(parts[0], 10), h = parseInt(parts[1], 10);
|
||||
if (w > 0 && h > 0) { itemWidth = w; itemHeight = h; }
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const isApprovalRequired = getManualApproval();
|
||||
const destDir = isApprovalRequired ? path.join(cfg.paths.pending, 'b') : cfg.paths.b;
|
||||
@@ -457,26 +431,22 @@ export default (router) => {
|
||||
size: (await fs.stat(path.join(destDir, filename))).size,
|
||||
checksum: insertChecksum,
|
||||
phash: phash,
|
||||
username: ownerName,
|
||||
username: session.user,
|
||||
userchannel: 'web',
|
||||
usernetwork: 'web',
|
||||
stamp: ~~(Date.now() / 1000),
|
||||
active: !isApprovalRequired,
|
||||
is_oc: !!is_oc,
|
||||
original_filename: original_filename || null,
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
slug: lib.generateSlug(11)
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'width', 'height', 'slug')}
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
// Automatically subscribe user to the new item
|
||||
if (cfg.enable_comments !== false) {
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
|
||||
}
|
||||
try {
|
||||
await db`INSERT INTO comment_subscriptions (user_id, item_id) VALUES (${session.id}, ${itemid}) ON CONFLICT (user_id, item_id) DO UPDATE SET is_subscribed = true`;
|
||||
} catch (e) { console.error('[REHOST] Auto-subscribe (new item) error:', e); }
|
||||
|
||||
// Process thumbnail
|
||||
try {
|
||||
@@ -486,14 +456,16 @@ export default (router) => {
|
||||
console.error('[REHOST] Thumbnail error:', err);
|
||||
}
|
||||
|
||||
// Tags: Only assign rating tag if explicitly specified; do NOT auto-tag board or sfw/nsfw
|
||||
if (rating) {
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
|
||||
}
|
||||
// Tags
|
||||
const ratingTagId = rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: session.id })} on conflict do nothing`;
|
||||
|
||||
const rawList = Array.isArray(tagsRaw) ? tagsRaw : (typeof tagsRaw === 'string' ? tagsRaw.split(',') : []);
|
||||
const tags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(Boolean) : [];
|
||||
// Board tag in chan-style format e.g. /gif/, /wsg/
|
||||
if (board) tags.push(`/${board}/`);
|
||||
// Auto-tag rating based on board
|
||||
if (board === 'wsg') tags.push('sfw');
|
||||
else if (board === 'gif') tags.push('nsfw');
|
||||
for (const tagName of tags) {
|
||||
let tagRow = await db`select id from tags where normalized = slugify(${tagName}) limit 1`;
|
||||
if (tagRow.length === 0) {
|
||||
@@ -505,21 +477,9 @@ export default (router) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Insert optional first comment if provided
|
||||
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
|
||||
try {
|
||||
const filteredComment = await applyWordFilter(comment.trim());
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: session.id,
|
||||
content: filteredComment
|
||||
})}
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error('[REHOST] Comment insert error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
|
||||
|
||||
@@ -530,12 +490,10 @@ export default (router) => {
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: mime,
|
||||
username: ownerName,
|
||||
username: session.user,
|
||||
display_name: session.display_name || null,
|
||||
tag_id: rating ? (rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: false,
|
||||
is_album: false,
|
||||
album_count: 0
|
||||
tag_id: rating === 'sfw' ? 1 : (rating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3)),
|
||||
is_oc: false
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[REHOST] new_item notify failed:', err);
|
||||
@@ -563,7 +521,7 @@ export default (router) => {
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, item_id: itemid, item_path: await itemPath(itemid) })
|
||||
body: JSON.stringify({ success: true, item_id: itemid })
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
@@ -576,155 +534,5 @@ export default (router) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/v2/scroller/rehost/details/:id
|
||||
// Retrieve current rating and tags for a rehosted item
|
||||
router.get(/^\/api\/v2\/scroller\/rehost\/details\/(?<id>\d+)\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const itemId = Number(req.params.id);
|
||||
try {
|
||||
const tags = await lib.getTags(itemId, req.session);
|
||||
const ratingTag = tags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const rating = ratingTag ? ratingTag.normalized : 'untagged';
|
||||
const userTags = tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => t.tag);
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: true, item_id: itemId, rating, tags: userTags })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: err.message })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/v2/scroller/rehost/details
|
||||
// Set rating, add tags, and or post a comment directly on a rehosted item
|
||||
router.post(/^\/api\/v2\/scroller\/rehost\/details\/?$/, lib.chanAuth, async (req, res) => {
|
||||
const session = req.session;
|
||||
if (!session || !session.user) {
|
||||
return res.reply({ code: 401, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
|
||||
}
|
||||
|
||||
const { item_id, rating, tags, comment } = req.post || req.body || {};
|
||||
const itemId = parseInt(item_id, 10);
|
||||
if (!itemId || isNaN(itemId)) {
|
||||
return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'Valid item_id is required' }) });
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await db`SELECT id, username FROM items WHERE id = ${itemId} AND active = true AND is_deleted = false LIMIT 1`;
|
||||
if (!rows.length) {
|
||||
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: 'Item not found' }) });
|
||||
}
|
||||
|
||||
const ownerName = getSessionOwnerName(session);
|
||||
const isOwner = !!(rows[0].username && ownerName && rows[0].username.toLowerCase() === ownerName.toLowerCase());
|
||||
const isAdmin = !!(session.admin || session.is_moderator);
|
||||
const isChanUser = canUseChan(session);
|
||||
|
||||
const hasTagsOrComment = (tags && Array.isArray(tags) ? tags.length > 0 : (typeof tags === 'string' && tags.trim().length > 0)) ||
|
||||
(comment && typeof comment === 'string' && comment.trim().length > 0);
|
||||
|
||||
if (hasTagsOrComment && !isOwner && !isAdmin) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Only owner can edit tags and comment' }) });
|
||||
}
|
||||
|
||||
if (!isOwner && !isAdmin && !isChanUser) {
|
||||
return res.reply({ code: 403, body: JSON.stringify({ success: false, msg: 'Unauthorized' }) });
|
||||
}
|
||||
|
||||
const nsflTagRow = await db`SELECT id FROM tags WHERE normalized = 'nsfl' LIMIT 1`;
|
||||
const nsflId = nsflTagRow.length > 0 ? nsflTagRow[0].id : (cfg.nsfl_tag_id || 11517);
|
||||
|
||||
// 1. Update Rating if provided
|
||||
if (rating !== undefined && rating !== null && rating !== '') {
|
||||
const r = String(rating).toLowerCase().trim();
|
||||
|
||||
await db`
|
||||
DELETE FROM tags_assign
|
||||
WHERE item_id = ${itemId}
|
||||
AND (tag_id IN (1, 2, ${nsflId}) OR tag_id IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl')))
|
||||
`;
|
||||
|
||||
if (r === 'sfw') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 1, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
} else if (r === 'nsfw') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: 2, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
} else if (r === 'nsfl') {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: nsflId, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
// If r === 'untagged', no rating tag is inserted
|
||||
}
|
||||
|
||||
// 2. Sync Tags if provided
|
||||
if (tags !== undefined && tags !== null) {
|
||||
const rawList = Array.isArray(tags) ? tags : (typeof tags === 'string' ? tags.split(',') : []);
|
||||
const cleanTags = rawList.map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
|
||||
// Delete existing non-rating tags for this item by this user (or all non-rating tags if owner)
|
||||
await db`
|
||||
DELETE FROM tags_assign
|
||||
WHERE item_id = ${itemId}
|
||||
AND tag_id NOT IN (1, 2, ${nsflId})
|
||||
AND tag_id NOT IN (SELECT id FROM tags WHERE normalized IN ('sfw', 'nsfw', 'nsfl'))
|
||||
${isOwner ? db`` : db`AND user_id = ${session.id}`}
|
||||
`;
|
||||
|
||||
for (const tagName of cleanTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')} ON CONFLICT DO NOTHING`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
}
|
||||
if (tagRow.length) {
|
||||
await db`INSERT INTO tags_assign ${db({ item_id: itemId, tag_id: tagRow[0].id, user_id: session.id })} ON CONFLICT DO NOTHING`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Add Comment if provided
|
||||
if (comment && typeof comment === 'string' && comment.trim().length > 0) {
|
||||
const filteredComment = await applyWordFilter(comment.trim());
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemId,
|
||||
user_id: session.id,
|
||||
content: filteredComment
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
const freshTags = await lib.getTags(itemId, session);
|
||||
await db.notify('tags', JSON.stringify({ item_id: itemId, fresh: true, tags: freshTags })).catch(() => {});
|
||||
|
||||
const ratingTag = freshTags.find(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const cleanTagObjects = freshTags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized)).map(t => ({
|
||||
id: t.id,
|
||||
tag: t.tag,
|
||||
normalized: t.normalized,
|
||||
badge: t.badge
|
||||
}));
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
item_id: itemId,
|
||||
item_path: await itemPath(itemId),
|
||||
rating: ratingTag ? ratingTag.normalized : 'untagged',
|
||||
tags: cleanTagObjects
|
||||
})
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[REHOST-DETAILS] Error:', err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ success: false, msg: 'Failed to save details' })
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -87,29 +87,17 @@ export default (router, tpl) => {
|
||||
`;
|
||||
|
||||
if (items.length > 0) {
|
||||
const inputs = [];
|
||||
for (const item of items) {
|
||||
const filePath = path.join(cfg.paths.t, `${item.id}.webp`);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
inputs.push(`${filePath}[0]`);
|
||||
} catch {
|
||||
// Ignore missing thumbnail
|
||||
}
|
||||
}
|
||||
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
const { execFile } = await import('child_process');
|
||||
const util = await import('util');
|
||||
const execFilePromise = util.promisify(execFile);
|
||||
|
||||
if (inputs.length > 0) {
|
||||
await fs.mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
const { execFile } = await import('child_process');
|
||||
const util = await import('util');
|
||||
const execFilePromise = util.promisify(execFile);
|
||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
|
||||
|
||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||
return res.end(await fs.readFile(cachePath));
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
|
||||
return res.end(await fs.readFile(cachePath));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HALL_IMAGE] Error:', e);
|
||||
|
||||
+576
-849
File diff suppressed because it is too large
Load Diff
+130
-376
@@ -1,7 +1,6 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
import { invalidateHiddenItems } from "../hidden_items.mjs";
|
||||
import { promises as fs } from "fs";
|
||||
import cfg from "../config.mjs";
|
||||
import fetch from "flumm-fetch";
|
||||
@@ -11,23 +10,18 @@ import { getManualApproval } from "../settings.mjs";
|
||||
import { moveToDeleted, safeDeleteMediaFile } from "../lib_delete.mjs";
|
||||
import { setMotd } from "../motd.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Moderator Dashboard
|
||||
router.get(/^\/mod(\/)?$/, lib.modAuth, async (req, res) => {
|
||||
const pendingCount = (await db`select count(*) as c from "items" where active = false and is_deleted = false`)[0].c;
|
||||
const trashCount = (await db`select count(*) as c from "items" where active = false and is_deleted = true and is_purged = false`)[0].c;
|
||||
const reportsCount = (await db`select count(*)::int as c from reports where status = 'pending'`.catch(() => [{ c: 0 }]))[0].c;
|
||||
|
||||
|
||||
res.reply({
|
||||
body: tpl.render("mod", {
|
||||
session: req.session,
|
||||
pendingCount: parseInt(pendingCount),
|
||||
trashCount: parseInt(trashCount),
|
||||
reportsCount: parseInt(reportsCount) || 0,
|
||||
manualApproval: getManualApproval(),
|
||||
tmp: null
|
||||
}, req)
|
||||
@@ -45,119 +39,25 @@ export default (router, tpl) => {
|
||||
});
|
||||
|
||||
// Approval Queue (View only — GET is safe, no state change)
|
||||
// Tag badge classes for queue cards (shared by the approval queue and soft-deleted views)
|
||||
const processQueueItems = (items) => items.map(p => ({
|
||||
...p,
|
||||
tags: (p.tags || [])
|
||||
.filter(t => t.tag !== null)
|
||||
.map(t => {
|
||||
let badge = "badge-light";
|
||||
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
|
||||
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
|
||||
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
|
||||
else if (t.normalized === "german") badge = "badge-german badge-light";
|
||||
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
|
||||
else if (t.normalized === "sfw") badge = "badge-success";
|
||||
else if (t.normalized === "nsfw") badge = "badge-danger";
|
||||
return { ...t, badge };
|
||||
})
|
||||
}));
|
||||
|
||||
const QUEUE_PAGE_SIZE = 20;
|
||||
const queuePage = (req) => Math.max(1, +req.url.qs.page || 1);
|
||||
|
||||
// Approval queue: uploads waiting for approval (not deleted)
|
||||
router.get(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||
const page = queuePage(req);
|
||||
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = false`;
|
||||
// View Queue
|
||||
const page = +req.url.qs.page || 1;
|
||||
const limit = 20;
|
||||
// Fetch Pending (not deleted)
|
||||
const pending = await db`
|
||||
select i.id, i.mime, i.username, i.dest, i.title, i.stamp, i.size, i.width, i.height,
|
||||
i.original_filename, i.is_album, i.album_count,
|
||||
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
|
||||
select i.id, i.mime, i.username, i.dest, json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags
|
||||
from "items" i
|
||||
left join "tags_assign" ta on ta.item_id = i.id
|
||||
left join "tags" t on t.id = ta.tag_id
|
||||
where i.active = false and i.is_deleted = false
|
||||
group by i.id
|
||||
order by i.id desc
|
||||
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
|
||||
limit ${limit} offset ${(page - 1) * limit}
|
||||
`;
|
||||
|
||||
// Album pictures for the albums on this page (cover = order_index 0 uses the item's own files)
|
||||
const albumIds = pending.filter(p => p.is_album).map(p => p.id);
|
||||
const albumRows = albumIds.length ? await db`
|
||||
select item_id, id, dest, mime, width, height, order_index
|
||||
from album_items where item_id = any(${albumIds}::int[])
|
||||
order by item_id, order_index asc
|
||||
` : [];
|
||||
const albumsById = new Map();
|
||||
for (const r of albumRows) {
|
||||
if (!albumsById.has(r.item_id)) albumsById.set(r.item_id, []);
|
||||
albumsById.get(r.item_id).push(r);
|
||||
}
|
||||
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const mediaKind = (mime) => mime === 'video/youtube' ? 'youtube'
|
||||
: mime?.startsWith('video') ? 'video'
|
||||
: mime?.startsWith('image') ? 'image'
|
||||
: mime?.startsWith('audio') ? 'audio'
|
||||
: mime === 'application/pdf' ? 'pdf' : 'file';
|
||||
|
||||
const cards = processQueueItems(pending).map(p => {
|
||||
const names = p.tags.map(t => t.normalized);
|
||||
const rating = names.includes('nsfl') ? 'nsfl' : names.includes('nsfw') ? 'nsfw' : names.includes('sfw') ? 'sfw' : 'untagged';
|
||||
// Main media (cover for albums)
|
||||
const albumRows = p.is_album ? (albumsById.get(p.id) || []) : [];
|
||||
const coverRow = albumRows.find(r => r.dest === p.dest) || albumRows.find(r => r.order_index === 0);
|
||||
const media = [{
|
||||
index: 1, aid: coverRow ? coverRow.id : null, kind: mediaKind(p.mime), mime: p.mime,
|
||||
src: `/mod/pending/b/${p.dest}`, thumb: `/mod/pending/t/${p.id}.webp`,
|
||||
width: p.width, height: p.height
|
||||
}];
|
||||
if (p.is_album) {
|
||||
const subs = albumRows.filter(r => r !== coverRow);
|
||||
for (const r of subs) {
|
||||
media.push({
|
||||
index: media.length + 1, aid: r.id, kind: mediaKind(r.mime), mime: r.mime,
|
||||
src: `/mod/pending/b/${r.dest}`, thumb: `/mod/pending/t/${r.dest.replace(/\.[^.]+$/, '')}.webp`,
|
||||
width: r.width, height: r.height
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
...p,
|
||||
rating,
|
||||
kind: mediaKind(p.mime),
|
||||
media,
|
||||
media_json: JSON.stringify(media),
|
||||
is_album_view: media.length > 1,
|
||||
time_ago: p.stamp ? lib.timeAgo(new Date(p.stamp * 1000), req.lang) : '',
|
||||
time_full: p.stamp ? new Date(p.stamp * 1000).toISOString() : '',
|
||||
size_fmt: p.size ? lib.formatSize(p.size) : '',
|
||||
dims: (p.width && p.height) ? `${p.width}×${p.height}` : ''
|
||||
};
|
||||
});
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('mod/approve', {
|
||||
pending: cards,
|
||||
total,
|
||||
page,
|
||||
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
|
||||
enable_nsfl: !!cfg.enable_nsfl,
|
||||
nsfl_tag_id: nsflId,
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Soft deleted: removed items that still exist on disk and can be restored or purged
|
||||
router.get(/^\/mod\/trash\/?$/, lib.modAuth, async (req, res) => {
|
||||
const page = queuePage(req);
|
||||
const [{ n: total }] = await db`select count(*)::int as n from "items" where active = false and is_deleted = true and is_purged = false`;
|
||||
// Fetch Trash (deleted)
|
||||
const trash = await db`
|
||||
select i.id, i.mime, i.username, i.dest,
|
||||
select i.id, i.mime, i.username, i.dest,
|
||||
json_agg(json_build_object('tag', t.tag, 'normalized', t.normalized)) as tags,
|
||||
(select details->>'reason' from audit_log where target_id = i.id::text and action = 'delete_item' order by created_at desc limit 1) as delete_reason
|
||||
from "items" i
|
||||
@@ -166,121 +66,45 @@ export default (router, tpl) => {
|
||||
where i.active = false and i.is_deleted = true and i.is_purged = false
|
||||
group by i.id
|
||||
order by i.id desc
|
||||
limit ${QUEUE_PAGE_SIZE} offset ${(page - 1) * QUEUE_PAGE_SIZE}
|
||||
limit 20
|
||||
`;
|
||||
|
||||
const processItems = (items) => {
|
||||
return items.map(p => {
|
||||
const tags = (p.tags || [])
|
||||
.filter(t => t.tag !== null)
|
||||
.map(t => {
|
||||
let badge = "badge-light";
|
||||
if (t.tag.startsWith(">")) badge = "badge-greentext badge-light";
|
||||
else if (t.normalized === "ukraine") badge = "badge-ukraine badge-light";
|
||||
else if (/[а-яё]/.test(t.normalized) || t.normalized === "russia") badge = "badge-russia badge-light";
|
||||
else if (t.normalized === "german") badge = "badge-german badge-light";
|
||||
else if (t.normalized === "dutch") badge = "badge-dutch badge-light";
|
||||
else if (t.normalized === "sfw") badge = "badge-success";
|
||||
else if (t.normalized === "nsfw") badge = "badge-danger";
|
||||
|
||||
return { ...t, badge };
|
||||
});
|
||||
|
||||
return {
|
||||
...p,
|
||||
tags
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('mod/trash', {
|
||||
trash: processQueueItems(trash),
|
||||
total,
|
||||
body: tpl.render('mod/approve', {
|
||||
pending: processItems(pending),
|
||||
trash: processItems(trash),
|
||||
page,
|
||||
pages: Math.max(1, Math.ceil(total / QUEUE_PAGE_SIZE)),
|
||||
stats: { total: pending.length + trash.length },
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
const jsonReply = (res, code, obj) => {
|
||||
const body = JSON.stringify(obj);
|
||||
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
};
|
||||
|
||||
// Move an item's media (and album sub-items) from the pending or deleted folder back to the public folders.
|
||||
const moveItemFilesToPublic = async (item, id) => {
|
||||
const movePaths = [
|
||||
{ b: path.join(cfg.paths.pending, 'b', item.dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
||||
{ b: path.join(cfg.paths.deleted, 'b', item.dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
||||
];
|
||||
|
||||
const isYouTube = item.mime === 'video/youtube';
|
||||
for (const p of movePaths) {
|
||||
try {
|
||||
if (isYouTube) {
|
||||
await fs.access(p.t);
|
||||
} else {
|
||||
await fs.access(p.b);
|
||||
}
|
||||
console.log(`[MOD MOVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
||||
|
||||
const moveSafe = async (src, dst) => {
|
||||
try {
|
||||
const lstat = await fs.lstat(src);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(src);
|
||||
const absTarget = path.resolve(path.dirname(src), target);
|
||||
const relTarget = path.relative(path.dirname(dst), absTarget);
|
||||
await fs.symlink(relTarget, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
} else {
|
||||
await fs.copyFile(src, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
console.warn(`[MOD MOVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bDst = path.join(cfg.paths.b, item.dest);
|
||||
const tDst = path.join(cfg.paths.t, `${id}.webp`);
|
||||
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
||||
|
||||
if (!isYouTube) {
|
||||
await moveSafe(p.b, bDst);
|
||||
}
|
||||
await moveSafe(p.t, tDst);
|
||||
|
||||
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
||||
await moveSafe(blurSrc, blurDst);
|
||||
|
||||
if (item.mime.startsWith('audio')) {
|
||||
await moveSafe(p.ca, caDst);
|
||||
}
|
||||
|
||||
if (item.is_album) {
|
||||
try {
|
||||
const subItems = await db`SELECT dest FROM album_items WHERE item_id = ${id}`;
|
||||
for (const sub of subItems) {
|
||||
const subBase = sub.dest.replace(/\.[^.]+$/, '');
|
||||
await moveSafe(path.join(cfg.paths.pending, 'b', sub.dest), path.join(cfg.paths.b, sub.dest));
|
||||
await moveSafe(path.join(cfg.paths.pending, 't', `${subBase}.webp`), path.join(cfg.paths.t, `${subBase}.webp`));
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
break;
|
||||
} catch (e) { }
|
||||
}
|
||||
};
|
||||
|
||||
// Permanently remove an item's files and comments and flag it purged (admin action).
|
||||
const purgeItem = async (item, id) => {
|
||||
await safeDeleteMediaFile(item.dest, id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.t, `${id}_blur.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}_blur.webp`)).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
|
||||
}
|
||||
await db`update "items" set is_purged = true where id = ${id}`;
|
||||
await db`delete from comments where item_id = ${id}`;
|
||||
};
|
||||
|
||||
const uploaderInfoFor = async (username) => {
|
||||
try {
|
||||
const u = await db`select id, "user" as username from "user" where login = ${username} or "user" = ${username} limit 1`;
|
||||
return u.length ? { uploader_id: u[0].id, uploader_name: u[0].username } : {};
|
||||
} catch { return {}; }
|
||||
};
|
||||
|
||||
// F-005 Security: Approve action — POST with CSRF protection
|
||||
router.post(/^\/mod\/approve\/?/, lib.modAuth, async (req, res) => {
|
||||
const id = +(req.post?.id || 0);
|
||||
@@ -290,18 +114,15 @@ export default (router, tpl) => {
|
||||
}
|
||||
|
||||
const f0ck = await db`
|
||||
select i.dest, i.mime, i.username, i.id, i.visibility, i.is_album, i.album_count,
|
||||
(select ta2.tag_id from tags_assign ta2
|
||||
join tags t2 on t2.id = ta2.tag_id
|
||||
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl')
|
||||
limit 1) as tag_id
|
||||
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
|
||||
from "items" i
|
||||
where i.id = ${id} and i.active = false and i.is_deleted = false
|
||||
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
|
||||
where i.id = ${id} and i.active = false
|
||||
limit 1
|
||||
`;
|
||||
|
||||
if (f0ck.length === 0) {
|
||||
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: not in the approval queue` });
|
||||
const body = JSON.stringify({ success: false, msg: `f0ck ${id}: f0ck not found` });
|
||||
return res.writeHead(404, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
|
||||
@@ -318,12 +139,8 @@ export default (router, tpl) => {
|
||||
// We only proceed with side-effects (notifications/webhooks) if the update actually changed active=false to active=true.
|
||||
// This prevents duplicate webhooks from double-clicks or race conditions.
|
||||
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and active = false`;
|
||||
invalidateHiddenItems();
|
||||
|
||||
if (result.count === 1) {
|
||||
// Mark pending upload notifications as read for staff
|
||||
await db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id = ${id}`.catch(() => {});
|
||||
|
||||
await audit.log(req.session.id, 'approve_item', 'item', id, { filename: f0ck[0].dest, ...uploaderInfo });
|
||||
|
||||
// Notify User (WebSocket/Internal)
|
||||
@@ -397,9 +214,7 @@ export default (router, tpl) => {
|
||||
mime: f0ck[0].mime,
|
||||
username: f0ck[0].username,
|
||||
tag_id: f0ck[0].tag_id,
|
||||
is_oc: !!f0ck[0].is_oc,
|
||||
is_album: !!f0ck[0].is_album,
|
||||
album_count: f0ck[0].album_count || 0
|
||||
is_oc: !!f0ck[0].is_oc
|
||||
})})`;
|
||||
} catch (err) {
|
||||
console.error('[MOD APPROVE] new_item notify failed:', err);
|
||||
@@ -407,7 +222,61 @@ export default (router, tpl) => {
|
||||
}
|
||||
}
|
||||
|
||||
await moveItemFilesToPublic(f0ck[0], id);
|
||||
// Move files to public location
|
||||
const movePaths = [
|
||||
{ b: path.join(cfg.paths.pending, 'b', f0ck[0].dest), t: path.join(cfg.paths.pending, 't', `${id}.webp`), ca: path.join(cfg.paths.pending, 'ca', `${id}.webp`) },
|
||||
{ b: path.join(cfg.paths.deleted, 'b', f0ck[0].dest), t: path.join(cfg.paths.deleted, 't', `${id}.webp`), ca: path.join(cfg.paths.deleted, 'ca', `${id}.webp`) }
|
||||
];
|
||||
|
||||
const isYouTube = f0ck[0].mime === 'video/youtube';
|
||||
for (const p of movePaths) {
|
||||
try {
|
||||
if (isYouTube) {
|
||||
await fs.access(p.t);
|
||||
} else {
|
||||
await fs.access(p.b);
|
||||
}
|
||||
console.log(`[MOD APPROVE] Moving files for item ${id} from ${p.b.includes('pending') ? 'pending' : 'deleted'}`);
|
||||
|
||||
const moveSafe = async (src, dst) => {
|
||||
try {
|
||||
const lstat = await fs.lstat(src);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(src);
|
||||
const absTarget = path.resolve(path.dirname(src), target);
|
||||
const relTarget = path.relative(path.dirname(dst), absTarget);
|
||||
await fs.symlink(relTarget, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
} else {
|
||||
await fs.copyFile(src, dst);
|
||||
await fs.unlink(src).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') {
|
||||
console.warn(`[MOD APPROVE ERROR] Failed to move ${src} to ${dst}:`, e.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const bDst = path.join(cfg.paths.b, f0ck[0].dest);
|
||||
const tDst = path.join(cfg.paths.t, `${id}.webp`);
|
||||
const blurDst = path.join(cfg.paths.t, `${id}_blur.webp`);
|
||||
const caDst = path.join(cfg.paths.ca, `${id}.webp`);
|
||||
|
||||
if (!isYouTube) {
|
||||
await moveSafe(p.b, bDst);
|
||||
}
|
||||
await moveSafe(p.t, tDst);
|
||||
|
||||
const blurSrc = p.t.replace('.webp', '_blur.webp');
|
||||
await moveSafe(blurSrc, blurDst);
|
||||
|
||||
if (f0ck[0].mime.startsWith('audio')) {
|
||||
await moveSafe(p.ca, caDst);
|
||||
}
|
||||
break;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
const body = JSON.stringify({ success: true, item_id: id, msg: "Item approved" });
|
||||
@@ -432,11 +301,26 @@ export default (router, tpl) => {
|
||||
if (item.is_deleted) {
|
||||
// PURGE LOGIC (Strict Admin)
|
||||
if (!req.session.admin) {
|
||||
const body = JSON.stringify({ success: false, msg: "Only admins can purge items permanently." });
|
||||
return res.writeHead(403, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
return res.reply({ success: false, msg: "Only admins can purge items permanently." });
|
||||
}
|
||||
|
||||
await purgeItem(item, id);
|
||||
// Delete files — respect symlink ownership
|
||||
await safeDeleteMediaFile(item.dest, id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 't', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${id}.webp`)).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${id}.webp`)).catch(() => { });
|
||||
}
|
||||
|
||||
// DB Flag instead of delete
|
||||
await db`update "items" set is_purged = true where id = ${id}`;
|
||||
// Delete comments permanently on purge
|
||||
await db`delete from comments where item_id = ${id}`;
|
||||
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
@@ -480,7 +364,6 @@ export default (router, tpl) => {
|
||||
const reason = req.post?.reason || "Denied by moderator";
|
||||
|
||||
await db`update "items" set is_deleted = true, active = false where id = ${id}`;
|
||||
await db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id = ${id}`.catch(() => {});
|
||||
|
||||
// Fetch uploader details for audit log and notification
|
||||
let uploaderId = null;
|
||||
@@ -519,28 +402,16 @@ export default (router, tpl) => {
|
||||
const page = +(req.url.qs?.page || 1);
|
||||
const limit = 50;
|
||||
const offset = (page - 1) * limit;
|
||||
const filterAction = req.url.qs?.action?.trim() || '';
|
||||
const filterUser = req.url.qs?.user?.trim() || '';
|
||||
|
||||
const logs = await db`
|
||||
SELECT al.*, u.user as username
|
||||
FROM audit_log al
|
||||
LEFT JOIN "user" u ON al.user_id = u.id
|
||||
WHERE true
|
||||
${filterAction ? db`AND al.action = ${filterAction}` : db``}
|
||||
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
|
||||
ORDER BY al.created_at DESC
|
||||
LIMIT ${limit} OFFSET ${offset}
|
||||
`;
|
||||
|
||||
const totalResult = await db`
|
||||
SELECT count(*) as c
|
||||
FROM audit_log al
|
||||
LEFT JOIN "user" u ON al.user_id = u.id
|
||||
WHERE true
|
||||
${filterAction ? db`AND al.action = ${filterAction}` : db``}
|
||||
${filterUser ? db`AND u.user ILIKE ${'%' + filterUser + '%'}` : db``}
|
||||
`;
|
||||
const totalResult = await db`SELECT count(*) as c FROM audit_log`;
|
||||
const total = totalResult[0].c;
|
||||
const pages = Math.ceil(total / limit);
|
||||
|
||||
@@ -602,9 +473,7 @@ export default (router, tpl) => {
|
||||
logs: processed,
|
||||
page,
|
||||
pages,
|
||||
hasMore: page < pages,
|
||||
filterAction,
|
||||
filterUser
|
||||
hasMore: page < pages
|
||||
});
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
}
|
||||
@@ -617,8 +486,6 @@ export default (router, tpl) => {
|
||||
logs: processedLogs,
|
||||
page,
|
||||
pages,
|
||||
filterAction,
|
||||
filterUser,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
@@ -672,7 +539,6 @@ export default (router, tpl) => {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
await db`update "items" set is_deleted = true, active = false where id = ${+id}`;
|
||||
await db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id = ${+id}`.catch(() => {});
|
||||
// Fetch uploader details for audit log
|
||||
let uploaderInfo = {};
|
||||
try {
|
||||
@@ -798,138 +664,26 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Deny a single picture of a pending album. The rest stays in the queue; if the cover is denied the next
|
||||
// picture becomes the cover, and with one picture left the album is degraded to a normal single post.
|
||||
router.post(/^\/mod\/album-item\/deny\/?$/, lib.modAuth, async (req, res) => {
|
||||
const itemId = +(req.post?.item_id || 0);
|
||||
const subId = +(req.post?.album_item_id || 0);
|
||||
const reason = (req.post?.reason || '').toString().trim();
|
||||
if (!itemId || !subId) return jsonReply(res, 400, { success: false, msg: 'Missing item_id or album_item_id' });
|
||||
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
|
||||
|
||||
const [item] = await db`select id, dest, mime, username, is_album from "items" where id = ${itemId} and active = false and is_deleted = false limit 1`;
|
||||
if (!item || !item.is_album) return jsonReply(res, 404, { success: false, msg: 'Not a pending album' });
|
||||
|
||||
const subs = await db`
|
||||
select id, dest, mime, size, checksum, phash, width, height, order_index
|
||||
from album_items where item_id = ${itemId} order by order_index asc, id asc
|
||||
`;
|
||||
const target = subs.find(r => r.id === subId);
|
||||
if (!target) return jsonReply(res, 404, { success: false, msg: 'Album item not found' });
|
||||
if (subs.length <= 1) return jsonReply(res, 400, { success: false, msg: 'Only one item left, deny the whole post instead' });
|
||||
|
||||
const remaining = subs.filter(r => r.id !== subId);
|
||||
const isCover = target.dest === item.dest;
|
||||
const degraded = remaining.length === 1;
|
||||
|
||||
await db.begin(async sql => {
|
||||
await sql`delete from album_items where id = ${subId}`;
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
await sql`update album_items set order_index = ${i} where id = ${remaining[i].id}`;
|
||||
}
|
||||
if (isCover) {
|
||||
const c = remaining[0];
|
||||
await sql`
|
||||
update "items" set dest = ${c.dest}, mime = ${c.mime}, size = ${c.size},
|
||||
checksum = ${c.checksum}, phash = ${c.phash}, width = ${c.width}, height = ${c.height}
|
||||
where id = ${itemId}
|
||||
`;
|
||||
}
|
||||
if (degraded) {
|
||||
await sql`delete from album_items where item_id = ${itemId}`;
|
||||
await sql`update "items" set is_album = false, album_count = 0 where id = ${itemId}`;
|
||||
} else {
|
||||
await sql`update "items" set album_count = ${remaining.length} where id = ${itemId}`;
|
||||
}
|
||||
});
|
||||
|
||||
// Remove the denied picture's files from the pending folders
|
||||
const base = target.dest.replace(/\.[^.]+$/, '');
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'b', target.dest)).catch(() => {});
|
||||
await fs.unlink(path.join(cfg.paths.pending, 't', `${base}.webp`)).catch(() => {});
|
||||
await fs.unlink(path.join(cfg.paths.pending, 'ca', `${base}.webp`)).catch(() => {});
|
||||
|
||||
// New cover: regenerate the item's main + blurred thumbnail from it
|
||||
if (isCover) {
|
||||
const c = remaining[0];
|
||||
try {
|
||||
await queue.genThumbnail(c.dest, c.mime, itemId, null, true);
|
||||
await queue.genBlurredThumbnail(itemId, true);
|
||||
} catch (err) {
|
||||
console.error('[MOD ALBUM DENY] Thumbnail regeneration failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
await audit.log(req.session.id, 'deny_album_item', 'item', itemId, {
|
||||
filename: target.dest, album_item_id: subId, reason,
|
||||
remaining: remaining.length, cover_changed: isCover, degraded,
|
||||
...(await uploaderInfoFor(item.username))
|
||||
});
|
||||
|
||||
return jsonReply(res, 200, { success: true, remaining: remaining.length, cover_changed: isCover, degraded });
|
||||
});
|
||||
|
||||
// ── Soft deleted: restore / purge (own endpoints, independent of the approval queue) ──
|
||||
|
||||
// Restore a soft-deleted item: back to public, no "approved" notification or webhook
|
||||
router.post(/^\/mod\/trash\/restore\/?$/, lib.modAuth, async (req, res) => {
|
||||
const id = +(req.post?.id || 0);
|
||||
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
|
||||
|
||||
const rows = await db`
|
||||
select i.id, i.dest, i.mime, i.username, i.visibility, i.is_album, i.album_count, i.is_oc,
|
||||
(select ta2.tag_id from tags_assign ta2 join tags t2 on t2.id = ta2.tag_id
|
||||
where ta2.item_id = i.id and t2.tag in ('sfw','nsfw','nsfl') limit 1) as tag_id
|
||||
from "items" i
|
||||
where i.id = ${id} and i.is_deleted = true and i.is_purged = false
|
||||
limit 1
|
||||
`;
|
||||
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
|
||||
const item = rows[0];
|
||||
|
||||
const result = await db`update "items" set active = true, is_deleted = false where id = ${id} and is_deleted = true`;
|
||||
invalidateHiddenItems();
|
||||
if (result.count !== 1) return jsonReply(res, 409, { success: false, msg: 'Item was already restored' });
|
||||
|
||||
await moveItemFilesToPublic(item, id);
|
||||
await audit.log(req.session.id, 'restore_item', 'item', id, { filename: item.dest, ...(await uploaderInfoFor(item.username)) });
|
||||
|
||||
// Live grid update so the item reappears for open tabs
|
||||
if ((item.visibility || 0) === 0) {
|
||||
db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id, dest: item.dest, mime: item.mime, username: item.username, tag_id: item.tag_id,
|
||||
is_oc: !!item.is_oc, is_album: !!item.is_album, album_count: item.album_count || 0
|
||||
})})`.catch(err => console.error('[MOD RESTORE] new_item notify failed:', err));
|
||||
}
|
||||
|
||||
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item restored' });
|
||||
});
|
||||
|
||||
// Permanently purge one soft-deleted item (admins only)
|
||||
router.post(/^\/mod\/trash\/purge\/?$/, lib.auth, async (req, res) => {
|
||||
const id = +(req.post?.id || 0);
|
||||
if (!id) return jsonReply(res, 400, { success: false, msg: 'No ID provided' });
|
||||
const reason = (req.post?.reason || '').toString().trim();
|
||||
if (!reason) return jsonReply(res, 400, { success: false, msg: 'A reason is required' });
|
||||
|
||||
const rows = await db`select id, dest, mime, username from "items" where id = ${id} and is_deleted = true and is_purged = false limit 1`;
|
||||
if (!rows.length) return jsonReply(res, 404, { success: false, msg: `Item ${id} is not soft deleted` });
|
||||
const item = rows[0];
|
||||
|
||||
await purgeItem(item, id);
|
||||
await audit.log(req.session.id, 'purge_item', 'item', id, { filename: item.dest, reason, ...(await uploaderInfoFor(item.username)) });
|
||||
return jsonReply(res, 200, { success: true, item_id: id, msg: 'Item purged' });
|
||||
});
|
||||
|
||||
// Purge Trash (POST) - Strict Admin
|
||||
router.post(/^\/mod\/(?:purge-trash-all|trash\/purge-all)\/?$/, lib.auth, async (req, res) => {
|
||||
router.post(/^\/mod\/purge-trash-all\/?/, lib.auth, async (req, res) => {
|
||||
try {
|
||||
// lib.auth already ensures session.admin
|
||||
const trash = await db`select id, dest, mime from "items" where active = false and is_deleted = true and is_purged = false`;
|
||||
let count = 0;
|
||||
for (const item of trash) {
|
||||
try {
|
||||
await purgeItem(item, item.id);
|
||||
await safeDeleteMediaFile(item.dest, item.id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'b', item.dest)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 't', `${item.id}.webp`)).catch(() => { });
|
||||
if (item.mime?.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => { });
|
||||
await fs.unlink(path.join(cfg.paths.deleted, 'ca', `${item.id}.webp`)).catch(() => { });
|
||||
}
|
||||
|
||||
await db`update "items" set is_purged = true where id = ${item.id}`;
|
||||
// Delete comments permanently on purge
|
||||
await db`delete from comments where item_id = ${item.id}`;
|
||||
count++;
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
@@ -1,67 +1,12 @@
|
||||
import db from "../sql.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import { getEnableItemSlugs, isAnonymizeSession, getHwFingerprintEnabled } from "../settings.mjs";
|
||||
import { getEnableItemSlugs } from "../settings.mjs";
|
||||
import { setMotd } from "../motd.mjs";
|
||||
import security from "../security.mjs";
|
||||
|
||||
export const clients = new Set();
|
||||
const activeTabs = new Map(); // sessionId -> tabId
|
||||
|
||||
export function broadcastBan(data) {
|
||||
if (!data) return;
|
||||
const targetUserIds = new Set();
|
||||
if (data.userId) targetUserIds.add(+data.userId);
|
||||
if (Array.isArray(data.userIds)) data.userIds.forEach(id => targetUserIds.add(+id));
|
||||
|
||||
const targetFps = new Set();
|
||||
if (data.fingerprint) targetFps.add(data.fingerprint);
|
||||
if (Array.isArray(data.fingerprints)) data.fingerprints.forEach(fp => targetFps.add(fp));
|
||||
|
||||
const targetHws = new Set();
|
||||
if (data.hwFingerprint) targetHws.add(data.hwFingerprint);
|
||||
if (Array.isArray(data.hwFingerprints)) data.hwFingerprints.forEach(hw => targetHws.add(hw));
|
||||
|
||||
const targetIps = new Set();
|
||||
if (data.ip) targetIps.add(data.ip);
|
||||
if (Array.isArray(data.ips)) data.ips.forEach(ip => targetIps.add(ip));
|
||||
|
||||
const targetIpHashes = new Set();
|
||||
if (data.ipHash) targetIpHashes.add(data.ipHash);
|
||||
if (Array.isArray(data.ipHashes)) data.ipHashes.forEach(h => targetIpHashes.add(h));
|
||||
|
||||
for (const client of clients) {
|
||||
let isMatch = false;
|
||||
|
||||
if (client.userId && targetUserIds.has(+client.userId)) {
|
||||
isMatch = true;
|
||||
} else if (client.fingerprint && targetFps.has(client.fingerprint)) {
|
||||
isMatch = true;
|
||||
} else if (client.hwFingerprint && targetHws.has(client.hwFingerprint)) {
|
||||
isMatch = true;
|
||||
} else if (client.ip && targetIps.has(client.ip)) {
|
||||
isMatch = true;
|
||||
} else if (client.ipHash && (targetIpHashes.has(client.ipHash) || targetIps.has(client.ipHash))) {
|
||||
isMatch = true;
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
console.log(`[SSE] Delivering instant ban to client (userId: ${client.userId}, ip: ${client.ip}, tab: ${client.tabId})`);
|
||||
client.send({
|
||||
type: 'banned',
|
||||
data: {
|
||||
reason: data.reason || 'Violation of community rules',
|
||||
expires: data.expires ? (isNaN(new Date(data.expires).getTime()) ? data.expires : new Date(data.expires).toLocaleString()) : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}
|
||||
});
|
||||
setTimeout(() => {
|
||||
client.close();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast the deduplicated online-user list to all connected clients
|
||||
function broadcastChatPresence() {
|
||||
const seen = new Set();
|
||||
@@ -138,17 +83,6 @@ db.listen('warnings', (payload) => {
|
||||
}
|
||||
}).catch(err => console.error('DB Listen Warning error:', err));
|
||||
|
||||
// Global listener for bans
|
||||
db.listen('bans', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Received ban event via database notify:`, data);
|
||||
broadcastBan(data);
|
||||
} catch (e) {
|
||||
console.error('[SSE] Ban broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('[SSE] DB Listen Ban error:', err));
|
||||
|
||||
// Global listener for profile updates (display name changes etc.)
|
||||
db.listen('profile_update', (payload) => {
|
||||
try {
|
||||
@@ -268,37 +202,9 @@ db.listen('favorites', async (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting favorite update for item ${data.item_id} to ${clients.size} clients`);
|
||||
// Broadcast to ALL connected clients with per-client anonymization
|
||||
// Broadcast to ALL connected clients
|
||||
for (const client of clients) {
|
||||
const isAnonClient = client.session ? isAnonymizeSession(client.session) : (client.isAnonymized ?? isAnonymizeSession(null));
|
||||
if (isAnonClient) {
|
||||
const anonymizedFavs = Array.isArray(data.favs) ? data.favs.map(f => {
|
||||
const isSelf = client.userId && f.user_id && Number(f.user_id) === Number(client.userId);
|
||||
if (isSelf) return f;
|
||||
return {
|
||||
user_id: null,
|
||||
user: 'anonymous',
|
||||
login: 'anonymous',
|
||||
display_name: 'Anonymous',
|
||||
avatar: null,
|
||||
avatar_file: null,
|
||||
username_color: null,
|
||||
hide_fav_badge: f.hide_fav_badge,
|
||||
is_anon: true
|
||||
};
|
||||
}) : [];
|
||||
client.send({
|
||||
type: 'favorites',
|
||||
data: {
|
||||
item_id: data.item_id,
|
||||
user_id: (client.userId && data.user_id && Number(client.userId) === Number(data.user_id)) ? data.user_id : null,
|
||||
favorited: data.favorited,
|
||||
favs: anonymizedFavs
|
||||
}
|
||||
});
|
||||
} else {
|
||||
client.send({ type: 'favorites', data });
|
||||
}
|
||||
client.send({ type: 'favorites', data });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Favorite broadcast error:', e);
|
||||
@@ -318,20 +224,6 @@ db.listen('motd', (payload) => {
|
||||
}
|
||||
}).catch(err => console.error('DB Listen MOTD error:', err));
|
||||
|
||||
// Global listener for brand image updates
|
||||
db.listen('brand_image', (payload) => {
|
||||
try {
|
||||
const data = JSON.parse(payload);
|
||||
console.log(`[SSE] Broadcasting brand_image update to ${clients.size} clients`);
|
||||
for (const client of clients) {
|
||||
client.send({ type: 'brand_image', data: { url: data.url || null } });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Brand image broadcast error:', e);
|
||||
}
|
||||
}).catch(err => console.error('DB Listen brand_image error:', err));
|
||||
|
||||
|
||||
// Global listener for new items (live grid updates)
|
||||
db.listen('new_item', (payload) => {
|
||||
try {
|
||||
@@ -487,19 +379,14 @@ db.listen('global_chat_topic', (payload) => {
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// Cleanup any orphaned or obsolete unread notifications on startup
|
||||
db`UPDATE notifications SET is_read = true WHERE type = 'report' AND reference_id IN (SELECT id FROM reports WHERE status != 'pending') AND is_read = false`.catch(err => console.error('[NOTIF CLEANUP] Failed to cleanup report notifications:', err));
|
||||
db`UPDATE notifications SET is_read = true WHERE type = 'admin_pending' AND item_id IN (SELECT id FROM items WHERE active = true OR is_deleted = true) AND is_read = false`.catch(err => console.error('[NOTIF CLEANUP] Failed to cleanup admin_pending notifications:', err));
|
||||
|
||||
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
|
||||
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning', 'invite_approved', 'invite_denied'];
|
||||
const ADMIN_TYPES = ['invite_request'];
|
||||
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
|
||||
|
||||
const nsflTagId = cfg.nsfl_tag_id || 3;
|
||||
|
||||
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
|
||||
const offset = (page - 1) * limit;
|
||||
const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
|
||||
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
|
||||
const notifications = typeFilter
|
||||
? await db`
|
||||
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
|
||||
@@ -595,9 +482,8 @@ export default (router, tpl) => {
|
||||
LEFT JOIN "user" u ON c.user_id = u.id
|
||||
LEFT JOIN user_options uo ON u.id = uo.user_id
|
||||
LEFT JOIN items i ON n.item_id = i.id
|
||||
LEFT JOIN reports r ON n.type = 'report' AND n.reference_id = r.id
|
||||
WHERE n.user_id = ${req.session.id} AND n.is_read = false
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning', 'invite_request', 'invite_approved', 'invite_denied')
|
||||
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning')
|
||||
OR (
|
||||
${req.session.do_not_disturb !== true} AND (
|
||||
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
|
||||
@@ -605,9 +491,7 @@ export default (router, tpl) => {
|
||||
)
|
||||
)
|
||||
)
|
||||
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning', 'invite_request', 'invite_approved', 'invite_denied'))
|
||||
AND (n.type != 'report' OR r.status = 'pending')
|
||||
AND (n.type != 'admin_pending' OR (i.active = false AND i.is_deleted = false))
|
||||
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning'))
|
||||
ORDER BY n.created_at DESC
|
||||
LIMIT 1000
|
||||
`;
|
||||
@@ -631,21 +515,15 @@ export default (router, tpl) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Mark all as read (optionally filtered by tab)
|
||||
// Mark all as read
|
||||
router.post('/api/notifications/read', async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
|
||||
try {
|
||||
const tab = req.url.qs?.tab || null;
|
||||
const typeFilter = tab === 'admin' ? ADMIN_TYPES : (tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null));
|
||||
if (typeFilter) {
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id} AND type = ANY(${typeFilter})`;
|
||||
} else {
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
|
||||
}
|
||||
await db`UPDATE notifications SET is_read = true WHERE user_id = ${req.session.id}`;
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
||||
body: JSON.stringify({ success: true, tab })
|
||||
body: JSON.stringify({ success: true })
|
||||
});
|
||||
} catch (err) {
|
||||
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
|
||||
@@ -672,19 +550,17 @@ export default (router, tpl) => {
|
||||
// Used when the user receives a live notification while already viewing that item.
|
||||
// System-type notifications (item_deleted, deny, report, admin_pending) are excluded —
|
||||
// they require explicit user acknowledgment.
|
||||
router.post(/\/api\/notifications\/item\/(?<itemId>[a-zA-Z0-9_-]{11}|\d+)\/read/, async (req, res) => {
|
||||
if (!req.session?.id) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const rawItemId = req.params.itemId;
|
||||
const numericId = await f0cklib.resolveNumericItemId(rawItemId);
|
||||
if (!numericId) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
|
||||
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
|
||||
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
|
||||
const itemId = req.params.itemId;
|
||||
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report', 'warning'];
|
||||
console.log(`[NotificationRoute] Marking notifications for item ${numericId} (${rawItemId}) as read for user ${req.session.id}`);
|
||||
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
|
||||
try {
|
||||
await db`
|
||||
UPDATE notifications
|
||||
SET is_read = true
|
||||
WHERE user_id = ${req.session.id}
|
||||
AND item_id = ${numericId}
|
||||
AND item_id = ${+itemId}
|
||||
AND NOT (type = ANY(${SYSTEM_TYPES}))
|
||||
`;
|
||||
return res.reply({
|
||||
@@ -727,18 +603,8 @@ export default (router, tpl) => {
|
||||
res.writeHead(200, headers);
|
||||
res.write(': ok\n\n'); // Warmup
|
||||
|
||||
const clientIp = security.getRealIP(req);
|
||||
const clientIpHash = security.hashIP(clientIp);
|
||||
const clientFp = req.session?.fingerprint || req.session?.anon_fingerprint || req.url.qs?.fp || null;
|
||||
const clientHw = getHwFingerprintEnabled() ? (req.session?.hw_fingerprint || req.url.qs?.hw || null) : null;
|
||||
const clientUserId = (req.session && typeof req.session === 'object') ? req.session.id : null;
|
||||
|
||||
const client = {
|
||||
userId: clientUserId,
|
||||
session: req.session || null,
|
||||
isAnonymized: isAnonymizeSession(req.session),
|
||||
fingerprint: clientFp,
|
||||
hwFingerprint: clientHw,
|
||||
userId: (req.session && typeof req.session === 'object') ? req.session.id : null,
|
||||
username: req.session?.user || null,
|
||||
display_name: req.session?.display_name || null,
|
||||
avatar_file: req.session?.avatar_file || null,
|
||||
@@ -751,8 +617,7 @@ export default (router, tpl) => {
|
||||
do_not_disturb: req.session?.do_not_disturb === true,
|
||||
sessionId,
|
||||
tabId,
|
||||
ip: clientIp,
|
||||
ipHash: clientIpHash,
|
||||
ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
|
||||
send: (data) => {
|
||||
try {
|
||||
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
@@ -767,62 +632,6 @@ export default (router, tpl) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Check if connecting client is already banned
|
||||
(async () => {
|
||||
let isBanned = false;
|
||||
let banReason = 'Violation of community rules';
|
||||
let banExpires = null;
|
||||
|
||||
if (client.userId) {
|
||||
const u = await db`SELECT banned, ban_reason, ban_expires FROM "user" WHERE id = ${client.userId} LIMIT 1`;
|
||||
if (u[0]?.banned) {
|
||||
isBanned = true;
|
||||
banReason = u[0].ban_reason || banReason;
|
||||
banExpires = u[0].ban_expires;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBanned && client.ip) {
|
||||
const ipBan = await security.isIpBanned(client.ip);
|
||||
if (ipBan) {
|
||||
isBanned = true;
|
||||
banReason = ipBan.reason || banReason;
|
||||
banExpires = ipBan.expires;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBanned && client.fingerprint) {
|
||||
const fpBan = await security.isFingerprintBanned(client.fingerprint);
|
||||
if (fpBan) {
|
||||
isBanned = true;
|
||||
banReason = fpBan.reason || banReason;
|
||||
banExpires = fpBan.expires;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isBanned && client.hwFingerprint) {
|
||||
const hwBan = await security.isHardwareBanned(client.hwFingerprint);
|
||||
if (hwBan) {
|
||||
isBanned = true;
|
||||
banReason = hwBan.reason || banReason;
|
||||
banExpires = hwBan.expires;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBanned) {
|
||||
console.log(`[SSE] Connecting client is already banned, pushing instant redirect to /banned`);
|
||||
client.send({
|
||||
type: 'banned',
|
||||
data: {
|
||||
reason: banReason,
|
||||
expires: banExpires ? (isNaN(new Date(banExpires).getTime()) ? banExpires : new Date(banExpires).toLocaleString()) : 'Permanent',
|
||||
redirect: '/banned'
|
||||
}
|
||||
});
|
||||
setTimeout(() => client.close(), 1000);
|
||||
}
|
||||
})().catch(err => console.error('[SSE] Initial ban check error:', err));
|
||||
|
||||
// Send any unacknowledged warnings on connection
|
||||
if (!isGuest && req.session?.id) {
|
||||
db`
|
||||
@@ -887,7 +696,7 @@ export default (router, tpl) => {
|
||||
// Notification History Page
|
||||
router.get('/notifications', async (req, res) => {
|
||||
if (!req.session) return res.redirect('/login');
|
||||
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs?.tab || 'user');
|
||||
const tab = req.url.qs?.tab || 'user';
|
||||
const data = await getNotificationHistory(req.session.id, 1, 50, tab);
|
||||
data.session = req.session;
|
||||
data.hidePagination = true;
|
||||
@@ -908,7 +717,7 @@ export default (router, tpl) => {
|
||||
success: false
|
||||
}, 401);
|
||||
const page = parseInt(req.url.qs.page) || 1;
|
||||
const tab = (cfg.enable_comments === false) ? 'system' : (req.url.qs.tab || null);
|
||||
const tab = req.url.qs.tab || null;
|
||||
const data = await getNotificationHistory(req.session.id, page, 50, tab);
|
||||
|
||||
const html = tpl.render('snippets/notifications-list', { ...data, active_mode: req.session?.mode ?? 0 }, req);
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import path from "path";
|
||||
import { promises as fs, createReadStream } from "fs";
|
||||
import audit from "../audit.mjs";
|
||||
import { getManualApproval, getSessionOwnerName, getEnableItemSlugs } from "../settings.mjs";
|
||||
import { invalidateHiddenItems } from "../hidden_items.mjs";
|
||||
|
||||
/**
|
||||
* pending.mjs — upload status for the uploader
|
||||
* GET /pending own recent uploads with status (pending / live / denied / removed)
|
||||
* GET /pending/t/:id.webp thumbnail of an own pending upload (pending files are not public)
|
||||
* POST /pending/delete withdraw an own upload that is still pending (files + row are removed)
|
||||
* GET /api/v2/uploads/:id/status JSON status for API clients (session or X-Api-Key)
|
||||
*/
|
||||
|
||||
const RECENT_LIMIT = 50;
|
||||
|
||||
const statusOf = (row) => {
|
||||
if (row.is_purged) return 'removed';
|
||||
if (row.is_deleted) return 'denied';
|
||||
if (row.active) return 'live';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
const itemPath = (row) => (getEnableItemSlugs() && row.slug) ? row.slug : row.id;
|
||||
|
||||
// Latest moderator reason for denied/removed items (deny, delete or purge)
|
||||
const reasonsFor = async (ids) => {
|
||||
if (!ids.length) return new Map();
|
||||
const rows = await db`
|
||||
select distinct on (target_id) target_id, details->>'reason' as reason
|
||||
from audit_log
|
||||
where target_id = any(${ids.map(String)}::text[])
|
||||
and action in ('deny_item', 'delete_item', 'purge_item')
|
||||
order by target_id, created_at desc
|
||||
`.catch(() => []);
|
||||
return new Map(rows.map(r => [Number(r.target_id), r.reason]));
|
||||
};
|
||||
|
||||
// Session user, or the account behind an X-Api-Key header (for API clients)
|
||||
const resolveUser = async (req) => {
|
||||
if (req.session?.id) return req.session;
|
||||
const key = req.headers['x-api-key'];
|
||||
if (!key || cfg.websrv.enable_user_api_keys === false) return null;
|
||||
const rows = await db`
|
||||
select u.id, u.user, u.login, u.admin, u.is_moderator, u.banned
|
||||
from user_api_keys k join "user" u on u.id = k.user_id
|
||||
where k.api_key = ${key} limit 1
|
||||
`.catch(() => []);
|
||||
if (!rows.length || rows[0].banned) return null;
|
||||
return rows[0];
|
||||
};
|
||||
|
||||
const isOwnerOf = (row, session) => {
|
||||
const owner = getSessionOwnerName(session);
|
||||
return !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase());
|
||||
};
|
||||
|
||||
// Every file a pending upload keeps in the pending folder (main file, thumbs, cover art, album sub-items)
|
||||
const pendingFilesOf = async (row) => {
|
||||
const p = (...parts) => path.join(cfg.paths.pending, ...parts);
|
||||
const files = [p('t', `${row.id}.webp`), p('t', `${row.id}_blur.webp`), p('ca', `${row.id}.webp`)];
|
||||
if (row.dest && row.mime !== 'video/youtube') files.push(p('b', row.dest));
|
||||
if (row.is_album) {
|
||||
const subs = await db`select dest from album_items where item_id = ${row.id}`.catch(() => []);
|
||||
for (const sub of subs) {
|
||||
files.push(p('b', sub.dest), p('t', `${sub.dest.replace(/\.[^.]+$/, '')}.webp`));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const json = (res, code, obj) => {
|
||||
const body = JSON.stringify(obj);
|
||||
return res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
|
||||
};
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
router.get(/^\/pending\/?$/, async (req, res) => {
|
||||
if (!req.session) return res.redirect('/login');
|
||||
const owner = getSessionOwnerName(req.session);
|
||||
|
||||
const rows = owner ? await db`
|
||||
select id, slug, mime, title, original_filename, stamp, size, active, is_deleted, is_purged, is_album, album_count, visibility
|
||||
from items
|
||||
where lower(username) = ${owner.toLowerCase()}
|
||||
order by id desc
|
||||
limit ${RECENT_LIMIT}
|
||||
` : [];
|
||||
|
||||
const reasons = await reasonsFor(rows.filter(r => r.is_deleted).map(r => r.id));
|
||||
const items = rows.map(r => {
|
||||
const status = statusOf(r);
|
||||
return {
|
||||
id: r.id,
|
||||
path: itemPath(r),
|
||||
status,
|
||||
title: r.title || r.original_filename || '',
|
||||
mime: r.mime,
|
||||
is_album: !!r.is_album,
|
||||
album_count: r.album_count || 0,
|
||||
size_fmt: r.size ? lib.formatSize(r.size) : '',
|
||||
time_ago: r.stamp ? lib.timeAgo(new Date(r.stamp * 1000), req.lang) : '',
|
||||
time_full: r.stamp ? new Date(r.stamp * 1000).toISOString() : '',
|
||||
thumb: status === 'live' ? `/t/${r.id}.webp` : (status === 'pending' ? `/pending/t/${r.id}.webp` : ''),
|
||||
reason: status === 'denied' || status === 'removed' ? (reasons.get(r.id) || '') : ''
|
||||
};
|
||||
});
|
||||
|
||||
const counts = { pending: 0, live: 0, denied: 0, removed: 0 };
|
||||
items.forEach(i => { counts[i.status]++; });
|
||||
|
||||
res.reply({
|
||||
body: tpl.render('pending', {
|
||||
items,
|
||||
counts,
|
||||
manual_approval_on: getManualApproval(),
|
||||
session: req.session,
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
});
|
||||
|
||||
// Thumbnail of an own pending upload (moderators may view any)
|
||||
router.get(/^\/pending\/t\/(?<id>\d+)\.webp$/, async (req, res) => {
|
||||
if (!req.session) return res.writeHead(401).end();
|
||||
const id = +req.params.id;
|
||||
const [row] = await db`select username, active, is_deleted from items where id = ${id} limit 1`;
|
||||
const isStaff = !!(req.session.admin || req.session.is_moderator);
|
||||
const isOwner = isOwnerOf(row, req.session);
|
||||
if (!row || row.active || row.is_deleted || !(isOwner || isStaff)) return res.writeHead(404).end();
|
||||
|
||||
const file = path.join(cfg.paths.pending, 't', `${id}.webp`);
|
||||
try {
|
||||
const stat = await fs.stat(file);
|
||||
res.writeHead(200, { 'Content-Type': 'image/webp', 'Content-Length': stat.size, 'Cache-Control': 'private, max-age=60' });
|
||||
createReadStream(file).pipe(res);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
// Withdraw an own upload before a moderator has looked at it. Only the uploader, only while pending.
|
||||
router.post(/^\/pending\/delete\/?$/, async (req, res) => {
|
||||
if (!req.session) return json(res, 401, { success: false, msg: 'Login required' });
|
||||
const id = +(req.post?.id || req.body?.id || 0);
|
||||
if (!id) return json(res, 400, { success: false, msg: 'No ID provided' });
|
||||
|
||||
const [row] = await db`select id, username, dest, mime, is_album, active, is_deleted, is_purged from items where id = ${id} limit 1`;
|
||||
if (!row || !isOwnerOf(row, req.session)) return json(res, 404, { success: false, msg: 'Upload not found' });
|
||||
if (row.active || row.is_deleted || row.is_purged) return json(res, 409, { success: false, msg: 'Only pending uploads can be deleted' });
|
||||
|
||||
// Collect files before the row goes (album_items cascade). The active = false guard lets a concurrent
|
||||
// approval win; files are only touched once the row is gone. Cascades clear tags, notifications, reports.
|
||||
const files = await pendingFilesOf(row);
|
||||
const deleted = await db`delete from items where id = ${id} and active = false and is_deleted = false returning id`;
|
||||
if (!deleted.length) return json(res, 409, { success: false, msg: 'Upload was already reviewed' });
|
||||
invalidateHiddenItems();
|
||||
await Promise.all(files.map(f => fs.unlink(f).catch(() => {})));
|
||||
await audit.log(req.session.id, 'withdraw_item', 'item', id, { filename: row.dest, uploader_name: row.username });
|
||||
|
||||
return json(res, 200, { success: true, id });
|
||||
});
|
||||
|
||||
// JSON status for API clients (e.g. f0ckm-uploader polling after an upload went to manual approval)
|
||||
router.get(/^\/api\/v2\/uploads\/(?<id>\d+)\/status\/?$/, async (req, res) => {
|
||||
const user = await resolveUser(req);
|
||||
if (!user) return json(res, 401, { success: false, msg: 'Login or X-Api-Key required' });
|
||||
|
||||
const id = +req.params.id;
|
||||
const [row] = await db`select id, slug, username, active, is_deleted, is_purged from items where id = ${id} limit 1`;
|
||||
const owner = getSessionOwnerName(user.is_anon !== undefined ? user : { ...user, is_anon: false });
|
||||
const isStaff = !!(user.admin || user.is_moderator);
|
||||
const isOwner = !!(row && owner && row.username && row.username.toLowerCase() === owner.toLowerCase());
|
||||
if (!row || !(isOwner || isStaff)) return json(res, 404, { success: false, msg: 'Upload not found' });
|
||||
|
||||
const status = statusOf(row);
|
||||
const reason = (status === 'denied' || status === 'removed') ? ((await reasonsFor([row.id])).get(row.id) || null) : null;
|
||||
return json(res, 200, {
|
||||
success: true,
|
||||
itemid: row.id,
|
||||
slug: row.slug || null,
|
||||
status,
|
||||
reason,
|
||||
url: status === 'live' ? `${cfg.main.url.full}/${itemPath(row)}` : `${cfg.main.url.full}/pending#i${row.id}`,
|
||||
status_url: `${cfg.main.url.full}/pending#i${row.id}`
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -43,21 +43,17 @@ export default (router, tpl) => {
|
||||
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
|
||||
console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr);
|
||||
|
||||
const cookieMime = req.cookies?.mime !== undefined ? (decodeURIComponent(req.cookies.mime).trim() || null) : null;
|
||||
const reqQueryMime = req.url?.searchParams?.get('mime') || req.url?.qs?.mime;
|
||||
const effectiveMime = (reqQueryMime !== undefined && reqQueryMime !== null) ? reqQueryMime : (cookieMime || opts.mime || null);
|
||||
|
||||
const data = await f0cklib.getRandom({
|
||||
user: opts.user,
|
||||
tag: opts.tag,
|
||||
hall: opts.hall,
|
||||
mime: effectiveMime,
|
||||
mime: opts.mime || (req.cookies.mime || null),
|
||||
page: opts.page,
|
||||
fav: opts.mode === 'favs',
|
||||
mode: req.mode,
|
||||
ratings: ratingsArr,
|
||||
strict: opts.strict,
|
||||
session: req.session
|
||||
session: !!req.session
|
||||
});
|
||||
|
||||
console.log("data", data);
|
||||
@@ -67,16 +63,12 @@ export default (router, tpl) => {
|
||||
code: 404,
|
||||
body: tpl.render('error', {
|
||||
message: data.message,
|
||||
tmp: null,
|
||||
session: req.session ? { ...req.session } : false,
|
||||
error_filter_hint: null,
|
||||
error_filter_hint_link: null,
|
||||
error_see_anyways: null
|
||||
tmp: null
|
||||
}, req)
|
||||
});
|
||||
}
|
||||
|
||||
res.redirect(encodeURI(`${data.link.main}${data.link.path}${data.itemid}${data.link.suffix || ''}`) + '?random=1');
|
||||
res.redirect(encodeURI(`${data.link.main}${data.link.path}${data.itemid}${data.link.suffix || ''}`));
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -59,11 +59,6 @@ export default (router, tpl) => {
|
||||
from "tags_assign"
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join "user_options" on "user_options".user_id = "user".id
|
||||
left join "anon_identities" on "anon_identities".user_id = "user".id
|
||||
where "user".id is not null
|
||||
and "anon_identities".id is null
|
||||
and "user".login not like 'anon_%'
|
||||
and "user".user != 'anonymous'
|
||||
group by "user".user, "user_options".avatar, "user_options".avatar_file, "user".admin, "user_options".display_name
|
||||
order by count desc
|
||||
`;
|
||||
@@ -86,15 +81,6 @@ export default (router, tpl) => {
|
||||
const totalUsers = +(await db`
|
||||
select count(*) as total
|
||||
from "user"
|
||||
left join "anon_identities" on "anon_identities".user_id = "user".id
|
||||
where "anon_identities".id is null
|
||||
and "user".login not like 'anon_%'
|
||||
and "user".user != 'anonymous'
|
||||
`)[0].total;
|
||||
|
||||
const totalAnonUsers = +(await db`
|
||||
select count(distinct pubkey) as total
|
||||
from anon_identities
|
||||
`)[0].total;
|
||||
|
||||
const hoster = await db`
|
||||
@@ -155,7 +141,6 @@ export default (router, tpl) => {
|
||||
totalComments,
|
||||
totalFavs,
|
||||
totalUsers,
|
||||
totalAnonUsers,
|
||||
enable_nsfl: config.enable_nsfl,
|
||||
diskSize: cachedDiskSize,
|
||||
tmp: null,
|
||||
|
||||
+18
-62
@@ -80,11 +80,6 @@ export default (router, tpl) => {
|
||||
return renderError("Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.");
|
||||
}
|
||||
|
||||
// anon_* logins are reserved for anonymous shadow users (treated as anon throughout the app)
|
||||
if (/^anon_/i.test(username) || username.toLowerCase() === 'anonymous') {
|
||||
return renderError("Username taken");
|
||||
}
|
||||
|
||||
if (!password || password.length < 20) {
|
||||
return renderError("Password must be at least 20 characters long.");
|
||||
}
|
||||
@@ -95,7 +90,7 @@ export default (router, tpl) => {
|
||||
|
||||
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
|
||||
const isOnion = lib.isOnionRequest(req);
|
||||
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.site_key && cfg.recaptcha?.secret_key) {
|
||||
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
|
||||
const rcToken = req.post['g-recaptcha-response'];
|
||||
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
|
||||
try {
|
||||
@@ -159,55 +154,23 @@ export default (router, tpl) => {
|
||||
const hash = await lib.hash(password);
|
||||
const ts = ~~(Date.now() / 1e3);
|
||||
|
||||
// Anonymous passkey identity registering: upgrade the existing shadow user in place
|
||||
// instead of creating a new one. Favs, comments, uploads and passkeys stay attached;
|
||||
// dropping the anon_identities row is what turns the account into a regular one.
|
||||
const upgradeAnonId = req.session?.is_anon ? req.session.id : null;
|
||||
|
||||
let userId;
|
||||
try {
|
||||
if (upgradeAnonId) {
|
||||
await db.begin(async sql => {
|
||||
// Uploads reference their owner by name (items.username = the anon shadow login),
|
||||
// so they move to the new username, the name regular uploads are stored under
|
||||
const [old] = await sql`select login from "user" where id = ${upgradeAnonId} for update`;
|
||||
if (old?.login) {
|
||||
await sql`update items set username = ${username} where lower(username) = lower(${old.login})`;
|
||||
}
|
||||
await sql`
|
||||
update "user"
|
||||
set "login" = ${username.toLowerCase()}, "password" = ${hash}, "user" = ${username},
|
||||
"email" = ${email || null}, "activated" = ${activated}, "activation_token" = ${activationToken}
|
||||
where id = ${upgradeAnonId}
|
||||
`;
|
||||
await sql`
|
||||
update user_options
|
||||
set display_name = null, avatar_file = coalesce(avatar_file, 'default.png')
|
||||
where user_id = ${upgradeAnonId}
|
||||
`;
|
||||
await sql`delete from anon_identities where user_id = ${upgradeAnonId}`;
|
||||
});
|
||||
userId = upgradeAnonId;
|
||||
if (global._invalidateSessionCache && req.cookies?.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
} else {
|
||||
const newUser = await db`
|
||||
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
|
||||
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
|
||||
returning id
|
||||
`;
|
||||
userId = newUser[0].id;
|
||||
const newUser = await db`
|
||||
insert into "user" ("login", "password", "user", "created_at", "admin", "is_moderator", "email", "activated", "activation_token")
|
||||
values (${username.toLowerCase()}, ${hash}, ${username}, to_timestamp(${ts}), false, false, ${email || null}, ${activated}, ${activationToken})
|
||||
returning id
|
||||
`;
|
||||
userId = newUser[0].id;
|
||||
|
||||
// Assign default avatar file
|
||||
const avatarId = null;
|
||||
const avatarFile = 'default.png';
|
||||
// Assign default avatar file
|
||||
const avatarId = null;
|
||||
const avatarFile = 'default.png';
|
||||
|
||||
await db`
|
||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
|
||||
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
|
||||
`;
|
||||
}
|
||||
await db`
|
||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
|
||||
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
|
||||
`;
|
||||
} catch (err) {
|
||||
console.error(`[REGISTER] DB Error during user creation:`, err);
|
||||
if (err.code === '23505') { // Unique constraint violation
|
||||
@@ -237,11 +200,6 @@ export default (router, tpl) => {
|
||||
// In production they should see an error, but let's keep it simple for now.
|
||||
}
|
||||
|
||||
if (upgradeAnonId) {
|
||||
// Unactivated accounts may not stay logged in — end the anon sessions until the email link is used
|
||||
await db`delete from user_sessions where user_id = ${upgradeAnonId}`;
|
||||
res.setHeader('Set-Cookie', `session=; ${lib.getCookieOptions('Thu, 01 Jan 1970 00:00:00 GMT')}`);
|
||||
}
|
||||
await renderSuccess("Registration successful! Please check your email to activate your account.");
|
||||
return;
|
||||
}
|
||||
@@ -259,15 +217,13 @@ export default (router, tpl) => {
|
||||
|
||||
await security.recordAttempt(ip, username, 'register', true);
|
||||
|
||||
const successMsg = upgradeAnonId
|
||||
? "Username claimed, you can still use your passkey."
|
||||
: "Registration successful! You can now login.";
|
||||
const successMsg = "Registration successful! You can now login.";
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || (req.headers.accept && req.headers.accept.includes('application/json'))) {
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg, upgraded: !!upgradeAnonId }));
|
||||
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg: successMsg }));
|
||||
}
|
||||
|
||||
// Upgraded accounts keep their current session; otherwise redirect home with login success message
|
||||
return res.writeHead(302, { "Location": upgradeAnonId ? "/settings" : "/?login=success" }).end();
|
||||
// Redirect to home with login success message
|
||||
return res.writeHead(302, { "Location": "/?login=success" }).end();
|
||||
});
|
||||
|
||||
return router;
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import audit from "../audit.mjs";
|
||||
import cfg from "../config.mjs";
|
||||
import security from "../security.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
|
||||
// Submit a new report (Users & Guests)
|
||||
router.post(/^\/api\/v2\/report\/?$/, async (req, res) => {
|
||||
// User: Submit a new report
|
||||
router.post(/^\/api\/v2\/report\/?$/, lib.loggedin, async (req, res) => {
|
||||
try {
|
||||
const { item_id, comment_id, reported_user_id, reason } = req.post;
|
||||
// The framework's readBody uses Object.fromEntries which loses duplicate keys
|
||||
// and decodeURIComponent on an array joins it as comma-separated string.
|
||||
// So categories[]= ends up as a single comma-joined string — split it back.
|
||||
const VALID_CATEGORIES = ['wrong_rating', 'spam', 'duplicate', 'copyright', 'illegal', 'other'];
|
||||
let rawCats = req.post['categories[]'] || req.post['categories'] || '';
|
||||
let categories = [];
|
||||
if (Array.isArray(rawCats)) {
|
||||
categories = rawCats;
|
||||
} else if (typeof rawCats === 'string' && rawCats.length > 0) {
|
||||
categories = rawCats.split(',').map(s => s.trim());
|
||||
}
|
||||
categories = categories.filter(c => VALID_CATEGORIES.includes(c));
|
||||
|
||||
if ((!reason || reason.trim().length === 0) && categories.length === 0) {
|
||||
return res.json({ success: false, msg: "Please select at least one reason or provide a description." }, 400);
|
||||
if (!reason || reason.trim().length === 0) {
|
||||
return res.json({ success: false, msg: "Reason is required." }, 400);
|
||||
}
|
||||
|
||||
// At least one target must be specified
|
||||
@@ -32,45 +18,14 @@ export default (router, tpl) => {
|
||||
return res.json({ success: false, msg: "Must specify an item, comment, or user to report." }, 400);
|
||||
}
|
||||
|
||||
const ip = security.getRealIP(req);
|
||||
const isGuest = !req.session;
|
||||
|
||||
if (isGuest) {
|
||||
// CAPTCHA verification for guest reports (bypassed for .onion and localhost)
|
||||
const isOnion = lib.isOnionRequest(req);
|
||||
const isLocalhost = lib.isLocalhostRequest(req);
|
||||
if (!isOnion && !isLocalhost && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
|
||||
const rcToken = req.post['g-recaptcha-response'];
|
||||
if (!rcToken) {
|
||||
return res.json({ success: false, msg: "Please complete the CAPTCHA." }, 400);
|
||||
}
|
||||
try {
|
||||
const verifyRes = await fetch('https://www.google.com/recaptcha/api/siteverify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ secret: cfg.recaptcha.secret_key, response: rcToken, remoteip: ip })
|
||||
});
|
||||
const { success } = await verifyRes.json();
|
||||
if (!success) {
|
||||
return res.json({ success: false, msg: "CAPTCHA verification failed. Please try again." }, 400);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[REPORT] reCAPTCHA error:', e.message);
|
||||
return res.json({ success: false, msg: "CAPTCHA verification error. Please try again." }, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reportRes = await db`
|
||||
INSERT INTO reports (reporter_id, reporter_ip, item_id, comment_id, user_id, reason, categories)
|
||||
INSERT INTO reports (reporter_id, item_id, comment_id, user_id, reason)
|
||||
VALUES (
|
||||
${req.session ? req.session.id : null},
|
||||
${security.storableIP(ip)},
|
||||
${req.session.id},
|
||||
${item_id ? +item_id : null},
|
||||
${comment_id ? +comment_id : null},
|
||||
${reported_user_id ? +reported_user_id : null},
|
||||
${reason ? reason.trim() : ''},
|
||||
${db.array(categories)}
|
||||
${reason.trim()}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
@@ -121,7 +76,6 @@ export default (router, tpl) => {
|
||||
COALESCE(tgt_u.user, tgt_auth.user, comm_auth.user) AS reported_user_name,
|
||||
COALESCE(NULLIF(r.user_id, 0), tgt_auth.id, comm_auth.id) AS reported_user_id,
|
||||
COALESCE(tgt_u.admin, tgt_auth.admin, comm_auth.admin) AS reported_user_is_admin,
|
||||
resolver.user AS resolver_name,
|
||||
i.dest AS item_dest,
|
||||
tgt_auth.id AS item_user_id,
|
||||
tgt_auth.user AS item_user_name,
|
||||
@@ -132,12 +86,10 @@ export default (router, tpl) => {
|
||||
c.content AS comment_body,
|
||||
COALESCE(r.item_id, c.item_id) AS resolved_item_id,
|
||||
COALESCE(i.dest, ci.dest) AS resolved_item_dest,
|
||||
COALESCE(i.mime, ci.mime) AS resolved_item_mime,
|
||||
COALESCE(i.visibility, ci.visibility, 0) AS resolved_item_visibility
|
||||
COALESCE(i.mime, ci.mime) AS resolved_item_mime
|
||||
FROM reports r
|
||||
LEFT JOIN "user" rep ON r.reporter_id = rep.id
|
||||
LEFT JOIN "user" tgt_u ON r.user_id = tgt_u.id
|
||||
LEFT JOIN "user" resolver ON r.resolved_by = resolver.id
|
||||
LEFT JOIN items i ON r.item_id = i.id
|
||||
LEFT JOIN "user" tgt_auth ON i.username = tgt_auth.user
|
||||
LEFT JOIN comments c ON r.comment_id = c.id
|
||||
@@ -203,13 +155,6 @@ export default (router, tpl) => {
|
||||
return res.json({ success: false, msg: "Report not found." }, 404);
|
||||
}
|
||||
|
||||
// Mark all notifications for this report as read across all moderators
|
||||
await db`
|
||||
UPDATE notifications
|
||||
SET is_read = true
|
||||
WHERE type = 'report' AND reference_id = ${id}
|
||||
`;
|
||||
|
||||
await audit.log(req.session.id, 'resolve_report', 'report', id, { status: action });
|
||||
|
||||
return res.json({ success: true, msg: `Report marked as ${action}.` });
|
||||
|
||||
@@ -2,15 +2,8 @@ import cfg from "../config.mjs";
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
import { isAnonymizeSession, isAnonSession } from "../settings.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
// Redirect /scroller to /abyss (preserving ID or fragment)
|
||||
router.get(/^\/scroller(?:\/(?<id>[a-zA-Z0-9_\/-]+))?\/?$/, (req, res) => {
|
||||
const id = req.params?.id || req.params?.[0];
|
||||
return res.redirect(id ? `/abyss/${id}` : '/abyss');
|
||||
});
|
||||
|
||||
// Serve the scroller page
|
||||
router.get(/^\/abyss(?:\/(?<id>[a-zA-Z0-9_\/-]+))?\/?$/, async (req, res) => {
|
||||
if (cfg.websrv.abyss_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
|
||||
@@ -54,12 +47,6 @@ export default (router, tpl) => {
|
||||
} catch (e) {
|
||||
console.error('[SCROLLER] Failed to fetch meta for ID:', id, e);
|
||||
}
|
||||
} else if (id && /^([a-z0-9]+)\/(\d+)$/i.test(id.trim())) {
|
||||
const chanMatch = id.trim().match(/^([a-z0-9]+)\/(\d+)$/i);
|
||||
const [, board, tid] = chanMatch;
|
||||
page_meta.title = `/${board}/${tid} — Abyss`;
|
||||
page_meta.description = `Watch /${board}/ thread ${tid} in Abyss`;
|
||||
page_meta.url = `https://${cfg.main.url.domain}/abyss/${board}/${tid}`;
|
||||
}
|
||||
|
||||
return res.reply({
|
||||
@@ -277,7 +264,7 @@ export default (router, tpl) => {
|
||||
${excludeSwfSQL}
|
||||
${excludePdfSQL}
|
||||
${excludeArchiveSQL}
|
||||
${(!req.session || isAnonSession(req.session)) && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
`;
|
||||
// If the anchor item doesn't pass the rating filter, it's inaccessible to this user.
|
||||
// Return empty so the frontend shows "This post is currently unavailable".
|
||||
@@ -300,7 +287,7 @@ export default (router, tpl) => {
|
||||
${excludeSQL}
|
||||
${mimeSQL}
|
||||
${tagSQL}
|
||||
${(!req.session || isAnonSession(req.session)) && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||
ORDER BY random()
|
||||
LIMIT ${limit - 1}
|
||||
@@ -325,7 +312,7 @@ export default (router, tpl) => {
|
||||
${excludeSQL}
|
||||
${mimeSQL}
|
||||
${tagSQL}
|
||||
${(!req.session || isAnonSession(req.session)) && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
|
||||
${excludedTags.length > 0 ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND tag_id = ANY(${excludedTags}::int[]))` : db``}
|
||||
${orderSQL}
|
||||
LIMIT ${limit}
|
||||
@@ -390,21 +377,9 @@ export default (router, tpl) => {
|
||||
const lastItem = items[items.length - 1];
|
||||
const nextCursor = lastItem ? lastItem.id : null;
|
||||
|
||||
const isAnonymized = isAnonymizeSession(req.session);
|
||||
const outItems = isAnonymized
|
||||
? items.map(item => ({
|
||||
...item,
|
||||
username: 'anonymous',
|
||||
display_name: 'anonymous',
|
||||
avatar: '/a/default.png',
|
||||
username_color: null,
|
||||
src_host: ''
|
||||
}))
|
||||
: items;
|
||||
|
||||
return res.reply({
|
||||
headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store, no-cache' },
|
||||
body: JSON.stringify({ success: true, items: outItems, nextCursor })
|
||||
body: JSON.stringify({ success: true, items, nextCursor })
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[SCROLLER] Feed error:', e);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import db from "../sql.mjs";
|
||||
import lib from "../lib.mjs";
|
||||
import search from "../routeinc/search.mjs";
|
||||
import f0cklib from "../routeinc/f0cklib.mjs";
|
||||
|
||||
const _eps = 20;
|
||||
|
||||
@@ -15,9 +14,6 @@ export default (router, tpl) => {
|
||||
let pagination, link;
|
||||
|
||||
if (tag.length > 0) {
|
||||
if (req.session?.id && typeof tag === 'string' && !tag.startsWith('src:') && !tag.startsWith('title:')) {
|
||||
f0cklib.updateUserTagAffinity({ user_id: req.session.id, tag, scoreDelta: 2.0 }).catch(() => {});
|
||||
}
|
||||
if (tag.startsWith('src:')) {
|
||||
total = (await db`
|
||||
select count(*) as total
|
||||
|
||||
@@ -37,7 +37,7 @@ export default (router, tpl) => {
|
||||
|
||||
// Get full user info
|
||||
const user = (await db`
|
||||
select email, created_at, (password = '!') as password_disabled from "user" where id = ${+req.session.id}
|
||||
select email, created_at from "user" where id = ${+req.session.id}
|
||||
`)[0];
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
@@ -58,13 +58,11 @@ export default (router, tpl) => {
|
||||
banner_size: userOptions?.banner_size || 'cover',
|
||||
email: user?.email || '',
|
||||
joined: user?.created_at || null,
|
||||
// Passkey-only account: password login switched off (password = '!')
|
||||
password_disabled: !!user?.password_disabled,
|
||||
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
|
||||
enable_swf: cfg.enable_swf,
|
||||
enable_data_export: !req.session?.is_anon && cfg.websrv.enable_data_export,
|
||||
enable_user_api_keys: !req.session?.is_anon && cfg.websrv.enable_user_api_keys !== false,
|
||||
enable_user_invites: !req.session?.is_anon && cfg.websrv.enable_user_invites !== false,
|
||||
enable_data_export: cfg.websrv.enable_data_export,
|
||||
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
|
||||
enable_user_invites: cfg.websrv.enable_user_invites !== false,
|
||||
site_domain: cfg.main.url.domain,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
@@ -76,10 +74,6 @@ export default (router, tpl) => {
|
||||
});
|
||||
});
|
||||
group.get('/export-data', auth, async (req, res) => {
|
||||
if (req.session?.is_anon) {
|
||||
res.status(403).reply({ body: 'Export requires a registered account' });
|
||||
return;
|
||||
}
|
||||
if (!cfg.websrv.enable_data_export) {
|
||||
res.status(403).reply({ body: 'Export disabled' });
|
||||
return;
|
||||
|
||||
+6
-105
@@ -1,9 +1,6 @@
|
||||
import cfg from "../config.mjs";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import db from "../sql.mjs";
|
||||
import queue from "../queue.mjs";
|
||||
import { isHiddenItem } from "../hidden_items.mjs";
|
||||
|
||||
export default (router, tpl) => {
|
||||
router.static({
|
||||
@@ -41,110 +38,14 @@ export default (router, tpl) => {
|
||||
route: /^\/s\//
|
||||
});
|
||||
|
||||
const getImgMime = (filename) => {
|
||||
if (filename.endsWith('.webp')) return 'image/webp';
|
||||
if (filename.endsWith('.png')) return 'image/png';
|
||||
if (filename.endsWith('.gif')) return 'image/gif';
|
||||
if (filename.endsWith('.jpg') || filename.endsWith('.jpeg')) return 'image/jpeg';
|
||||
return 'application/octet-stream';
|
||||
};
|
||||
|
||||
// <id>.webp / <id>_blur.webp belonging to an item that isn't live (pending, deleted) is never public,
|
||||
// even if a stale file with that name exists. Uploaders use /pending/t/, staff /mod/pending/t/.
|
||||
const isHiddenMedia = async (file) => {
|
||||
const m = /^(\d+)(?:_blur)?\.webp$/.exec(file);
|
||||
return !!m && await isHiddenItem(m[1]);
|
||||
};
|
||||
|
||||
const notFound = (res) => {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
return res.end('404 - file not found.');
|
||||
};
|
||||
|
||||
router.get(/^\/t\/(?<file>.+)$/, async (req, res) => {
|
||||
const file = req.params.file;
|
||||
if (await isHiddenMedia(file)) return notFound(res);
|
||||
const filePath = path.join(cfg.paths.t, file);
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.isFile()) {
|
||||
const content = await fs.readFile(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Length': stat.size,
|
||||
'Content-Type': getImgMime(file),
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
});
|
||||
return res.end(content);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Dynamic generation or fallback for subf0cks / albums
|
||||
const base = file.replace(/\.[^.]+$/, '').replace(/_blur$/, '');
|
||||
try {
|
||||
const subRow = await db`SELECT id, item_id, dest, mime FROM album_items WHERE dest LIKE ${base + '.%'} LIMIT 1`;
|
||||
if (subRow.length > 0) {
|
||||
const sub = subRow[0];
|
||||
const srcFile = path.join(cfg.paths.b, sub.dest);
|
||||
const thumbDest = path.join(cfg.paths.t, `${base}.webp`);
|
||||
const srcStat = await fs.stat(srcFile).catch(() => null);
|
||||
if (srcStat && srcStat.size > 0) {
|
||||
if (sub.mime.startsWith('image/')) {
|
||||
await queue.spawn('magick', [srcFile + '[0]', '-resize', '256x256^', '-gravity', 'center', '-crop', '256x256+0+0', '+repage', thumbDest]);
|
||||
} else if (sub.mime.startsWith('video/')) {
|
||||
await queue.spawn('ffmpegthumbnailer', ['-i', srcFile, '-s', '256', '-o', thumbDest]).catch(async () => {
|
||||
await queue.spawn('ffmpeg', ['-y', '-ss', '00:00:01', '-i', srcFile, '-vframes', '1', '-vf', 'scale=256:256:force_original_aspect_ratio=increase,crop=256:256', thumbDest]);
|
||||
});
|
||||
}
|
||||
const genStat = await fs.stat(thumbDest).catch(() => null);
|
||||
if (genStat && genStat.size > 0) {
|
||||
const content = await fs.readFile(thumbDest);
|
||||
res.writeHead(200, {
|
||||
'Content-Length': genStat.size,
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
});
|
||||
return res.end(content);
|
||||
}
|
||||
}
|
||||
// Fallback to parent album thumbnail if subf0ck media file is missing
|
||||
const parentThumb = path.join(cfg.paths.t, `${sub.item_id}.webp`);
|
||||
const pStat = await fs.stat(parentThumb).catch(() => null);
|
||||
if (pStat && pStat.size > 0) {
|
||||
const content = await fs.readFile(parentThumb);
|
||||
res.writeHead(200, {
|
||||
'Content-Length': pStat.size,
|
||||
'Content-Type': 'image/webp',
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
});
|
||||
return res.end(content);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[STATIC /t/] Dynamic subf0ck thumbnail generation error:', e.message);
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
return res.end('404 - file not found.');
|
||||
router.static({
|
||||
dir: cfg.paths.t,
|
||||
route: /^\/t\//
|
||||
});
|
||||
|
||||
router.get(/^\/ca\/(?<file>.+)$/, async (req, res) => {
|
||||
const file = req.params.file;
|
||||
if (await isHiddenMedia(file)) return notFound(res);
|
||||
const filePath = path.join(cfg.paths.ca, file);
|
||||
try {
|
||||
const stat = await fs.stat(filePath);
|
||||
if (stat.isFile()) {
|
||||
const content = await fs.readFile(filePath);
|
||||
res.writeHead(200, {
|
||||
'Content-Length': stat.size,
|
||||
'Content-Type': getImgMime(file),
|
||||
'Cache-Control': 'public, max-age=3600'
|
||||
});
|
||||
return res.end(content);
|
||||
}
|
||||
} catch (_) {}
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
||||
return res.end('404 - file not found.');
|
||||
router.static({
|
||||
dir: cfg.paths.ca,
|
||||
route: /^\/ca\//
|
||||
});
|
||||
|
||||
router.static({
|
||||
|
||||
@@ -40,22 +40,11 @@ export async function regenerateTagImage(tag, mode) {
|
||||
`;
|
||||
|
||||
if (items.length > 0) {
|
||||
const inputs = [];
|
||||
for (const item of items) {
|
||||
const filePath = path.join(cfg.paths.t, `${item.id}.webp`);
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
inputs.push(`${filePath}[0]`);
|
||||
} catch {
|
||||
// Ignore missing thumbnail
|
||||
}
|
||||
}
|
||||
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
|
||||
|
||||
if (inputs.length > 0) {
|
||||
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
|
||||
return cachePath;
|
||||
}
|
||||
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
|
||||
return cachePath;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[TAG_IMAGE] Failed to generate image for tag "${tag}" (mode ${mode}):`, err);
|
||||
|
||||
+12
-24
@@ -1,7 +1,6 @@
|
||||
import db from "../../inc/sql.mjs";
|
||||
import lib from "../../inc/lib.mjs";
|
||||
import cfg from "../../inc/config.mjs";
|
||||
import { isAnonSession, getAnonAllowedModes } from "../settings.mjs";
|
||||
import url from "url";
|
||||
|
||||
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
|
||||
@@ -10,7 +9,6 @@ export default (router, tpl) => {
|
||||
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
|
||||
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
|
||||
const isGuest = !sessionObj;
|
||||
const isPublicVisitor = !sessionObj || isAnonSession(sessionObj);
|
||||
let baseMode = lib.getMode(mode);
|
||||
if (isGuest) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
@@ -31,26 +29,15 @@ export default (router, tpl) => {
|
||||
const modequery = baseMode;
|
||||
|
||||
let restrictedFilter = db``;
|
||||
if (isPublicVisitor && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
let effectiveNsfp = [...cfg.nsfp];
|
||||
if (sessionObj && isAnonSession(sessionObj)) {
|
||||
const allowedModes = getAnonAllowedModes();
|
||||
if (allowedModes.includes('nsfw')) effectiveNsfp = effectiveNsfp.filter(id => id !== 2);
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
if (allowedModes.includes('nsfl')) effectiveNsfp = effectiveNsfp.filter(id => id !== nsflId);
|
||||
} else if (cfg.websrv.public_nsfw) {
|
||||
effectiveNsfp = effectiveNsfp.filter(id => id !== 2);
|
||||
}
|
||||
if (effectiveNsfp.length > 0) {
|
||||
restrictedFilter = db`
|
||||
AND t.id NOT IN ${db(effectiveNsfp)}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tags_assign ta_res
|
||||
WHERE ta_res.item_id = items.id
|
||||
AND ta_res.tag_id IN ${db(effectiveNsfp)}
|
||||
)
|
||||
`;
|
||||
}
|
||||
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
|
||||
restrictedFilter = db`
|
||||
AND t.id NOT IN ${db(cfg.nsfp)}
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tags_assign ta_res
|
||||
WHERE ta_res.item_id = items.id
|
||||
AND ta_res.tag_id IN ${db(cfg.nsfp)}
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
const userExcludeFilter = excludedTags.length > 0
|
||||
@@ -129,7 +116,7 @@ export default (router, tpl) => {
|
||||
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
|
||||
return res.json({
|
||||
success: true,
|
||||
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||
html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
|
||||
currentPage: page,
|
||||
hasMore: tags.length === TAGS_PER_PAGE
|
||||
});
|
||||
@@ -144,7 +131,7 @@ export default (router, tpl) => {
|
||||
});
|
||||
|
||||
// Main tags page
|
||||
router.get(/^\/tags?\/?$/, async (req, res) => {
|
||||
router.get(/^\/tags$/, async (req, res) => {
|
||||
const phrase = cfg.websrv.phrases[~~(Math.random() * cfg.websrv.phrases.length)];
|
||||
const mode = req.mode ?? 0;
|
||||
const query = req.url.qs || {};
|
||||
@@ -162,6 +149,7 @@ export default (router, tpl) => {
|
||||
phrase,
|
||||
tmp: null,
|
||||
hidePagination: true,
|
||||
user: req.session?.user ? { user: req.session.user } : null,
|
||||
session: (req.session && req.session.user) ? { ...req.session } : false,
|
||||
page_meta: {
|
||||
title: 'Tags',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user