Compare commits
1
Commits
alter
..
difflayouts
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb8861aed |
+3
-13
@@ -1,13 +1,3 @@
|
||||
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
|
||||
#
|
||||
# VIRTUAL_HOST=yourdomain.com
|
||||
# LETSENCRYPT_HOST=yourdomain.com
|
||||
# LETSENCRYPT_EMAIL=your-email@example.com
|
||||
POSTGRES_USER=
|
||||
POSTGRES_DB=
|
||||
POSTGRES_PASSWORD=
|
||||
|
||||
@@ -17,5 +17,3 @@ public/tag_cache
|
||||
.env
|
||||
config.json
|
||||
bundle.css
|
||||
public/s/fonts/impact.woff
|
||||
f0ck-svelte/*
|
||||
+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 . .
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
# f0ckm
|
||||
|
||||
Happy to finally bring you f0ckm! The long awaited imageboard solution that you can host yourself. It is designed to be a real alternative to big tech platforms that you can modify to suit your communities needs.
|
||||
|
||||
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 comes without any warranties or entitlements of any kind! The developer is not responsible for anything you do with the use of this software.
|
||||
|
||||
## Software Requirements
|
||||
|
||||
- Docker (https://docs.docker.com/engine/install/debian/)
|
||||
|
||||
## prod
|
||||
|
||||
first things
|
||||
|
||||
`cp .env.example .env`
|
||||
|
||||
fill with for example: f0ckm (prefilled)
|
||||
fill with for example: f0ckm
|
||||
|
||||
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:
|
||||
|
||||
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname, if you choose localhost you wont need to add the ENV `DB_HOST=localhost` and if you also change the port to be `DB_PORT=5454` you wont need this ENV either, but then Docker wont find the DB.
|
||||
host can either be localhost or the docker containers hostname, when running via docker this must be the dockers hostname
|
||||
|
||||
```
|
||||
"sql": {
|
||||
@@ -59,8 +42,6 @@ now vist http://localhost:1337 in your browser
|
||||
|
||||
## dev
|
||||
|
||||
NOTE: when developing locally it might be necessary to run commands with `DB_HOST=localhost DB_PORT=5454`
|
||||
|
||||
`docker compose up -d f0ckm-db`
|
||||
|
||||
on dev machine:
|
||||
@@ -68,67 +49,8 @@ on dev machine:
|
||||
`npm i`
|
||||
`npm run dev`
|
||||
|
||||
Fill Database
|
||||
|
||||
`docker exec -i f0ckm-db psql -U f0ckm -d f0ckm < migrations/f0ckm_schema.sql`
|
||||
|
||||
`DB_HOST=localhost DB_PORT=5454 node scripts/seed.mjs`
|
||||
|
||||
Create admin user in dev env
|
||||
|
||||
`DB_HOST=localhost DB_PORT=5454 node scripts/create-admin.mjs admin 'YOUR_PASSWORD_HERE'`
|
||||
|
||||
now visit http://localhost:1337 in your browser, you can develop without needing to rebuild the docker image for every change
|
||||
|
||||
## Configuration Management
|
||||
|
||||
You can manage configuration in YAML (`config.yaml`) or JSON (`config.json`):
|
||||
- `npm run config:gen`: Compiles `config.yaml` to `config.json`. Automatically runs before `npm run dev` and `npm start`.
|
||||
- `npm run config:watch`: Watches `config.yaml` for edits and recompiles `config.json` automatically on save.
|
||||
- `npm run config:to-yaml`: Converts existing `config.json` into `config.yaml`.
|
||||
|
||||
## NGINX
|
||||
|
||||
uncomment in .env # COMPOSE_PROFILES=f0ckm-nginx to enable nginx proxy with automatic lets encrypt.
|
||||
|
||||
## Tor Hidden Service (.onion)
|
||||
|
||||
Tor is bundled in `docker-compose.yml` and preconfigured as long as COMPOSE_PROFILES=f0ckm-tor is set with a hidden service pointing to the application (`f0ckm:1337`).
|
||||
|
||||
When running `docker compose up -d`, Tor automatically generates a `.onion` address for your node. You can find your onion address by running:
|
||||
|
||||
```bash
|
||||
cat ./f0ckm-data/tor/f0ckm_hs/hostname
|
||||
```
|
||||
|
||||
### Automatic Onion-Location Header
|
||||
|
||||
To advertise your `.onion` address to Tor Browser users visiting your clearnet site, add your `.onion` address to `config.json`:
|
||||
|
||||
```json
|
||||
"main": {
|
||||
"onion": "http://yourgeneratedaddress.onion"
|
||||
}
|
||||
```
|
||||
|
||||
## Chat uploads (temporary file hosting for mumh5)
|
||||
|
||||
Upload-only API keys for external chat clients. Files are public, never appear on the imageboard, and expire (default 30 days, see `chat_upload_*` in `config.yaml`).
|
||||
|
||||
Manage keys (the key is shown once, only its hash is stored):
|
||||
|
||||
`node scripts/chat-upload-key.mjs create <name> [max_mb]`
|
||||
`node scripts/chat-upload-key.mjs list`
|
||||
`node scripts/chat-upload-key.mjs revoke <id>`
|
||||
|
||||
Upload (raw body, returns JSON with `url` and `delete_token`):
|
||||
|
||||
```bash
|
||||
curl -X POST https://your.host/api/chat/upload \
|
||||
-H "X-API-Key: cu_..." -H "X-Filename: clip.webm" -H "X-Upload-Expiry: 7d" \
|
||||
--data-binary @clip.webm
|
||||
```
|
||||
|
||||
Delete: `curl -X DELETE https://your.host/api/chat/upload/<slug> -H "X-Delete-Token: ..."`
|
||||
|
||||
Files are served at `/cu/<slug>/<name>` with Range support. Only sniffed images, video and audio are served inline; anything else is forced to download.
|
||||
|
||||
+4
-7
@@ -16,7 +16,7 @@ fi
|
||||
# Parse arguments
|
||||
TARGET="master"
|
||||
CACHE_FLAG=""
|
||||
CUSTOM_BRANCH=""
|
||||
BRANCH_TARGET=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
@@ -27,8 +27,8 @@ for arg in "$@"; do
|
||||
CACHE_FLAG="--no-cache"
|
||||
;;
|
||||
*)
|
||||
if [ -z "$CUSTOM_BRANCH" ]; then
|
||||
CUSTOM_BRANCH=$arg
|
||||
if [ -z "$BRANCH_TARGET" ]; then
|
||||
BRANCH_TARGET=$arg
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
@@ -43,10 +43,7 @@ fi
|
||||
# Set Image name and branch defaults
|
||||
IMAGE_NAME="f0ckm"
|
||||
|
||||
if [ -n "$CUSTOM_BRANCH" ]; then
|
||||
BRANCH_TARGET="$CUSTOM_BRANCH"
|
||||
echo "--- CUSTOM BRANCH DEPLOYMENT (${BRANCH_TARGET}) ---"
|
||||
elif [ "$TARGET" == "master" ]; then
|
||||
if [ "$TARGET" == "master" ]; then
|
||||
echo "--- PRODUCTION DEPLOYMENT (master) ---"
|
||||
BRANCH_TARGET="master"
|
||||
elif [ "$TARGET" == "stg" ]; then
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
client_max_body_size 10000M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Tor configuration for f0ckm
|
||||
|
||||
# Enable SOCKS proxy for internal container & external connections
|
||||
SocksPort 0.0.0.0:9050
|
||||
|
||||
# Tor Hidden Service (v3 Onion Service) configuration
|
||||
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
|
||||
HiddenServiceDir /var/lib/tor/f0ckm_hs/
|
||||
HiddenServicePort 80 f0ckm:1337
|
||||
+2
-135
@@ -5,7 +5,6 @@
|
||||
"domain": "example.com",
|
||||
"regex": "example\\.com"
|
||||
},
|
||||
"onion": "http://your-onion-address.onion",
|
||||
"socks": "socks5://127.0.0.1:9050",
|
||||
"mail": "admin@example.com",
|
||||
"maxfilesize": 104857600,
|
||||
@@ -16,11 +15,7 @@
|
||||
"example.org"
|
||||
],
|
||||
"invite_secret": "YOUR_SECRET_HERE",
|
||||
"ip_hash_secret": "",
|
||||
"ip_hash_legacy_secrets": [],
|
||||
"hide_comments_from_public": false,
|
||||
"guest_anonymize": false,
|
||||
"anon_anonymize": false,
|
||||
"timezone": "UTC",
|
||||
"development": true
|
||||
},
|
||||
@@ -33,62 +28,11 @@
|
||||
],
|
||||
"enable_pdf": false,
|
||||
"enable_nsfl": false,
|
||||
"enable_comments": true,
|
||||
"enable_private_uploads": true,
|
||||
"enable_expiring_uploads": true,
|
||||
"default_upload_visibility": 0,
|
||||
"allow_user_upload_visibility": true,
|
||||
"enable_item_slugs": true,
|
||||
"enable_anonymous_access": true,
|
||||
"anonymous_permissions": {
|
||||
"upload": false,
|
||||
"comment": true,
|
||||
"comment_attachments": false,
|
||||
"comment_vote": true,
|
||||
"poll_vote": true,
|
||||
"tag": true,
|
||||
"tag_vote": true,
|
||||
"favorite": true,
|
||||
"rate_item": false,
|
||||
"filter": true,
|
||||
"exclude_tags": true,
|
||||
"chan": false,
|
||||
"anonymize_users": false,
|
||||
"allowed_modes": [
|
||||
"sfw",
|
||||
"nsfw",
|
||||
"untagged",
|
||||
"all",
|
||||
"nsfl"
|
||||
],
|
||||
"allowed_mimes": [
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"flash",
|
||||
"pdf"
|
||||
]
|
||||
},
|
||||
"onara": false,
|
||||
"nsfl_tag_id": 4,
|
||||
"allowedMimes": [
|
||||
"audio",
|
||||
"image",
|
||||
"video",
|
||||
"application/x-shockwave-flash",
|
||||
"application/vnd.adobe.flash.movie",
|
||||
"application/zip",
|
||||
"application/x-zip-compressed",
|
||||
"application/x-rar-compressed",
|
||||
"application/vnd.rar",
|
||||
"application/x-rar",
|
||||
"application/rar",
|
||||
"application/x-rar-archive",
|
||||
"application/x-7z-compressed",
|
||||
"application/x-tar",
|
||||
"application/gzip",
|
||||
"application/x-bzip2",
|
||||
"application/x-xz"
|
||||
"video"
|
||||
],
|
||||
"nsfp": [
|
||||
2,
|
||||
@@ -101,69 +45,35 @@
|
||||
"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"
|
||||
],
|
||||
"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,
|
||||
"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,
|
||||
"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": [
|
||||
@@ -173,18 +83,6 @@
|
||||
],
|
||||
"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": [
|
||||
@@ -196,25 +94,12 @@
|
||||
"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",
|
||||
@@ -290,22 +175,9 @@
|
||||
"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"
|
||||
"application/pdf": "pdf"
|
||||
},
|
||||
"apis": {},
|
||||
"smtp": {
|
||||
@@ -317,10 +189,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+1
-75
@@ -11,15 +11,12 @@ services:
|
||||
networks:
|
||||
- f0ckm-net
|
||||
volumes:
|
||||
- ./config.json:/opt/f0ckm/config.json:ro,Z
|
||||
- ./config.json:/opt/f0ckm/config.json:Z
|
||||
- ./src/:/opt/f0ckm/src/:Z
|
||||
- ./views/:/opt/f0ckm/views/:Z
|
||||
- ./scripts/:/opt/f0ckm/scripts/:Z
|
||||
- ./f0ckm-data/a/:/opt/f0ckm/public/a/:Z
|
||||
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
|
||||
- ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z
|
||||
- ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z
|
||||
- ./f0ckm-data/cu/:/opt/f0ckm/public/cu/:Z
|
||||
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
|
||||
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
|
||||
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
|
||||
@@ -32,25 +29,10 @@ services:
|
||||
- ./f0ckm-data/fonts/:/opt/f0ckm/public/s/fonts/:Z
|
||||
- ./f0ckm-data/hall_cache/:/opt/f0ckm/public/hall_cache/:Z
|
||||
- ./f0ckm-data/hall_custom/:/opt/f0ckm/public/hall_custom/:Z
|
||||
- ./f0ckm-data/koepfe/:/opt/f0ckm/public/s/koepfe/:Z
|
||||
- ./f0ckm-data/import/:/opt/f0ckm/f0ckm-data/import/:Z
|
||||
- ./f0ckm-data/manifest.json:/opt/f0ckm/public/manifest.json:Z
|
||||
- ./f0ckm-data/s/img/navbar/:/opt/f0ckm/public/s/img/navbar/:Z
|
||||
|
||||
environment:
|
||||
- GIT_HASH=${f0ckm_TAG:-unknown}
|
||||
- VIRTUAL_HOST=${VIRTUAL_HOST:-localhost}
|
||||
- VIRTUAL_PORT=1337
|
||||
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
|
||||
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
|
||||
- DB_HOST=f0ckm-db
|
||||
- DB_PORT=5432
|
||||
- DB_USER=${POSTGRES_USER:-f0ckm}
|
||||
- DB_PASS=${POSTGRES_PASSWORD:-f0ckm}
|
||||
- DB_NAME=${POSTGRES_DB:-f0ckm}
|
||||
- NODE_ENV=production
|
||||
# Dedicated secret for IP hashing (HMAC). Set in .env, keep it out of config.json and DB backups.
|
||||
- IP_HASH_SECRET=${IP_HASH_SECRET:-}
|
||||
ports:
|
||||
- "1337:1337"
|
||||
restart: unless-stopped
|
||||
@@ -58,21 +40,6 @@ services:
|
||||
f0ckm-db:
|
||||
condition: service_healthy
|
||||
|
||||
tor:
|
||||
image: dockurr/tor
|
||||
container_name: tor
|
||||
profiles:
|
||||
- tor
|
||||
ports:
|
||||
- "127.0.0.1:9050:9050"
|
||||
- "127.0.0.1:8118:8118"
|
||||
volumes:
|
||||
- ./config/tor:/etc/tor:ro
|
||||
- ./f0ckm-data/tor:/var/lib/tor:Z
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: always
|
||||
|
||||
f0ckm-db:
|
||||
container_name: f0ckm-db
|
||||
image: postgres:17
|
||||
@@ -111,47 +78,6 @@ services:
|
||||
# - f0ckm-net
|
||||
# restart: unless-stopped
|
||||
|
||||
f0ckm-nginx:
|
||||
image: nginxproxy/nginx-proxy:latest
|
||||
container_name: f0ckm-nginx
|
||||
network_mode: "host"
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
environment:
|
||||
- ENABLE_IPV6=true
|
||||
volumes:
|
||||
- /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- vhost:/etc/nginx/vhost.d:rw
|
||||
- html:/usr/share/nginx/html:rw
|
||||
- ./config/nginx/f0ck.conf:/etc/nginx/conf.d/f0ckm.conf:ro
|
||||
restart: unless-stopped
|
||||
|
||||
acme-companion:
|
||||
image: nginxproxy/acme-companion:latest
|
||||
container_name: f0ckm-acme-companion
|
||||
profiles:
|
||||
- f0ckm-nginx
|
||||
environment:
|
||||
- NGINX_PROXY_CONTAINER=f0ckm-nginx
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- certs:/etc/nginx/certs:rw
|
||||
- vhost:/etc/nginx/vhost.d:rw
|
||||
- html:/usr/share/nginx/html:rw
|
||||
- acme:/etc/acme.sh:rw
|
||||
depends_on:
|
||||
- f0ckm-nginx
|
||||
networks:
|
||||
- f0ckm-net
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
f0ckm-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
certs:
|
||||
vhost:
|
||||
html:
|
||||
acme:
|
||||
|
||||
@@ -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,10 +0,0 @@
|
||||
-- User emoji favorites — persisted per-user, cross-device
|
||||
CREATE TABLE IF NOT EXISTS user_emoji_favorites (
|
||||
user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
emoji_name text NOT NULL,
|
||||
emoji_url text NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (user_id, emoji_name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON user_emoji_favorites (user_id);
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Migration: Add expires_at column to items table for expiring uploads
|
||||
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- Add favorites_private column to user_options table
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';
|
||||
@@ -1,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,5 +0,0 @@
|
||||
-- Add hide_fav_badge column to user_options table
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
|
||||
|
||||
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';
|
||||
@@ -1,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,38 +0,0 @@
|
||||
-- Add visibility and slug columns to items table
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS visibility smallint DEFAULT 0;
|
||||
ALTER TABLE items ADD COLUMN IF NOT EXISTS slug varchar(16) UNIQUE;
|
||||
ALTER TABLE user_options ADD COLUMN IF NOT EXISTS default_upload_visibility smallint DEFAULT 0;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_slug ON items(slug);
|
||||
|
||||
COMMENT ON COLUMN items.visibility IS '0=public, 1=unlisted, 2=private';
|
||||
COMMENT ON COLUMN items.slug IS 'Unique unguessable 11-character URL slug for direct access';
|
||||
COMMENT ON COLUMN user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
|
||||
|
||||
-- Populate missing slugs for existing items
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
chars text := 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||
new_slug text;
|
||||
i integer;
|
||||
BEGIN
|
||||
FOR r IN SELECT id FROM items WHERE slug IS NULL LOOP
|
||||
LOOP
|
||||
new_slug := '';
|
||||
FOR i IN 1..11 LOOP
|
||||
new_slug := new_slug || substr(chars, floor(random() * 64 + 1)::integer, 1);
|
||||
END LOOP;
|
||||
BEGIN
|
||||
UPDATE items SET slug = new_slug WHERE id = r.id;
|
||||
EXIT;
|
||||
EXCEPTION WHEN unique_violation THEN
|
||||
END;
|
||||
END LOOP;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Fix any items with NULL visibility (should default to 0 = public)
|
||||
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||
ALTER TABLE items ALTER COLUMN visibility SET NOT NULL;
|
||||
ALTER TABLE items ALTER COLUMN visibility SET DEFAULT 0;
|
||||
@@ -1 +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,53 +0,0 @@
|
||||
-- Migration: Add sticker_packs table and link custom_emojis to packs
|
||||
-- Run this against the f0ckm database (idempotent — safe to run multiple times)
|
||||
|
||||
-- Create sticker_packs table
|
||||
CREATE TABLE IF NOT EXISTS public.sticker_packs (
|
||||
id serial PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
tg_name text UNIQUE,
|
||||
tg_title text,
|
||||
thumb_url text,
|
||||
sticker_count integer DEFAULT 0,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE public.sticker_packs OWNER TO f0ckm;
|
||||
|
||||
-- Add pack_id column to custom_emojis (nullable: NULL = standalone emoji, no pack)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'custom_emojis'
|
||||
AND column_name = 'pack_id'
|
||||
) THEN
|
||||
ALTER TABLE public.custom_emojis
|
||||
ADD COLUMN pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
|
||||
RAISE NOTICE 'pack_id column added to custom_emojis';
|
||||
ELSE
|
||||
RAISE NOTICE 'pack_id column already exists on custom_emojis — skipping';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Add thumb_url column to sticker_packs if missing
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'sticker_packs'
|
||||
AND column_name = 'thumb_url'
|
||||
) THEN
|
||||
ALTER TABLE public.sticker_packs ADD COLUMN thumb_url text;
|
||||
RAISE NOTICE 'thumb_url column added to sticker_packs';
|
||||
ELSE
|
||||
RAISE NOTICE 'thumb_url already exists on sticker_packs — skipping';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- Index for fast pack lookups
|
||||
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- Add banner_file column to user_options for custom profile banners
|
||||
-- Banners are displayed in the alternative infobox (user-infobox-block)
|
||||
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
|
||||
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
|
||||
|
||||
ALTER TABLE user_options
|
||||
ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
|
||||
|
||||
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/ (same dir as avatars)';
|
||||
COMMENT ON COLUMN public.user_options.banner_position IS 'CSS background-position for the banner';
|
||||
COMMENT ON COLUMN public.user_options.banner_size IS 'CSS background-size for the banner (e.g. cover, contain, or a percentage)';
|
||||
@@ -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);
|
||||
+483
-1464
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
-- Fix items with NULL visibility (should default to 0 = public)
|
||||
UPDATE items SET visibility = 0 WHERE visibility IS NULL;
|
||||
Generated
+7
-28
@@ -17,8 +17,7 @@
|
||||
"jszip": "3.10.1",
|
||||
"marked": "18.0.2",
|
||||
"matrix-js-sdk": "^40.3.0-rc.0",
|
||||
"postgres": "^3.3.4",
|
||||
"yaml": "^2.9.1"
|
||||
"postgres": "^3.3.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
@@ -76,9 +75,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -449,21 +448,6 @@
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/eemeli"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -508,9 +492,9 @@
|
||||
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
|
||||
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
@@ -773,11 +757,6 @@
|
||||
"version": "13.0.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz",
|
||||
"integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw=="
|
||||
},
|
||||
"yaml": {
|
||||
"version": "2.9.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
|
||||
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-11
@@ -5,14 +5,8 @@
|
||||
"main": "index.mjs",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prestart": "node scripts/generate-config.mjs",
|
||||
"start": "node --trace-uncaught src/index.mjs",
|
||||
"predev": "node scripts/generate-config.mjs",
|
||||
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch --watch-path src src/index.mjs",
|
||||
"devv2": "STORAGE_DIR=/home/kibi/Projects/f0ckmv2/f0ckm/f0ckm-data DB_HOST=localhost DB_PORT=5455 node --trace-uncaught --watch --watch-path src src/index.mjs",
|
||||
"config:gen": "node scripts/generate-config.mjs",
|
||||
"config:watch": "node scripts/generate-config.mjs --watch",
|
||||
"config:to-yaml": "node scripts/generate-config.mjs --to-yaml",
|
||||
"dev": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node --trace-uncaught --watch src/index.mjs",
|
||||
"trigger": "node debug/trigger.mjs",
|
||||
"autotagger": "node debug/autotagger.mjs",
|
||||
"thumbnailer": "node debug/thumbnailer.mjs",
|
||||
@@ -22,8 +16,7 @@
|
||||
"build": "node scripts/build-css.mjs",
|
||||
"copy-vendor": "node scripts/copy-vendor.mjs",
|
||||
"seed": "node scripts/seed.mjs",
|
||||
"create-admin": "node scripts/create-admin.mjs",
|
||||
"import": "STORAGE_DIR=f0ckm-data DB_HOST=localhost DB_PORT=5454 node scripts/import.mjs"
|
||||
"create-admin": "node scripts/create-admin.mjs"
|
||||
},
|
||||
"author": "Kibi Kelburton",
|
||||
"license": "MIT",
|
||||
@@ -36,7 +29,6 @@
|
||||
"jszip": "3.10.1",
|
||||
"marked": "18.0.2",
|
||||
"matrix-js-sdk": "^40.3.0-rc.0",
|
||||
"postgres": "^3.3.4",
|
||||
"yaml": "^2.9.1"
|
||||
"postgres": "^3.3.4"
|
||||
}
|
||||
}
|
||||
|
||||
+1200
-10798
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,13 @@
|
||||
@font-face {
|
||||
font-family: 'Impact';
|
||||
src: url('/s/fonts/impact.woff') format('woff');
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Impact';
|
||||
src: url('/s/fonts/impact.woff') format('woff');
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Meme Creator Styles */
|
||||
|
||||
.meme-creator-container {
|
||||
box-sizing: border-box !important;
|
||||
padding: 20px;
|
||||
max-width: 1100px;
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
@@ -112,7 +104,6 @@
|
||||
|
||||
.canvas-wrapper {
|
||||
flex: 1;
|
||||
max-width: 650px;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
background: #000;
|
||||
border: 2px solid var(--nav-bg, #2b2b2b);
|
||||
@@ -194,8 +185,6 @@ canvas#memeCanvas {
|
||||
.meme-layout-wrapper input[type="range"] {
|
||||
width: 100%;
|
||||
accent-color: var(--accent, #9f0);
|
||||
touch-action: pan-x;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.meme-layout-wrapper .layer-input-group {
|
||||
@@ -240,49 +229,22 @@ canvas#memeCanvas {
|
||||
color: var(--white, #fff);
|
||||
}
|
||||
|
||||
/* Mobile Stacking - Clean Flexbox Column Layout */
|
||||
/* Mobile Stacking - Simple 2-Row Layout */
|
||||
@media (max-width: 950px) {
|
||||
.meme-editor-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: 1fr !important;
|
||||
grid-template-rows: 0.6fr 1fr !important;
|
||||
gap: 20px;
|
||||
}
|
||||
.meme-controls {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 500px !important;
|
||||
grid-row: 2;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.canvas-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 650px !important;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack early when sidebar is open (sidebar is 300px, so 950 + 300 = 1250px)
|
||||
This prevents the canvas from shrinking too much when the sidebar takes space. */
|
||||
@media (max-width: 1250px) {
|
||||
body:not(.sidebar-right-hidden) .meme-editor-layout {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
align-items: center !important;
|
||||
gap: 20px;
|
||||
}
|
||||
body:not(.sidebar-right-hidden) .meme-controls {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 500px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
body:not(.sidebar-right-hidden) .canvas-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100% !important;
|
||||
max-width: 650px !important;
|
||||
grid-row: 1;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
height: auto !important;
|
||||
@@ -298,44 +260,3 @@ canvas#memeCanvas {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Template Card & Drag Hover Styles */
|
||||
.custom-template-card .template-image-wrapper {
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-template-card:hover .template-image-wrapper {
|
||||
background-color: rgba(255, 255, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
.custom-template-card i {
|
||||
transition: transform 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-template-card:hover i {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.canvas-wrapper.drag-hover {
|
||||
border-color: var(--accent, #9f0) !important;
|
||||
box-shadow: 0 0 25px rgba(159, 255, 0, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Sidebar space reservation for meme pages */
|
||||
.meme-layout-wrapper {
|
||||
box-sizing: border-box !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
/* Reserve space for the fixed sidebar so content doesn't flow behind it */
|
||||
body:not(.sidebar-right-hidden) .meme-layout-wrapper {
|
||||
padding-right: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collapse reserved space when sidebar is hidden */
|
||||
body.sidebar-right-hidden .meme-layout-wrapper {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
|
||||
+33
-858
File diff suppressed because it is too large
Load Diff
+30
-376
@@ -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,17 +132,19 @@
|
||||
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);
|
||||
}
|
||||
|
||||
.v0ck:hover .v0ck_player_controls,
|
||||
.v0ck.v0ck_hover .v0ck_player_controls,
|
||||
.v0ck.v0ck_swf_active .v0ck_player_controls {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.v0ck:hover .v0ck_progress,
|
||||
.v0ck.v0ck_hover .v0ck_progress,
|
||||
.v0ck.v0ck_swf_active .v0ck_progress {
|
||||
height: 8px;
|
||||
@@ -326,144 +315,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 +382,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 +401,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 +423,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;
|
||||
@@ -624,9 +434,8 @@
|
||||
padding: 0 3px;
|
||||
text-shadow: none;
|
||||
}
|
||||
/* Hide images/videos (emojis, inline imgs, webm stickers) inside unrevealed spoiler */
|
||||
.danmaku-pill .dpill-spoiler img,
|
||||
.danmaku-pill .dpill-spoiler video {
|
||||
/* Hide images (emojis, inline imgs) inside unrevealed spoiler */
|
||||
.danmaku-pill .dpill-spoiler img {
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
@@ -639,9 +448,7 @@
|
||||
-1px 1px 0 #000, 1px 1px 0 #000;
|
||||
}
|
||||
.danmaku-pill .dpill-spoiler:hover img,
|
||||
.danmaku-pill .dpill-spoiler.revealed img,
|
||||
.danmaku-pill .dpill-spoiler:hover video,
|
||||
.danmaku-pill .dpill-spoiler.revealed video {
|
||||
.danmaku-pill .dpill-spoiler.revealed img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -671,8 +478,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 +491,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,160 +508,7 @@
|
||||
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)); }
|
||||
}
|
||||
|
||||
/* Speedup 2x HUD Pill */
|
||||
.v0ck_speed_indicator {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -10px);
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.v0ck_speed_indicator:not(.v0ck_hidden) {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
/* Hide mouse cursor on inactivity when player controls auto-hide */
|
||||
.v0ck:not(.v0ck_hover),
|
||||
.v0ck: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;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<rect width="100" height="100" fill="#242526"/>
|
||||
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
|
||||
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 443 B |
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Font Awesome Free 5.15.3 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) -->
|
||||
<defs>
|
||||
<symbol id="heart_regular" viewBox="0 0 512 512"><path d="M458.4 64.3C400.6 15.7 311.3 23 256 79.3 200.7 23 111.4 15.6 53.6 64.3-21.6 127.6-10.6 230.8 43 285.5l175.4 178.7c10 10.2 23.4 15.9 37.6 15.9 14.3 0 27.6-5.6 37.6-15.8L469 285.6c53.5-54.7 64.7-157.9-10.6-221.3zm-23.6 187.5L259.4 430.5c-2.4 2.4-4.4 2.4-6.8 0L77.2 251.8c-36.5-37.2-43.9-107.6 7.3-150.7 38.9-32.7 98.9-27.8 136.5 10.5l35 35.7 35-35.7c37.8-38.5 97.8-43.2 136.5-10.6 51.1 43.1 43.5 113.9 7.3 150.8z"/></symbol>
|
||||
<symbol id="heart_solid" viewBox="0 0 512 512"><path d="M462.3 62.6C407.5 15.9 326 24.3 275.7 76.2L256 96.5l-19.7-20.3C186.1 24.3 104.5 15.9 49.7 62.6c-62.8 53.6-66.1 149.8-9.9 207.9l193.5 199.8c12.5 12.9 32.8 12.9 45.3 0l193.5-199.8c56.3-58.1 53-154.3-9.8-207.9z"/></symbol>
|
||||
<symbol id="cross" viewBox="0 0 512 512"><path d="M53.6,62.3 L458.4,458.4 M458.4,62.3 L53.6,458.4" style="stroke-linecap: round;stroke-width: 60;"/></symbol>
|
||||
<symbol id="window-maximize" viewBox="0 0 512 512"><path d="M464 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm0 394c0 3.3-2.7 6-6 6H54c-3.3 0-6-2.7-6-6V192h416v234z" style="fill: var(--maximize_button);"/></symbol>
|
||||
<symbol id="window-minimize" viewBox="0 0 512 512"><path d="M464 0H144c-26.5 0-48 21.5-48 48v48H48c-26.5 0-48 21.5-48 48v320c0 26.5 21.5 48 48 48h320c26.5 0 48-21.5 48-48v-48h48c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48zm-96 464H48V256h320v208zm96-96h-48V144c0-26.5-21.5-48-48-48H144V48h320v320z" style="fill: var(--maximize_button);"/></symbol>
|
||||
<symbol id="plus" viewBox="0 0 512 512"><path d="M256 80c0-8.84-7.16-16-16-16s-16 7.16-16 16v160H64c-8.84 0-16 7.16-16 16s7.16 16 16 16h160v160c0 8.84 7.16 16 16 16s16-7.16 16-16V272h160c8.84 0 16-7.16 16-16s-7.16-16-16-16H272V80z"/></symbol>
|
||||
<symbol id="tag" viewBox="0 0 512 512"><path d="M0 252.118V48C0 21.49 21.49 0 48 0h204.118a48 48 0 0 1 33.941 14.059l211.882 211.882c18.745 18.745 18.745 49.137 0 67.882L293.823 497.941c-18.745 18.745-49.137 18.745-67.882 0L14.059 286.059A48 48 0 0 1 0 252.118zM112 64a48 48 0 1 0 48 48 48.055 48.055 0 0 0-48-48z"/></symbol>
|
||||
<symbol id="shield" viewBox="0 0 512 512"><path d="M466.5 83.71c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91a27.3 27.3 0 0 1-31.4-18.41V28c0-15.46-12.54-28-28-28h-74.8c-15.46 0-28 12.54-28 28v37.39a27.3 27.3 0 0 1-31.4 18.41c-19.2-4.44-45.2-14.91-45.2-14.91a27.29 27.29 0 0 0-21.6 0s-26 10.47-45.2 14.91A27.3 27.3 0 0 1 12 102.12V256c0 141.38 102.34 230.13 234.33 254.12a27.5 27.5 0 0 0 9.34 0C387.66 486.13 490 397.38 490 256V102.12a27.3 27.3 0 0 1-23.5-18.41z"/></symbol>
|
||||
<symbol id="bell_solid" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71z"/></symbol>
|
||||
<symbol id="bell_regular" viewBox="0 0 448 512"><path d="M224 512c35.32 0 63.97-28.65 63.97-64H160.03c0 35.35 28.65 64 63.97 64zm215.39-149.71c-19.32-20.76-55.47-51.99-55.47-154.29 0-77.7-54.48-139.9-127.94-155.16V32c0-17.67-14.32-32-31.98-32s-31.98 14.33-31.98 32v20.84C118.56 68.1 64.08 130.3 64.08 208c0 102.3-36.15 133.53-55.47 154.29-6 6.45-8.66 14.16-8.61 21.71.11 16.4 12.98 32 32.1 32h383.8c19.12 0 32-15.6 32.1-32 .05-7.55-2.61-15.27-8.61-21.71zM44.75 330.4C63.2 312 96 281.3 96 208c0-61.94 43.15-112 112-112s112 50.06 112 112c0 73.3 32.8 104 51.25 122.4 2.8 2.8 3.5 6.9 1.75 10.4s-5.4 5.6-9.15 5.6H52.1c-3.75 0-7.35-2.1-9.1-5.6s-1.05-7.6 1.75-10.4z"/></symbol>
|
||||
<symbol id="pin_solid" viewBox="0 0 24 24"><path d="M12 17h-7v-1.76a2 2 0 0 1 1.11-1.79l1.78-.9a2 0 0 0 1.11-1.76v-4.79a3 3 0 0 1 3-3 3 3 0 0 1 3 3v4.76a2 2 0 0 0 1.11 1.79l1.78.9a2 0 0 1 1.11 1.79v1.76h-7v5z" fill="currentColor" transform="rotate(45 12 12)"/></symbol>
|
||||
<symbol id="pin_regular" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="17" x2="12" y2="22"></line><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V17z"></path></g></symbol>
|
||||
<symbol id="building" viewBox="0 0 512 512"><path d="M432 64H80c-8.8 0-16 7.2-16 16v352c0 8.8 7.2 16 16 16h352c8.8 0 16-7.2 16-16V80c0-8.8-7.2-16-16-16zm-32 352H112V96h288v320zM192 288h128v32H192zm0-64h128v32H192zm0-64h128v32H192z" fill="currentColor"/></symbol>
|
||||
<symbol id="arrow-down" viewBox="0 0 448 512"><path d="M413.1 222.5l22.2 22.2c9.4 9.4 9.4 24.6 0 33.9L241 473c-9.4 9.4-24.6 9.4-33.9 0L12.7 278.6c-9.4-9.4-9.4-24.6 0-33.9l22.2-22.2c9.5-9.5 25-9.3 34.3.4L184 343.4V56c0-13.3 10.7-24 24-24h32c13.3 0 24 10.7 24 24v287.4l114.8-120.5c9.3-9.8 24.8-10 34.3-.4z" fill="currentColor"/></symbol>
|
||||
<symbol id="arrow-up" viewBox="0 0 448 512"><path d="M34.9 289.5l-22.2-22.2c-9.4-9.4-9.4-24.6 0-33.9L207 39c9.4-9.4 24.6-9.4 33.9 0l194.3 194.3c9.4 9.4 9.4 24.6 0 33.9l-22.2 22.2c-9.5 9.5-25 9.3-34.3-.4L264 168.6V456c0 13.3-10.7 24-24 24h-32c-13.3 0-24-10.7-24-24V168.6L69.2 289.1c-9.3 9.8-24.8 10-34.3.4z" fill="currentColor"/></symbol>
|
||||
<symbol id="star_regular" viewBox="0 0 576 512"><path d="M528.1 171.5L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6zM388.6 312.3l23.7 137.4L288 384.8l-124.3 64.9 23.7-137.4L87.5 214.4l138-20.1L288 69.1l62.5 125.2 138 20.1-99.9 97.9z" fill="currentColor"/></symbol>
|
||||
<symbol id="star_solid" viewBox="0 0 576 512"><path d="M259.3 17.8L194 150.2 47.9 171.5c-26.2 3.8-36.7 36.1-17.7 54.6l105.7 103-25 145.5c-4.5 26.3 23.2 46 46.4 33.7L288 439.6l126.7 66.7c23.2 12.3 50.9-7.4 46.4-33.7l-25-145.5 105.7-103c19-18.5 8.5-50.8-17.7-54.6L382 150.2 316.7 17.8c-11.7-23.6-45.6-23.9-57.4 0z" fill="currentColor"/></symbol>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
+108
-693
@@ -1,30 +1,18 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context
|
||||
const getContext = () => {
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
const favoEl = document.querySelector("#a_favo");
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const usernameEl = document.querySelector("a#a_username");
|
||||
const currentSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
|
||||
? window.albumGallery.getCurrentSubf0ck()
|
||||
: null;
|
||||
const subf0ck_id = currentSub ? (currentSub.slug || currentSub.id) : (tagsContainer?.dataset?.subf0ckSlug || tagsContainer?.dataset?.subf0ckId || null);
|
||||
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
subf0ck_id,
|
||||
postid: +idLink.innerText,
|
||||
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
|
||||
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
|
||||
poster: (usernameEl?.dataset?.username || '').trim() || null,
|
||||
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -56,13 +44,6 @@
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
if (!tagsContainer) return;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
|
||||
|
||||
const canManage = !!(
|
||||
document.querySelector('#tags[data-can-manage="true"]') ||
|
||||
(window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) ||
|
||||
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
|
||||
);
|
||||
|
||||
// Only remove existing dynamically generated tags
|
||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||
@@ -72,110 +53,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
|
||||
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
|
||||
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
|
||||
|
||||
const tagsCopy = [...cleanTags];
|
||||
tagsCopy.reverse().forEach(tag => {
|
||||
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized);
|
||||
let contentEl;
|
||||
if (isRating) {
|
||||
contentEl = document.createElement("span");
|
||||
contentEl.className = "rating-label";
|
||||
contentEl.textContent = tag.tag;
|
||||
} else {
|
||||
contentEl = document.createElement("a");
|
||||
contentEl.href = "/tag/" + tag.normalized;
|
||||
contentEl.style = "color: inherit !important";
|
||||
contentEl.textContent = tag.tag;
|
||||
}
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.textContent = tag.tag;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge");
|
||||
span.classList.add("badge", "mr-2");
|
||||
if (highlightTag && (tag.tag === highlightTag || tag.normalized === highlightTag)) {
|
||||
span.classList.add('new-tag-glow');
|
||||
}
|
||||
span.setAttribute('tooltip', tag.display_name || tag.user);
|
||||
|
||||
tag.badge.split(" ").forEach(b => span.classList.add(b));
|
||||
|
||||
if (isRating) {
|
||||
span.classList.add('rating-tag', `is-${tag.normalized}`);
|
||||
span.dataset.rating = tag.normalized;
|
||||
if (activePostId) span.dataset.itemId = activePostId;
|
||||
if (canManage) {
|
||||
span.classList.add('can-cycle');
|
||||
}
|
||||
} else {
|
||||
span.classList.add('tag-badge');
|
||||
span.setAttribute('data-tag-id', tag.id || '');
|
||||
span.setAttribute('data-tag', tag.tag);
|
||||
span.setAttribute('data-tag-normalized', tag.normalized);
|
||||
if (!window.f0ckSession?.is_anonymized && window.f0ckSession?.logged_in && !window.f0ckSession?.is_anon && (tag.display_name || tag.user)) {
|
||||
span.setAttribute("tooltip", tag.display_name || tag.user);
|
||||
}
|
||||
const isExcl = !!tag.is_excluded;
|
||||
if (isExcl) span.classList.add('tag-is-excluded');
|
||||
}
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
delbutton.href = "javascript:void(0)";
|
||||
// Class for delegation
|
||||
delbutton.classList.add("admin-deltag", "removetag");
|
||||
|
||||
span.appendChild(contentEl);
|
||||
|
||||
if (!isRating && tag.can_exclude) {
|
||||
const excludeBtn = document.createElement("button");
|
||||
excludeBtn.type = "button";
|
||||
excludeBtn.className = "tag-exclude-btn";
|
||||
excludeBtn.setAttribute("title", tag.is_excluded ? "Excluded (click to unexclude)" : "Exclude tag");
|
||||
excludeBtn.setAttribute("aria-label", "Exclude tag");
|
||||
excludeBtn.innerHTML = `<i class="fa-solid ${tag.is_excluded ? 'fa-circle-check' : 'fa-ban'}"></i>`;
|
||||
span.appendChild(excludeBtn);
|
||||
}
|
||||
|
||||
if (!isRating) {
|
||||
const delbutton = document.createElement("a");
|
||||
delbutton.href = "#";
|
||||
delbutton.classList.add("admin-deltag", "removetag");
|
||||
delbutton.innerHTML = '<i class="fa-solid fa-xmark"></i>';
|
||||
span.appendChild(document.createTextNode(" "));
|
||||
span.appendChild(delbutton);
|
||||
}
|
||||
span.appendChild(a);
|
||||
span.appendChild(document.createTextNode('\u00A0'));
|
||||
span.appendChild(delbutton);
|
||||
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
|
||||
const hasRating = !!lastRatingTag;
|
||||
if (!hasRating) {
|
||||
const untaggedSpan = document.createElement("span");
|
||||
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canManage ? ' can-cycle' : ''}`;
|
||||
untaggedSpan.dataset.rating = 'untagged';
|
||||
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "rating-label";
|
||||
lbl.textContent = "unrated";
|
||||
untaggedSpan.appendChild(lbl);
|
||||
inner.insertAdjacentElement("afterbegin", untaggedSpan);
|
||||
}
|
||||
|
||||
// Safeguard: remove any extra rating tags in DOM
|
||||
const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]');
|
||||
if (allRatingEls.length > 1) {
|
||||
for (let i = 1; i < allRatingEls.length; i++) {
|
||||
allRatingEls[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
|
||||
if (activePostId) {
|
||||
const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
|
||||
document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
|
||||
th.setAttribute('data-mode', modeAttr);
|
||||
});
|
||||
if (document.body.classList.contains('onara-modal-open')) {
|
||||
const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
|
||||
if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle show more/less toggle visibility and count
|
||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||
@@ -210,7 +115,7 @@
|
||||
e.stopImmediatePropagation();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, subf0ck_id } = ctx;
|
||||
const { postid } = ctx;
|
||||
|
||||
let target = e.target;
|
||||
if (target.nodeType === 3) target = target.parentElement;
|
||||
@@ -224,12 +129,8 @@
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm((window.f0ckI18n && window.f0ckI18n.tag_delete_title) || 'Delete Tag', `${(window.f0ckI18n && window.f0ckI18n.tag_delete_confirm) || 'Are you sure you want to delete the tag'} <strong style="color:#d9534f">${tagname}</strong>?`, async (reason) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (reason) qs.set('reason', reason);
|
||||
if (subf0ck_id) qs.set('subf0ck_id', subf0ck_id);
|
||||
const qsStr = qs.toString();
|
||||
|
||||
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (qsStr ? "?" + qsStr : ""), {
|
||||
// Send reason via query param for DELETE request
|
||||
const res = await (await fetch("/api/v2/tags/" + postid + "/" + encodeURIComponent(tagname) + (reason ? "?reason=" + encodeURIComponent(reason) : ""), {
|
||||
method: 'DELETE',
|
||||
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||
})).json();
|
||||
@@ -237,10 +138,6 @@
|
||||
if (!res.success) {
|
||||
throw new Error(res.msg || "Error deleting tag");
|
||||
}
|
||||
if (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function') {
|
||||
const cur = window.albumGallery.getCurrentSubf0ck();
|
||||
if (cur && res.tags) cur.tags = res.tags;
|
||||
}
|
||||
renderTags(res.tags);
|
||||
if (window.flashMessage) window.flashMessage((window.f0ckI18n?.tag_deleted_success) || 'Tag deleted', 2500, 'success');
|
||||
}, { allowEmpty: window.f0ckSession?.is_admin });
|
||||
@@ -250,7 +147,7 @@
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, subf0ck_id, tags } = ctx;
|
||||
const { postid, tags } = ctx;
|
||||
const anchor = document.querySelector("a#a_addtag");
|
||||
if (!anchor) return;
|
||||
|
||||
@@ -258,364 +155,74 @@
|
||||
postid,
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||
renderTags
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
let favSeq = 0;
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
if (!favoBtns.length) return;
|
||||
const firstFavoBtn = favoBtns[0];
|
||||
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
|
||||
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
|
||||
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
|
||||
let localId = firstFavoBtn?.dataset?.localId;
|
||||
|
||||
const ctx = getContext();
|
||||
const postid = localId ? Number(localId) : ctx?.postid;
|
||||
if (!postid && !chanUrl) return;
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const wasAlreadyFav = favoBtns[0].classList.contains('fa-solid') && !favoBtns[0].classList.contains('fa-spinner');
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
// Read state BEFORE the API call so we know which direction to toggle
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const setFavoUi = (isFav) => {
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fa-spinner', 'fa-spin');
|
||||
btn.classList.add('iconset', 'fa-heart');
|
||||
btn.classList.toggle('fa-solid', isFav);
|
||||
btn.classList.toggle('fa-regular', !isFav);
|
||||
btn.title = isFav ? (window.f0ckI18n?.fav_remove || 'Remove from favorites') : (window.f0ckI18n?.fav_add || 'Favorite');
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Instantly apply client UI
|
||||
setFavoUi(isNowFav);
|
||||
|
||||
// Micro-animation for tactile pop
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fav-pop');
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add('fav-pop');
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
// Instant flash message & vibration feedback
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
|
||||
if (postid && window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
|
||||
// Optimistically update #favs badge list
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
const prevFavsHtml = favcontainer ? favcontainer.innerHTML : '';
|
||||
const prevFavsHidden = favcontainer ? favcontainer.hidden : true;
|
||||
|
||||
if (favcontainer) {
|
||||
const currentUser = (window.f0ckSession?.user || '').toLowerCase();
|
||||
const isAnon = !!(window.f0ckSession?.is_anon || window.f0ckSession?.user === 'anonymous');
|
||||
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
|
||||
|
||||
const findSelfBadge = () => {
|
||||
const selfBadges = favcontainer.querySelectorAll('[data-self="true"]');
|
||||
if (selfBadges.length) return selfBadges[0];
|
||||
if (currentUser && currentUser !== 'anonymous') {
|
||||
const links = favcontainer.querySelectorAll('a[href]');
|
||||
for (const a of links) {
|
||||
const path = a.getAttribute('href') || '';
|
||||
if (path.toLowerCase().endsWith('/user/' + currentUser)) return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!isNowFav) {
|
||||
const selfBadge = findSelfBadge();
|
||||
if (selfBadge) selfBadge.remove();
|
||||
if (favcontainer.children.length === 0) {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
} else {
|
||||
let selfBadge = findSelfBadge();
|
||||
if (!selfBadge) {
|
||||
selfBadge = document.createElement('a');
|
||||
selfBadge.dataset.self = 'true';
|
||||
selfBadge.setAttribute('flow', 'up');
|
||||
if (isAnonymized || isAnon) {
|
||||
selfBadge.className = 'ghost-fav';
|
||||
selfBadge.setAttribute('tooltip', 'anonymous');
|
||||
selfBadge.style.cursor = 'default';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/a/default.png';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
selfBadge.appendChild(img);
|
||||
} else {
|
||||
selfBadge.href = `/user/${currentUser}`;
|
||||
selfBadge.setAttribute('tooltip', window.f0ckSession?.display_name || window.f0ckSession?.user || 'anonymous');
|
||||
const img = document.createElement('img');
|
||||
const avatarFile = window.f0ckSession?.avatar_file;
|
||||
const avatar = window.f0ckSession?.avatar;
|
||||
img.src = avatarFile ? `/a/${avatarFile}` : (avatar ? `/t/${avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (window.f0ckSession?.username_color) img.style.borderColor = window.f0ckSession.username_color;
|
||||
selfBadge.appendChild(img);
|
||||
}
|
||||
favcontainer.appendChild(selfBadge);
|
||||
}
|
||||
favcontainer.hidden = false;
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
}
|
||||
|
||||
const mySeq = ++favSeq;
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
// 2. Run server operation in background — can take as long as it needs
|
||||
(async () => {
|
||||
try {
|
||||
// If viewing un-rehosted 4chan post, auto-rehost first
|
||||
if (isChan && !localId && chanUrl) {
|
||||
if (mySeq !== favSeq) return;
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const rehostResp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: chanUrl,
|
||||
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
|
||||
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
|
||||
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
|
||||
})
|
||||
});
|
||||
const rehostData = await rehostResp.json();
|
||||
if (rehostData.success && rehostData.item_id) {
|
||||
localId = rehostData.item_id;
|
||||
favoBtns.forEach(btn => {
|
||||
btn.dataset.itemId = localId;
|
||||
btn.dataset.localId = localId;
|
||||
});
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
if (commentsEl) commentsEl.dataset.itemId = localId;
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
if (infoEl) infoEl.dataset.itemId = localId;
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
const timeEl = document.querySelector('time.timeago');
|
||||
if (timeEl) {
|
||||
const nowIso = new Date().toISOString();
|
||||
timeEl.dataset.iso = nowIso;
|
||||
const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
|
||||
timeEl.setAttribute('tooltip', fullDate);
|
||||
timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
|
||||
}
|
||||
|
||||
if (chanRehostBtn) {
|
||||
chanRehostBtn.classList.add('rehosted');
|
||||
chanRehostBtn.outerHTML = `
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
throw new Error(rehostData.msg || 'Rehost failed');
|
||||
}
|
||||
}
|
||||
|
||||
const effectivePostId = localId ? Number(localId) : (postid || getContext()?.postid);
|
||||
if (!effectivePostId) {
|
||||
throw new Error('Missing post ID');
|
||||
}
|
||||
|
||||
if (mySeq !== favSeq) return;
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: effectivePostId,
|
||||
chan_url: chanUrl,
|
||||
action: isNowFav ? 'add' : 'delete',
|
||||
favorited: isNowFav
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||
});
|
||||
|
||||
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 +253,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 +319,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();
|
||||
@@ -904,91 +379,40 @@
|
||||
window.adminSetPassword = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
const password = prompt(`Enter new password for ${name} (min 20 chars):`);
|
||||
if (!password) return;
|
||||
if (password.length < 20) return alert('Password must be at least 20 characters.');
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
if (!confirm(`Are you sure you want to set a new password for ${name}? This will invalidate all their existing sessions and force them to change it on next login.`)) return;
|
||||
|
||||
const hint =
|
||||
'Set a new password for <strong>' + escHTML(name) + '</strong>. Must be at least 20 characters.<br><br>' +
|
||||
'<input type="password" id="admin-pw-new" class="input" placeholder="New password (min 20 chars)" style="width:100%;margin-bottom:8px;" autocomplete="new-password">' +
|
||||
'<input type="password" id="admin-pw-confirm" class="input" placeholder="Confirm new password" style="width:100%;" autocomplete="new-password">';
|
||||
|
||||
ModAction.confirm('Set Password', hint, async () => {
|
||||
const password = document.getElementById('admin-pw-new')?.value || '';
|
||||
const confirm = document.getElementById('admin-pw-confirm')?.value || '';
|
||||
if (password.length < 20) throw new Error('Password must be at least 20 characters.');
|
||||
if (password !== confirm) throw new Error('Passwords do not match.');
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/set-password', { user_id: id, password });
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
alert(data.msg);
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to set password');
|
||||
alert(data.msg || 'Failed to set password');
|
||||
}
|
||||
}, { hideReason: true, confirmText: 'Set Password', unsafeContent: true });
|
||||
};
|
||||
|
||||
window.adminRenameUser = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const currentName = btn.dataset.name;
|
||||
const currentUsername = btn.dataset.username;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm(
|
||||
'Rename User',
|
||||
'Enter a new login name for <strong>' + escHTML(currentName) + '</strong>.<br>' +
|
||||
'<small style="color:#888;">Current login: <code>' + escHTML(currentUsername) + '</code> — All uploads will be reassigned. User sessions will be invalidated. And the user has to login with the NEW name from now on.</small>',
|
||||
async (newUsername) => {
|
||||
const data = await post('/api/v2/admin/users/rename', { user_id: id, new_username: newUsername });
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
// Update the row in-place: links, text, and all button data attributes
|
||||
const row = document.getElementById('user-row-' + id);
|
||||
if (row) {
|
||||
// Update the name link
|
||||
const link = row.querySelector('.user-info-cell a');
|
||||
if (link) {
|
||||
link.href = '/user/' + data.new_login;
|
||||
// Only overwrite text if there's no display_name (plain username link)
|
||||
if (!link.querySelector('span[style*="accent"]')) {
|
||||
link.textContent = data.new_user;
|
||||
}
|
||||
}
|
||||
// Update all buttons in the row with the new name/username
|
||||
row.querySelectorAll('[data-username]').forEach(el => { el.dataset.username = data.new_login; });
|
||||
row.querySelectorAll('[data-name]').forEach(el => { el.dataset.name = data.new_user; });
|
||||
// Update activity stat links
|
||||
row.querySelectorAll('a[href^="/user/"]').forEach(a => {
|
||||
a.href = a.href.replace(/\/user\/[^/]+/, '/user/' + data.new_login);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
throw new Error(data.msg || 'Rename failed');
|
||||
}
|
||||
},
|
||||
{ hideReason: false, singleLine: true, confirmText: 'Rename', placeholder: 'new username' }
|
||||
);
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
window.adminDeleteUser = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm(`CRITICAL ACTION: Are you sure you want to PERMANENTLY DELETE user ${name}? All their uploads and comments will be reassigned to 'deleted_user'. This cannot be undone.`)) return;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm(
|
||||
'Delete User',
|
||||
'<strong style="color:#d9534f">CRITICAL ACTION</strong>: Permanently delete user <strong>' + escHTML(name) + '</strong>?<br><br>All their uploads and comments will be reassigned to <code>deleted_user</code>. <strong>This cannot be undone.</strong>',
|
||||
async () => {
|
||||
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
document.getElementById(`user-row-${id}`)?.remove();
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to delete user');
|
||||
}
|
||||
},
|
||||
{ hideReason: true, confirmText: 'Delete User' }
|
||||
);
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/delete', { user_id: id });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
document.getElementById(`user-row-${id}`)?.remove();
|
||||
} else {
|
||||
alert(data.msg || 'Failed to delete user');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
window.adminResetLoginAttempts = async (btn) => {
|
||||
@@ -998,13 +422,8 @@
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/reset-login-attempts', { username });
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
// Remove the failed attempt badges and reset button from the row in-place
|
||||
const row = btn.closest('tr');
|
||||
if (row) {
|
||||
row.querySelectorAll('.status-badge[style*="ffcc00"], .status-badge[style*="ff4d4d"]').forEach(el => el.remove());
|
||||
}
|
||||
btn.remove();
|
||||
alert(data.msg);
|
||||
window.location.reload(); // Quickest way to refresh badges
|
||||
} else {
|
||||
alert(data.msg || 'Failed to reset attempts');
|
||||
}
|
||||
@@ -1016,22 +435,18 @@
|
||||
window.adminBulkDeleteHalls = async (btn) => {
|
||||
const id = btn.dataset.id;
|
||||
const name = btn.dataset.name;
|
||||
if (!confirm(`Are you sure you want to PERMANENTLY DELETE ALL HALLS for ${name}? This cannot be undone.`)) return;
|
||||
|
||||
if (typeof ModAction === 'undefined') return alert('Error: ModAction module not loaded');
|
||||
|
||||
ModAction.confirm(
|
||||
'Delete All Halls',
|
||||
'Permanently delete <strong>ALL halls</strong> for <strong>' + escHTML(name) + '</strong>? <strong>This cannot be undone.</strong>',
|
||||
async () => {
|
||||
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
||||
if (data.success) {
|
||||
showFlash(data.msg, 'success');
|
||||
} else {
|
||||
throw new Error(data.msg || 'Failed to delete halls');
|
||||
}
|
||||
},
|
||||
{ hideReason: true, confirmText: 'Delete Everything' }
|
||||
);
|
||||
try {
|
||||
const data = await post('/api/v2/admin/users/bulk-delete-halls', { user_id: id });
|
||||
if (data.success) {
|
||||
alert(data.msg);
|
||||
} else {
|
||||
alert(data.msg || 'Failed to delete halls');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Network error');
|
||||
}
|
||||
};
|
||||
|
||||
window.adminReassignUploads = async (btn) => {
|
||||
@@ -1058,7 +473,7 @@
|
||||
throw new Error(res.msg || 'Reassignment failed');
|
||||
}
|
||||
},
|
||||
{ hideReason: false, singleLine: true, confirmText: 'Reassign', placeholder: 'target username' }
|
||||
{ hideReason: false, confirmText: 'Reassign', placeholder: 'target username' }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
+382
-2091
File diff suppressed because it is too large
Load Diff
+100
-548
@@ -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,10 +528,12 @@ 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;
|
||||
if (!msg.querySelector('.dpill-emoji')) {
|
||||
// Only re-render if the content is still plain text (no img children)
|
||||
if (!msg.querySelector('img.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,54 +587,35 @@ 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];
|
||||
const url = emojiCache[code];
|
||||
if (url) {
|
||||
if (url.endsWith('.webm')) {
|
||||
const vid = document.createElement('video');
|
||||
vid.src = url;
|
||||
vid.className = 'dpill-emoji';
|
||||
vid.muted = true;
|
||||
vid.loop = true;
|
||||
vid.autoplay = true;
|
||||
vid.playsInline = true;
|
||||
vid.play().catch(() => {
|
||||
vid.addEventListener('canplay', () => vid.play().catch(() => {}), { once: true });
|
||||
});
|
||||
parent.appendChild(vid);
|
||||
} else {
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = `:${code}:`;
|
||||
img.className = 'dpill-emoji';
|
||||
parent.appendChild(img);
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
img.alt = `:${code}:`;
|
||||
img.className = 'dpill-emoji';
|
||||
parent.appendChild(img);
|
||||
} else {
|
||||
parent.appendChild(document.createTextNode(match[0]));
|
||||
}
|
||||
} 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 +650,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 +669,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 +688,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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -91,19 +46,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark internal drags to prevent them from triggering the dropzone
|
||||
window.addEventListener('dragstart', (e) => {
|
||||
if (e.dataTransfer) {
|
||||
e.dataTransfer.setData('application/x-f0ck-internal', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
// Global Drag Events
|
||||
window.addEventListener('dragenter', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.location.pathname === '/upload') return;
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
dragCounter++;
|
||||
dropOverlay.classList.add('active');
|
||||
}
|
||||
@@ -112,17 +59,10 @@
|
||||
window.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.location.pathname === '/upload') return;
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) {
|
||||
// Change dropEffect to none to indicate this dropzone doesn't accept internal elements
|
||||
e.dataTransfer.dropEffect = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('dragleave', (e) => {
|
||||
if (window.location.pathname === '/upload') return;
|
||||
// dragleave doesn't always have reliable dataTransfer.types, but if it does, check it
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
|
||||
|
||||
dragCounter--;
|
||||
if (dragCounter <= 0) {
|
||||
dragCounter = 0;
|
||||
@@ -132,8 +72,6 @@
|
||||
|
||||
window.addEventListener('drop', (e) => {
|
||||
if (window.location.pathname === '/upload') return;
|
||||
if (e.dataTransfer && e.dataTransfer.types.includes('application/x-f0ck-internal')) return;
|
||||
|
||||
e.preventDefault();
|
||||
dragCounter = 0;
|
||||
dropOverlay.classList.remove('active');
|
||||
|
||||
+1216
-18479
File diff suppressed because it is too large
Load Diff
+46
-165
@@ -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();
|
||||
}
|
||||
|
||||
updateUIFromSettings();
|
||||
// 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.
|
||||
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage(`Flash Yank ${settings.enabled ? 'enabled' : 'disabled'}`, 2000, settings.enabled ? 'success' : 'info');
|
||||
}
|
||||
updateUIFromSettings();
|
||||
}
|
||||
|
||||
// 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,17 @@
|
||||
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';
|
||||
// Handle floating badge visibility
|
||||
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
|
||||
|
||||
// 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 +656,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 +663,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) {
|
||||
|
||||
+14
-107
@@ -23,22 +23,6 @@
|
||||
let chatFocused = document.hasFocus();
|
||||
const ytOembedCache = new Map(); // videoId → {title, author_name}
|
||||
|
||||
// Shared IntersectionObserver for lazy-loading embedded images.
|
||||
// Images are rendered with data-lazy-src; this observer sets the real src
|
||||
// when the image is within 200px of the visible scroll area.
|
||||
const lazyImgObserver = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
const img = entry.target;
|
||||
const src = img.dataset.lazySrc;
|
||||
if (src) { img.src = src; delete img.dataset.lazySrc; }
|
||||
lazyImgObserver.unobserve(img);
|
||||
}
|
||||
}, {
|
||||
rootMargin: '200px', // start loading 200px before entering viewport
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
function updateBadge() {
|
||||
const badge = document.getElementById('gchat-badge');
|
||||
const bubble = document.getElementById('gchat-reopen-bubble');
|
||||
@@ -203,7 +187,7 @@
|
||||
'gi'
|
||||
);
|
||||
html = html.replace(imageRegex, url =>
|
||||
`<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
||||
`<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`
|
||||
);
|
||||
|
||||
// 6b. Raw video URLs from allowed hosts → <video>
|
||||
@@ -286,7 +270,7 @@
|
||||
if (/\.(mp4|webm|ogv|mov)(\?.*)?$/i.test(path))
|
||||
return `${pre}<span class="gchat-embed-video"><video src="${url}" controls loop muted playsinline preload="metadata"></video></span>`;
|
||||
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(path))
|
||||
return `${pre}<span class="gchat-embed-img"><img data-lazy-src="${url}" src="" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
||||
return `${pre}<span class="gchat-embed-img"><img src="${url}" loading="lazy" alt="" onerror="this.parentNode.style.display='none'"></span>`;
|
||||
if (/\.(mp3|ogg|wav|flac|aac|opus|m4a)(\?.*)?$/i.test(path))
|
||||
return `${pre}<span class="gchat-embed-audio"><audio src="${url}" controls preload="metadata"></audio></span>`;
|
||||
}
|
||||
@@ -326,73 +310,11 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function scrollToBottom(force = false, smooth = false) {
|
||||
function scrollToBottom(force = false) {
|
||||
const el = document.getElementById('gchat-messages');
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||
if (!force && !nearBottom) return;
|
||||
if (smooth) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
} else {
|
||||
// Double rAF ensures layout is committed before reading scrollHeight
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => { el.scrollTop = el.scrollHeight; }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the message container and keep snapping to bottom for durationMs.
|
||||
* Only stops if the user actively scrolls up via wheel / touch / keyboard.
|
||||
* Same logic as the DM snapToBottomSticky.
|
||||
*/
|
||||
function startStickyScroll(durationMs = 8000) {
|
||||
const el = document.getElementById('gchat-messages');
|
||||
if (!el) return;
|
||||
|
||||
let userScrolledUp = false;
|
||||
|
||||
const onWheel = (e) => { if (e.deltaY < 0) userScrolledUp = true; };
|
||||
const onKey = (e) => { if (['ArrowUp', 'PageUp', 'Home'].includes(e.key)) userScrolledUp = true; };
|
||||
let touchStartY = 0;
|
||||
const onTouchStart = (e) => { touchStartY = e.touches[0]?.clientY ?? 0; };
|
||||
const onTouchMove = (e) => { if ((e.touches[0]?.clientY ?? 0) > touchStartY + 10) userScrolledUp = true; };
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: true });
|
||||
el.addEventListener('keydown', onKey, { passive: true });
|
||||
el.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
el.addEventListener('touchmove', onTouchMove, { passive: true });
|
||||
|
||||
let ro;
|
||||
const cleanup = () => {
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
el.removeEventListener('keydown', onKey);
|
||||
el.removeEventListener('touchstart', onTouchStart);
|
||||
el.removeEventListener('touchmove', onTouchMove);
|
||||
if (ro) ro.disconnect();
|
||||
};
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
// Debounce: batch rapid layout changes (e.g. progressive image renders)
|
||||
// into a single smooth scroll instead of many jarring instant jumps.
|
||||
let debounceTimer = null;
|
||||
ro = new ResizeObserver(() => {
|
||||
if (userScrolledUp) { cleanup(); return; }
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
if (!userScrolledUp) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
||||
}, 80);
|
||||
});
|
||||
ro.observe(el);
|
||||
} else {
|
||||
setTimeout(() => scrollToBottom(true), 300);
|
||||
setTimeout(() => scrollToBottom(true), 800);
|
||||
setTimeout(() => scrollToBottom(true), 2000);
|
||||
}
|
||||
|
||||
setTimeout(cleanup, durationMs);
|
||||
|
||||
// First snap is instant (no animation — the panel just opened)
|
||||
scrollToBottom(true);
|
||||
setTimeout(() => { if (!userScrolledUp) scrollToBottom(true); }, 150);
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
if (force || nearBottom) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
async function fetchYtOembed(cardEl) {
|
||||
@@ -495,19 +417,9 @@
|
||||
s.addEventListener('click', () => s.classList.toggle('revealed'));
|
||||
});
|
||||
|
||||
// Embedded images: register with lazy observer; scroll on load only for new messages (not history)
|
||||
node.querySelectorAll('.gchat-embed-img img[data-lazy-src]').forEach(img => {
|
||||
// Only snap to bottom on image load for NEW incoming messages, not history.
|
||||
// History already scrolls once at the end of loadHistory; an extra scroll
|
||||
// here is what causes the double jump.
|
||||
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
|
||||
img.addEventListener('click', () => openImgModal(img.src));
|
||||
img.style.cursor = 'zoom-in';
|
||||
lazyImgObserver.observe(img);
|
||||
});
|
||||
// Already-src'd images (avatars etc.) — same rule
|
||||
node.querySelectorAll('.gchat-embed-img img:not([data-lazy-src])').forEach(img => {
|
||||
if (scrollForce) img.addEventListener('load', () => scrollToBottom(true));
|
||||
// Embedded images: scroll to bottom when loaded + open modal on click
|
||||
node.querySelectorAll('.gchat-embed-img img').forEach(img => {
|
||||
img.addEventListener('load', () => scrollToBottom(scrollForce));
|
||||
img.addEventListener('click', () => openImgModal(img.src));
|
||||
img.style.cursor = 'zoom-in';
|
||||
});
|
||||
@@ -532,14 +444,11 @@
|
||||
const data = await res.json();
|
||||
if (!data.success) return;
|
||||
const container = document.getElementById('gchat-messages');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
(data.messages || []).forEach(m => appendMsg(m, false));
|
||||
// Double rAF: wait for the browser to commit the layout (panel just became
|
||||
// visible from display:none) before reading scrollHeight.
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}));
|
||||
if (container) container.innerHTML = '';
|
||||
(data.messages || []).forEach(m => appendMsg(m));
|
||||
scrollToBottom(true);
|
||||
// Also scroll after images have had time to paint
|
||||
setTimeout(() => scrollToBottom(true), 600);
|
||||
} catch (e) {
|
||||
console.error('[Chat] Failed to load history:', e);
|
||||
}
|
||||
@@ -643,9 +552,7 @@
|
||||
if (icon) icon.className = `fa-solid ${isMinimized ? 'fa-chevron-up' : 'fa-chevron-down'}`;
|
||||
if (!isMinimized) {
|
||||
clearUnread();
|
||||
// Wait one rAF so the panel transitions from display:none to its full
|
||||
// height before loadHistory measures scrollHeight.
|
||||
requestAnimationFrame(() => loadHistory());
|
||||
loadHistory();
|
||||
if (!window.matchMedia('(pointer: coarse)').matches)
|
||||
setTimeout(() => document.getElementById('gchat-input')?.focus(), 150);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
+93
-351
@@ -18,69 +18,8 @@
|
||||
let draggingLayer = null;
|
||||
let hoveredLayer = null;
|
||||
let img = new Image();
|
||||
let hasLoadedImage = window.memeTemplate.id !== 'custom' && window.memeTemplate.category !== 'Custom';
|
||||
|
||||
// Show the local file picker only when no pre-selected template exists
|
||||
if (!hasLoadedImage) {
|
||||
const customSelector = document.getElementById('customTemplateSelector');
|
||||
if (customSelector) customSelector.style.display = '';
|
||||
}
|
||||
|
||||
const memeFont = 'Impact, Charcoal, sans-serif';
|
||||
const memeLetterSpacing = '0.03em';
|
||||
|
||||
function wrapText(ctx, text, maxWidth) {
|
||||
const paragraphs = text.split('\n');
|
||||
const lines = [];
|
||||
|
||||
paragraphs.forEach(paragraph => {
|
||||
if (paragraph.trim() === '') {
|
||||
lines.push('');
|
||||
return;
|
||||
}
|
||||
|
||||
const words = paragraph.split(' ').filter(w => w !== '');
|
||||
let currentLine = '';
|
||||
|
||||
words.forEach(word => {
|
||||
const testLine = currentLine ? currentLine + ' ' + word : word;
|
||||
let metrics = ctx.measureText(testLine);
|
||||
|
||||
if (metrics.width <= maxWidth) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine !== '') {
|
||||
lines.push(currentLine);
|
||||
currentLine = '';
|
||||
}
|
||||
|
||||
metrics = ctx.measureText(word);
|
||||
if (metrics.width <= maxWidth) {
|
||||
currentLine = word;
|
||||
} else {
|
||||
let charLine = '';
|
||||
for (let i = 0; i < word.length; i++) {
|
||||
const char = word[i];
|
||||
const testCharLine = charLine + char;
|
||||
if (ctx.measureText(testCharLine).width > maxWidth && charLine !== '') {
|
||||
lines.push(charLine);
|
||||
charLine = char;
|
||||
} else {
|
||||
charLine = testCharLine;
|
||||
}
|
||||
}
|
||||
currentLine = charLine;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
|
||||
// Image Setup
|
||||
img.crossOrigin = "anonymous";
|
||||
@@ -90,23 +29,11 @@
|
||||
|
||||
const defaultSize = 40;
|
||||
|
||||
// Initial layers - only set if we don't have any layers yet and we have loaded an image
|
||||
if (textLayers.length === 0 && hasLoadedImage) {
|
||||
textLayers = [
|
||||
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
||||
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
||||
];
|
||||
} else if (hasLoadedImage) {
|
||||
// Keep the text layers but adjust their coordinates to be in-bounds if they exceed new boundaries
|
||||
textLayers.forEach(layer => {
|
||||
if (layer.x > canvas.width) {
|
||||
layer.x = canvas.width / 2;
|
||||
}
|
||||
if (layer.y > canvas.height) {
|
||||
layer.y = canvas.height - 100;
|
||||
}
|
||||
});
|
||||
}
|
||||
// Initial layers
|
||||
textLayers = [
|
||||
{ id: Date.now(), text: '', x: canvas.width / 2, y: 40, fontSize: defaultSize },
|
||||
{ id: Date.now() + 1, text: '', x: canvas.width / 2, y: canvas.height - 100, fontSize: defaultSize }
|
||||
];
|
||||
|
||||
renderInputs();
|
||||
draw();
|
||||
@@ -115,65 +42,11 @@
|
||||
|
||||
// Ensure font is loaded before first draw
|
||||
if (document.fonts) {
|
||||
// Force browser to load the Impact font
|
||||
document.fonts.load('bold 40px Impact');
|
||||
document.fonts.load('bold 80px Impact');
|
||||
document.fonts.load('bold 150px Impact');
|
||||
document.fonts.ready.then(() => {
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function createSlider(container, min, max, initValue, onChange) {
|
||||
container.style.cssText = 'position:relative;height:28px;display:flex;align-items:center;flex:1;cursor:pointer;user-select:none;-webkit-user-select:none;touch-action:none;';
|
||||
|
||||
const track = document.createElement('div');
|
||||
track.style.cssText = 'position:absolute;left:8px;right:8px;height:4px;background:#333;border-radius:2px;top:50%;transform:translateY(-50%);';
|
||||
|
||||
const fill = document.createElement('div');
|
||||
fill.style.cssText = 'position:absolute;left:0;height:100%;background:var(--accent,#9f0);border-radius:2px;pointer-events:none;';
|
||||
|
||||
const thumb = document.createElement('div');
|
||||
thumb.style.cssText = 'position:absolute;width:18px;height:18px;background:var(--accent,#9f0);border-radius:50%;top:50%;transform:translate(-50%,-50%);box-shadow:0 0 6px rgba(0,0,0,.6);pointer-events:none;transition:transform .1s;';
|
||||
|
||||
const setRatio = (r) => {
|
||||
fill.style.width = (r * 100) + '%';
|
||||
thumb.style.left = (r * 100) + '%';
|
||||
};
|
||||
setRatio((initValue - min) / (max - min));
|
||||
|
||||
track.appendChild(fill);
|
||||
track.appendChild(thumb);
|
||||
container.appendChild(track);
|
||||
|
||||
const valueFromClientX = (clientX) => {
|
||||
const rect = track.getBoundingClientRect();
|
||||
const r = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
||||
setRatio(r);
|
||||
return Math.round(min + r * (max - min));
|
||||
};
|
||||
|
||||
container.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault(); // block keyboard + scroll takeover
|
||||
container.setPointerCapture(e.pointerId);
|
||||
thumb.style.transform = 'translate(-50%,-50%) scale(1.25)';
|
||||
onChange(valueFromClientX(e.clientX));
|
||||
}, { passive: false });
|
||||
|
||||
container.addEventListener('pointermove', (e) => {
|
||||
if (!container.hasPointerCapture(e.pointerId)) return;
|
||||
onChange(valueFromClientX(e.clientX));
|
||||
});
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (!container.hasPointerCapture(e.pointerId)) return;
|
||||
container.releasePointerCapture(e.pointerId);
|
||||
thumb.style.transform = 'translate(-50%,-50%) scale(1)';
|
||||
};
|
||||
container.addEventListener('pointerup', onEnd);
|
||||
container.addEventListener('pointercancel', onEnd);
|
||||
}
|
||||
|
||||
function renderInputs() {
|
||||
layersContainer.innerHTML = '';
|
||||
textLayers.forEach((layer, index) => {
|
||||
@@ -191,7 +64,7 @@
|
||||
|
||||
<div class="layer-font-size-control" style="display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 0.8em; color: #888; white-space: nowrap;">${(window.f0ckI18n?.meme?.size_label) || 'Size'}: <span class="layer-fs-val">${layer.fontSize}</span>px</span>
|
||||
<div class="layer-fs-slider" style="flex: 1;"></div>
|
||||
<input type="range" class="layer-fs-input" min="10" max="200" value="${layer.fontSize}" style="flex: 1;">
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -201,11 +74,11 @@
|
||||
draw();
|
||||
});
|
||||
|
||||
const fsSlider = div.querySelector('.layer-fs-slider');
|
||||
const fsVal = div.querySelector('.layer-fs-val');
|
||||
createSlider(fsSlider, 10, 200, layer.fontSize, (val) => {
|
||||
layer.fontSize = val;
|
||||
fsVal.textContent = val;
|
||||
const fsInput = div.querySelector('.layer-fs-input');
|
||||
const fsVal = div.querySelector('.layer-fs-val');
|
||||
fsInput.addEventListener('input', (e) => {
|
||||
layer.fontSize = parseInt(e.target.value);
|
||||
fsVal.textContent = layer.fontSize;
|
||||
draw();
|
||||
});
|
||||
|
||||
@@ -221,10 +94,6 @@
|
||||
}
|
||||
|
||||
addTextBtn.addEventListener('click', () => {
|
||||
if (!hasLoadedImage) {
|
||||
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
textLayers.push({
|
||||
id: Date.now(),
|
||||
text: 'NEW TEXT',
|
||||
@@ -256,11 +125,10 @@
|
||||
|
||||
const fontSize = layer.fontSize || 40;
|
||||
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
||||
ctx.letterSpacing = memeLetterSpacing;
|
||||
|
||||
let displayStr = layer.text.toUpperCase();
|
||||
const lines = wrapText(ctx, displayStr, canvas.width * 0.9);
|
||||
const h = lines.length * fontSize * 1.25;
|
||||
const lines = displayStr.split('\n');
|
||||
const h = lines.length * fontSize * 1.1;
|
||||
const w = canvas.width * 0.9;
|
||||
|
||||
// Box for the dragged/hovered layer (top-most layer gets preference)
|
||||
@@ -274,13 +142,12 @@
|
||||
}
|
||||
|
||||
lines.forEach((line, i) => {
|
||||
const yOffset = i * (fontSize * 1.25);
|
||||
const yOffset = i * (fontSize * 1.1);
|
||||
const renderX = Math.round(layer.x);
|
||||
const renderY = Math.round(layer.y + yOffset);
|
||||
|
||||
ctx.save();
|
||||
ctx.font = `bold ${fontSize}px ${memeFont}`; // Ensure correct font size per line
|
||||
ctx.letterSpacing = memeLetterSpacing;
|
||||
ctx.strokeStyle = 'black';
|
||||
ctx.lineWidth = Math.max(2, fontSize / 8); // Slightly thicker stroke for better legibility
|
||||
ctx.strokeText(line, renderX, renderY);
|
||||
@@ -306,13 +173,9 @@
|
||||
const isInsideText = (pt, layer) => {
|
||||
if (!layer.text) return false;
|
||||
const fontSize = layer.fontSize || 40;
|
||||
ctx.save();
|
||||
ctx.font = `bold ${fontSize}px ${memeFont}`;
|
||||
ctx.letterSpacing = memeLetterSpacing;
|
||||
const lines = wrapText(ctx, layer.text.toUpperCase(), canvas.width * 0.9);
|
||||
ctx.restore();
|
||||
const lines = layer.text.split('\n');
|
||||
const w = canvas.width * 0.95;
|
||||
const h = lines.length * fontSize * 1.25;
|
||||
const h = lines.length * fontSize * 1.2;
|
||||
|
||||
return pt.x >= layer.x - w / 2 && pt.x <= layer.x + w / 2 &&
|
||||
pt.y >= layer.y - 20 && pt.y <= layer.y + h + 20;
|
||||
@@ -369,22 +232,16 @@
|
||||
canvas.addEventListener('mousedown', onStart);
|
||||
// Upload
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
if (!hasLoadedImage) {
|
||||
window.flashMessage((window.f0ckI18n?.meme?.choose_image_first) || 'Please select an image first!', 3000, 'error');
|
||||
return;
|
||||
}
|
||||
const category = (window.memeTemplate && window.memeTemplate.category) ? window.memeTemplate.category.toLowerCase() : '';
|
||||
const subCategory = (window.memeTemplate && window.memeTemplate.sub_category) ? window.memeTemplate.sub_category.toLowerCase() : '';
|
||||
const templateId = (window.memeTemplate && window.memeTemplate.id) ? window.memeTemplate.id.toLowerCase() : '';
|
||||
|
||||
const isOrakelVon10 = subCategory === 'von10';
|
||||
const isOrakelUser = subCategory === 'user';
|
||||
const isOrakelBingoApu = subCategory === 'bingoapu' || templateId === 'bingoapu';
|
||||
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10 && !isOrakelBingoApu;
|
||||
const isOrakelNormal = category === 'orakel' && !isOrakelUser && !isOrakelVon10;
|
||||
|
||||
let uploadCanvas = canvas;
|
||||
|
||||
if (isOrakelNormal || isOrakelUser || isOrakelVon10 || isOrakelBingoApu) {
|
||||
if (isOrakelNormal || isOrakelUser || isOrakelVon10) {
|
||||
// Create an off-screen canvas to apply the orakel answer silently
|
||||
uploadCanvas = document.createElement('canvas');
|
||||
uploadCanvas.width = canvas.width;
|
||||
@@ -395,7 +252,6 @@
|
||||
uCtx.drawImage(canvas, 0, 0);
|
||||
|
||||
let result = '';
|
||||
let selectedCorner = null;
|
||||
if (isOrakelNormal) {
|
||||
const outcomes = ['JA', 'NEIN', 'VIELLEICHT', 'AUF JEDEN FALL', 'NIEMALS', 'SOWAS VON JA', 'VERGISS ES', 'FRAG SPÄTER', 'KOMMT DRAUF AN'];
|
||||
result = outcomes[Math.floor(Math.random() * outcomes.length)];
|
||||
@@ -409,124 +265,92 @@
|
||||
}
|
||||
} else if (isOrakelVon10) {
|
||||
result = Math.floor(Math.random() * 11).toString();
|
||||
} else if (isOrakelBingoApu) {
|
||||
const corners = [
|
||||
{ x: 60, y: 100, label: 'YES' },
|
||||
{ x: 573, y: 100, label: 'NO' },
|
||||
{ x: 60, y: 615, label: 'NO' },
|
||||
{ x: 573, y: 615, label: 'YES' }
|
||||
];
|
||||
selectedCorner = corners[Math.floor(Math.random() * corners.length)];
|
||||
result = selectedCorner.label;
|
||||
}
|
||||
|
||||
// Draw Orakel result on the hidden canvas
|
||||
uCtx.save();
|
||||
if (isOrakelBingoApu) {
|
||||
// Draw "KOPS" in normal meme text style on the selected corner
|
||||
uCtx.font = `bold 28px ${memeFont}`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
uCtx.textAlign = 'center';
|
||||
uCtx.textBaseline = 'middle';
|
||||
uCtx.fillStyle = '#fff';
|
||||
uCtx.strokeStyle = '#000';
|
||||
uCtx.lineWidth = 4;
|
||||
uCtx.miterLimit = 2;
|
||||
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
||||
uCtx.textAlign = 'center';
|
||||
uCtx.textBaseline = 'middle';
|
||||
|
||||
uCtx.strokeText('KOPS', selectedCorner.x, selectedCorner.y);
|
||||
uCtx.fillText('KOPS', selectedCorner.x, selectedCorner.y);
|
||||
if (isOrakelNormal) {
|
||||
uCtx.shadowBlur = 20;
|
||||
uCtx.shadowColor = 'rgba(101, 37, 212, 1)';
|
||||
} else if (isOrakelVon10) {
|
||||
uCtx.shadowBlur = 0; // No shadow as requested
|
||||
} else {
|
||||
uCtx.font = (isOrakelVon10) ? 'bold 150px Impact' : 'bold 80px Impact'; // Bigger font for the rating
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
uCtx.textAlign = 'center';
|
||||
uCtx.textBaseline = 'middle';
|
||||
// No shadow for the User Orakel
|
||||
uCtx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
if (isOrakelNormal) {
|
||||
uCtx.shadowBlur = 20;
|
||||
uCtx.shadowColor = 'rgba(101, 37, 212, 1)';
|
||||
} else if (isOrakelVon10) {
|
||||
uCtx.shadowBlur = 0; // No shadow as requested
|
||||
} else {
|
||||
// No shadow for the User Orakel
|
||||
uCtx.shadowBlur = 0;
|
||||
}
|
||||
uCtx.fillStyle = '#fff';
|
||||
uCtx.strokeStyle = '#000';
|
||||
uCtx.lineWidth = 10;
|
||||
uCtx.miterLimit = 2;
|
||||
|
||||
uCtx.fillStyle = '#fff';
|
||||
uCtx.strokeStyle = '#000';
|
||||
uCtx.lineWidth = 10;
|
||||
uCtx.miterLimit = 2;
|
||||
// Adjust position for user Orakel (reverting to +10 offset)
|
||||
let yPos = Math.round(isOrakelUser ? (uploadCanvas.height / 2 + 10) : (uploadCanvas.height / 2 + 50));
|
||||
|
||||
// Adjust position for user Orakel (reverting to +10 offset)
|
||||
let yPos = Math.round(isOrakelUser ? (uploadCanvas.height / 2 + 10) : (uploadCanvas.height / 2 + 50));
|
||||
if (isOrakelVon10) {
|
||||
yPos = Math.round(uploadCanvas.height / 2 ); // 1px lower
|
||||
}
|
||||
|
||||
if (isOrakelVon10) {
|
||||
yPos = Math.round(uploadCanvas.height / 2 ); // 1px lower
|
||||
}
|
||||
const xPos = Math.round(uploadCanvas.width / 2);
|
||||
|
||||
const xPos = Math.round(uploadCanvas.width / 2);
|
||||
// Auto-fit font size for user orakel — shrink until text fits within image width
|
||||
let orakelFontSize = isOrakelVon10 ? 150 : 80;
|
||||
const maxTextWidth = uploadCanvas.width - 80; // 40px padding each side
|
||||
|
||||
// Auto-fit font size for user orakel — shrink until text fits within image width
|
||||
let orakelFontSize = isOrakelVon10 ? 150 : 80;
|
||||
const maxTextWidth = uploadCanvas.width - 80; // 40px padding each side
|
||||
if (isOrakelUser) {
|
||||
const parts = result.split('|||');
|
||||
const namePart = parts[0];
|
||||
const idPart = parts.length > 1 ? `(${parts[1]})` : '';
|
||||
const combinedText = idPart ? `${namePart} ${idPart}` : namePart;
|
||||
|
||||
if (isOrakelUser) {
|
||||
const parts = result.split('|||');
|
||||
const namePart = parts[0];
|
||||
const idPart = parts.length > 1 ? `(${parts[1]})` : '';
|
||||
const combinedText = idPart ? `${namePart} ${idPart}` : namePart;
|
||||
// Even tighter threshold for User Orakel (approx 25% total padding)
|
||||
const userMaxWidth = Math.round(uploadCanvas.width * 0.75);
|
||||
|
||||
// Even tighter threshold for User Orakel (approx 25% total padding)
|
||||
const userMaxWidth = Math.round(uploadCanvas.width * 0.75);
|
||||
let currentFontSize = 74;
|
||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||
|
||||
let currentFontSize = 74;
|
||||
// First attempt: Shrink entire text on one line down to 58px if needed
|
||||
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
||||
currentFontSize -= 2;
|
||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
|
||||
// First attempt: Shrink entire text on one line down to 58px if needed
|
||||
while (uCtx.measureText(combinedText).width > userMaxWidth && currentFontSize > 58) {
|
||||
currentFontSize -= 2;
|
||||
uCtx.font = `bold ${currentFontSize}px Impact`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
}
|
||||
|
||||
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
||||
|
||||
if (combinedFits) {
|
||||
// Single line — potentially shrunk for long names
|
||||
uCtx.fillText(combinedText, xPos, yPos);
|
||||
} else {
|
||||
// Two lines — auto-fit just the name, ID below
|
||||
let nameFontSize = 74;
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
||||
nameFontSize -= 2;
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
}
|
||||
|
||||
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
||||
const lineGap = Math.round(nameFontSize * 1.25);
|
||||
const nameY = Math.round(yPos - lineGap / 2);
|
||||
const idY = Math.round(yPos + lineGap / 2) + 2;
|
||||
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
uCtx.fillText(namePart, xPos, nameY);
|
||||
|
||||
if (idPart) {
|
||||
uCtx.font = `bold ${idFontSize}px Impact`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
uCtx.fillText(idPart, xPos, idY);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal / von10 — single line as before
|
||||
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
||||
uCtx.letterSpacing = memeLetterSpacing;
|
||||
uCtx.strokeText(result, xPos, yPos);
|
||||
uCtx.fillText(result, xPos, yPos);
|
||||
}
|
||||
|
||||
const combinedFits = uCtx.measureText(combinedText).width <= userMaxWidth;
|
||||
|
||||
if (combinedFits) {
|
||||
// Single line — potentially shrunk for long names
|
||||
uCtx.fillText(combinedText, xPos, yPos);
|
||||
} else {
|
||||
// Two lines — auto-fit just the name, ID below
|
||||
let nameFontSize = 74;
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
while (uCtx.measureText(namePart).width > userMaxWidth && nameFontSize > 16) {
|
||||
nameFontSize -= 2;
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
}
|
||||
|
||||
const idFontSize = Math.max(18, Math.round(nameFontSize * 0.45));
|
||||
const lineGap = Math.round(nameFontSize * 0.65);
|
||||
const nameY = Math.round(yPos - lineGap / 2);
|
||||
const idY = Math.round(yPos + lineGap / 2) + 2;
|
||||
|
||||
uCtx.font = `bold ${nameFontSize}px Impact`;
|
||||
uCtx.fillText(namePart, xPos, nameY);
|
||||
|
||||
if (idPart) {
|
||||
uCtx.font = `bold ${idFontSize}px Impact`;
|
||||
uCtx.fillText(idPart, xPos, idY);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal / von10 — single line as before
|
||||
uCtx.font = `bold ${orakelFontSize}px Impact`;
|
||||
uCtx.strokeText(result, xPos, yPos);
|
||||
uCtx.fillText(result, xPos, yPos);
|
||||
}
|
||||
uCtx.restore();
|
||||
}
|
||||
@@ -560,22 +384,13 @@
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
if (result.manual_approval) {
|
||||
window.flashMessage(window.f0ckI18n?.upload_pending_approval_patient || 'Upload awaits approval', 3000, 'warning');
|
||||
if (window.loadPageAjax) {
|
||||
window.loadPageAjax('/');
|
||||
} else {
|
||||
window.location.href = '/';
|
||||
}
|
||||
const dest = result.redirect || '/meme';
|
||||
if (window.loadItemAjax) {
|
||||
window.loadItemAjax(dest);
|
||||
} else if (window.loadPageAjax) {
|
||||
window.loadPageAjax(dest);
|
||||
} else {
|
||||
const dest = result.redirect || '/meme';
|
||||
if (window.loadItemAjax) {
|
||||
window.loadItemAjax(dest);
|
||||
} else if (window.loadPageAjax) {
|
||||
window.loadPageAjax(dest);
|
||||
} else {
|
||||
window.location.href = dest;
|
||||
}
|
||||
window.location.href = dest;
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -589,79 +404,6 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Custom Local Image Selector logic
|
||||
const fileInput = document.getElementById('customTemplateFile');
|
||||
const selectFileBtn = document.getElementById('selectCustomFileBtn');
|
||||
const canvasWrapper = document.querySelector('.canvas-wrapper');
|
||||
|
||||
const loadLocalImage = (file) => {
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
hasLoadedImage = true;
|
||||
img.src = event.target.result;
|
||||
|
||||
// Update template metadata
|
||||
window.memeTemplate.name = file.name.replace(/\.[^/.]+$/, "");
|
||||
|
||||
// Update header title dynamically
|
||||
const headerTitle = document.querySelector('.meme-title');
|
||||
if (headerTitle) {
|
||||
const baseTitle = window.f0ckI18n?.meme?.create_meme || 'Create Meme:';
|
||||
headerTitle.innerHTML = `${baseTitle} ${window.memeTemplate.name}`;
|
||||
}
|
||||
|
||||
// Update tags input value if tags are present
|
||||
const tagsInput = document.getElementById('tags');
|
||||
if (tagsInput) {
|
||||
tagsInput.value = `meme, Custom, ${window.memeTemplate.name}`;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
if (selectFileBtn && fileInput) {
|
||||
selectFileBtn.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
loadLocalImage(file);
|
||||
});
|
||||
}
|
||||
|
||||
// HTML5 Drag & Drop Support
|
||||
if (canvas && canvasWrapper) {
|
||||
const preventDefaults = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||
canvasWrapper.addEventListener(eventName, preventDefaults, false);
|
||||
});
|
||||
|
||||
['dragenter', 'dragover'].forEach(eventName => {
|
||||
canvasWrapper.addEventListener(eventName, () => {
|
||||
canvasWrapper.classList.add('drag-hover');
|
||||
}, false);
|
||||
});
|
||||
|
||||
['dragleave', 'drop'].forEach(eventName => {
|
||||
canvasWrapper.addEventListener(eventName, () => {
|
||||
canvasWrapper.classList.remove('drag-hover');
|
||||
}, false);
|
||||
});
|
||||
|
||||
canvasWrapper.addEventListener('drop', (e) => {
|
||||
const dt = e.dataTransfer;
|
||||
const file = dt.files[0];
|
||||
loadLocalImage(file);
|
||||
}, false);
|
||||
}
|
||||
|
||||
// Initial draw
|
||||
setTimeout(draw, 300);
|
||||
}
|
||||
|
||||
+133
-1255
File diff suppressed because it is too large
Load Diff
@@ -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; } };
|
||||
})();
|
||||
+131
-427
@@ -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] };
|
||||
}
|
||||
@@ -239,12 +209,6 @@
|
||||
if (!str) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
function decodeHtmlEntities(str) {
|
||||
if (!str) return '';
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = String(str);
|
||||
return txt.value;
|
||||
}
|
||||
function timeAgo(iso) {
|
||||
const s = Math.floor((Date.now() - new Date(iso)) / 1000);
|
||||
const i = window.f0ckI18n || {};
|
||||
@@ -265,33 +229,28 @@
|
||||
return ago(fmt(y === 1 ? i.ta_year : i.ta_years, y, 'year'));
|
||||
}
|
||||
function hashId() {
|
||||
// Check path first /abyss/1234 or /abyss/gif/1234
|
||||
const pathClean = location.pathname.replace(/\/$/, '');
|
||||
const pathMatch = pathClean.match(/\/abyss\/([a-zA-Z0-9_\/-]+)$/);
|
||||
if (pathMatch) return pathMatch[1];
|
||||
|
||||
// Fallback to hash
|
||||
// Strip the leading '#' and allow numeric IDs or board/postid format
|
||||
const raw = location.hash.replace(/^#/, '').trim();
|
||||
if (/^\d+$/.test(raw)) return raw;
|
||||
if (/^[a-z0-9]+\/\d+$/.test(raw)) return raw;
|
||||
return '';
|
||||
}
|
||||
let lastPushedUrl = location.pathname + location.hash;
|
||||
let lastPushedHash = location.hash;
|
||||
function pushHash(id) {
|
||||
if (!id) return;
|
||||
const newUrl = '/abyss/' + id;
|
||||
if (newUrl === lastPushedUrl) return;
|
||||
lastPushedUrl = newUrl;
|
||||
history.pushState({ scrollerId: id }, '', newUrl);
|
||||
const newHash = '#' + id;
|
||||
if (newHash === lastPushedHash) return;
|
||||
lastPushedHash = newHash;
|
||||
history.pushState({ scrollerId: id }, '', '/abyss' + newHash);
|
||||
updateCacheActiveId(id);
|
||||
}
|
||||
|
||||
// Handle back/forward within abyss — scroll to the target slide
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (!document.body.classList.contains('scroller-active')) return;
|
||||
const id = e.state?.scrollerId || hashId();
|
||||
const id = e.state?.scrollerId || location.hash.replace('#', '');
|
||||
if (!id) return;
|
||||
lastPushedUrl = '/abyss/' + id;
|
||||
lastPushedHash = '#' + id;
|
||||
const slide = feed.querySelector(`.scroll-slide[data-id="${id}"], .scroll-slide[data-local-id="${id}"]`);
|
||||
if (slide) {
|
||||
slide.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
@@ -558,7 +517,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)); }
|
||||
|
||||
@@ -748,12 +707,9 @@
|
||||
const id = contextLink.dataset.id;
|
||||
const target = commentsList.querySelector(`.comment-item[data-comment-id="${id}"]`);
|
||||
if (target) {
|
||||
// Clear any previous persistent highlight
|
||||
commentsList.querySelectorAll('.comment-linked').forEach(el => el.classList.remove('comment-linked'));
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Flash the animation for attention, then keep the persistent highlight
|
||||
target.classList.add('highlight-comment', 'comment-linked');
|
||||
setTimeout(() => target.classList.remove('highlight-comment'), 2500);
|
||||
target.classList.add('highlight-comment');
|
||||
setTimeout(() => target.classList.remove('highlight-comment'), 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1030,10 +986,9 @@
|
||||
e.stopPropagation();
|
||||
const itemId = slide.dataset.id;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(tag)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1171,23 +1126,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; }
|
||||
@@ -1204,44 +1143,16 @@
|
||||
if (nowFaved) flashFav(slide);
|
||||
}
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch('/api/v2/togglefav', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `postid=${id}&action=${nowFaved ? 'add' : 'delete'}&favorited=${nowFaved}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `postid=${id}`
|
||||
});
|
||||
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 +1160,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 +1326,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 +1336,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 +1349,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,21 +1363,21 @@
|
||||
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-label"></span>
|
||||
<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-label"></span>
|
||||
<span class="scroll-btn-count">${item.comment_count ?? 0}</span>
|
||||
</button>
|
||||
${window.scrollerLoggedIn ? `
|
||||
<button class="scroll-btn js-tag-btn" data-id="${item.id}" title="${_i.add_tag || 'Add tag'}">
|
||||
<div class="scroll-btn-icon"><i class="fa-solid fa-tag"></i></div>
|
||||
<span class="scroll-btn-label"></span>
|
||||
</button>` : ''}
|
||||
<button class="scroll-btn js-share-btn" data-id="${item.id}" title="${_i.share_label || 'Share'}">
|
||||
<div class="scroll-btn-icon"><i class="fa-solid fa-share-nodes"></i></div>
|
||||
@@ -1486,7 +1385,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 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 +1535,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');
|
||||
@@ -1647,13 +1546,24 @@
|
||||
const rBadge = slide.querySelector('.scroll-rating[data-item-id]');
|
||||
if (rBadge) {
|
||||
if (window.scrollerIsMod || item.local_id) rBadge.classList.add('can-cycle');
|
||||
rBadge.addEventListener('click', e => {
|
||||
rBadge.addEventListener('click', async e => {
|
||||
if (Date.now() - speedEndedAt < 200) return; // ignore click if we just ended a speed-hold
|
||||
e.stopPropagation();
|
||||
const slideEl = rBadge.closest('.scroll-slide');
|
||||
const id = slideEl?.dataset.localId || rBadge.dataset.itemId;
|
||||
if (!id || isNaN(id)) { showShareToast('Rehost first to change rating'); return; }
|
||||
cycleRatingOptimistic(rBadge, id, false);
|
||||
try {
|
||||
const resp = await fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||
method: 'PUT',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
rBadge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||
rBadge.textContent = data.rating_label;
|
||||
rBadge.dataset.rating = data.rating_class;
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1672,10 +1582,9 @@
|
||||
e.stopPropagation();
|
||||
const itemId = slide.dataset.id;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${itemId}/${encodeURIComponent(t)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) pill.remove();
|
||||
@@ -1761,7 +1670,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 +1679,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}`,
|
||||
@@ -1781,14 +1688,13 @@
|
||||
avatar: '/a/default.png',
|
||||
stamp: p.time,
|
||||
timeago: timeAgo(p.time * 1000),
|
||||
tags: ``,
|
||||
tags: `4chan, /${data.board}/`,
|
||||
is_video: isVideo,
|
||||
is_image: isImage,
|
||||
is_audio: false,
|
||||
comment_count: p.replies || 0,
|
||||
rating_label: isWsg ? 'SFW' : (isGif ? 'NSFW' : 'External'),
|
||||
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged'),
|
||||
original_filename: p.filename ? `${decodeHtmlEntities(p.filename)}${ext}` : null
|
||||
rating_class: isWsg ? 'sfw' : (isGif ? 'nsfw' : 'untagged')
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
@@ -1919,7 +1825,7 @@
|
||||
const target = hid ? feed.querySelector(`.scroll-slide[data-id="${hid}"]`) : null;
|
||||
const first = feed.querySelector('.scroll-slide:not([data-lock])');
|
||||
const toActivate = target || first;
|
||||
if (toActivate) setTimeout(() => { toActivate.scrollIntoView({ behavior: 'instant', block: 'start' }); activateSlide(toActivate); hideLoader(); }, 200);
|
||||
if (toActivate) setTimeout(() => { activateSlide(toActivate); hideLoader(); }, 200);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SCROLLER] Fetch error:', err);
|
||||
@@ -1938,17 +1844,22 @@
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
'x-csrf-token': window.scrollerCsrf || ''
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: item.external_media_url || item.dest,
|
||||
...(item.original_filename ? { original_filename: item.original_filename } : {})
|
||||
rating: rating,
|
||||
tags: '4chan',
|
||||
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`
|
||||
})
|
||||
});
|
||||
const data = await resp.json();
|
||||
@@ -1974,7 +1885,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 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 +1896,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) {
|
||||
@@ -2014,74 +1925,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function cycleRatingOptimistic(badge, id, showToast = false) {
|
||||
if (!badge || !id || isNaN(id)) return;
|
||||
|
||||
let currentRating = badge.dataset.rating || '';
|
||||
if (!currentRating) {
|
||||
if (badge.classList.contains('sfw')) currentRating = 'sfw';
|
||||
else if (badge.classList.contains('nsfw')) currentRating = 'nsfw';
|
||||
else if (badge.classList.contains('nsfl')) currentRating = 'nsfl';
|
||||
}
|
||||
|
||||
let nextRating = 'sfw';
|
||||
if (currentRating === 'sfw') nextRating = 'nsfw';
|
||||
else if (currentRating === 'nsfw') nextRating = 'nsfl';
|
||||
|
||||
const mapping = {
|
||||
sfw: { label: 'SFW', cls: 'sfw', toast: '🛡 SFW' },
|
||||
nsfw: { label: 'NSFW', cls: 'nsfw', toast: '🔥 NSFW' },
|
||||
nsfl: { label: 'NSFL', cls: 'nsfl', toast: '💀 NSFL' }
|
||||
};
|
||||
|
||||
const info = mapping[nextRating];
|
||||
|
||||
const oldClassName = badge.className;
|
||||
const oldTextContent = badge.textContent;
|
||||
const oldDatasetRating = badge.dataset.rating;
|
||||
|
||||
// Track active request ID to ignore out-of-order race conditions on rapid keypresses
|
||||
const reqId = (badge._lastCycleReqId || 0) + 1;
|
||||
badge._lastCycleReqId = reqId;
|
||||
|
||||
// Optimistically apply new state
|
||||
badge.className = `scroll-rating ${info.cls}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||
badge.textContent = info.label;
|
||||
badge.dataset.rating = info.cls;
|
||||
|
||||
if (showToast) {
|
||||
showShareToast(info.toast);
|
||||
}
|
||||
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/v2/tags/${id}/cycle-rating`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
|
||||
if (!data.success) {
|
||||
revert();
|
||||
return;
|
||||
}
|
||||
// Verify we match actual server result
|
||||
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||
badge.textContent = data.rating_label;
|
||||
badge.dataset.rating = data.rating_class;
|
||||
})
|
||||
.catch(() => {
|
||||
if (badge._lastCycleReqId !== reqId) return; // ignore stale responses
|
||||
revert();
|
||||
});
|
||||
|
||||
function revert() {
|
||||
badge.className = oldClassName;
|
||||
badge.textContent = oldTextContent;
|
||||
badge.dataset.rating = oldDatasetRating;
|
||||
showShareToast('⚠️ Failed to update rating');
|
||||
}
|
||||
}
|
||||
|
||||
function reloadFeed() {
|
||||
clearCache();
|
||||
@@ -2207,12 +2050,7 @@
|
||||
const contentEl = commentEl.querySelector('.comment-content');
|
||||
if (!contentEl) return;
|
||||
|
||||
let raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
if (raw.includes('>') || raw.includes('<') || raw.includes('&')) {
|
||||
const txt = document.createElement('textarea');
|
||||
txt.innerHTML = raw;
|
||||
raw = txt.value;
|
||||
}
|
||||
const raw = (contentEl.dataset.raw || '').replace(/<br\s*\/?>/gi, '\n').trim();
|
||||
const lines = raw.split('\n');
|
||||
const quote = `>>${id}\n${lines.map(line => `>${line}`).join('\n')}\n`;
|
||||
|
||||
@@ -2239,11 +2077,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 +2088,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>
|
||||
` : ''}
|
||||
@@ -2881,16 +2718,11 @@
|
||||
if (!content || !commentsItemId || commentsPosting) return;
|
||||
commentsPosting = true; commentSendBtn.disabled = true;
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
let postBody = `item_id=${commentsItemId}&content=${encodeURIComponent(content)}`;
|
||||
if (replyToCommentId) postBody += `&parent_id=${replyToCommentId}`;
|
||||
if (csrfToken) postBody += `&csrf_token=${encodeURIComponent(csrfToken)}`;
|
||||
const resp = await fetch('/api/comments', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: postBody
|
||||
});
|
||||
const data = await resp.json();
|
||||
@@ -2904,7 +2736,7 @@
|
||||
avatar: null,
|
||||
username: window.scrollerUsername,
|
||||
display_name: displayName,
|
||||
content: data.comment?.content ?? content,
|
||||
content: content,
|
||||
created_at: null
|
||||
}, !!window.scrollerLoggedIn);
|
||||
// Set avatar from global
|
||||
@@ -2998,14 +2830,10 @@
|
||||
if (addTagSendBtn) addTagSendBtn.disabled = true;
|
||||
closeSugg();
|
||||
try {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
const resp = await fetch(`/api/v2/tags/${targetId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: `tagname=${encodeURIComponent(tag)}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `tagname=${encodeURIComponent(tag)}`
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
@@ -3057,7 +2885,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 +2894,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 +2902,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 +2933,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 +2947,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 +2969,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 +2984,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 +2993,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(' · ');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3407,17 +3136,30 @@
|
||||
else if (e.key === 'p' || e.key === 'P') {
|
||||
e.preventDefault();
|
||||
if (!currentSlide || !window.scrollerLoggedIn) return;
|
||||
const itemId = currentSlide.dataset.localId || currentSlide.dataset.id;
|
||||
const itemId = currentSlide.dataset.id;
|
||||
if (!itemId) return;
|
||||
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
|
||||
if (badge) {
|
||||
cycleRatingOptimistic(badge, itemId, true);
|
||||
}
|
||||
fetch(`/api/v2/tags/${itemId}/cycle-rating`, {
|
||||
method: 'PUT',
|
||||
headers: { 'x-csrf-token': window.scrollerCsrf || '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (!data.success) return;
|
||||
// Update the badge on the slide immediately
|
||||
const badge = currentSlide.querySelector('.scroll-rating[data-item-id]');
|
||||
if (badge) {
|
||||
badge.className = `scroll-rating ${data.rating_class}${window.scrollerIsMod ? ' can-cycle' : ''}`;
|
||||
badge.textContent = data.rating_label;
|
||||
badge.dataset.rating = data.rating_class;
|
||||
}
|
||||
const labels = { sfw: '🛡 SFW', nsfw: '🔥 NSFW', nsfl: '💀 NSFL' };
|
||||
showShareToast(labels[data.rating_class] ?? data.rating_label);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
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 +3404,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 +3441,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'];
|
||||
let sActiveTab = 'user';
|
||||
let sCachedNotifs = [];
|
||||
|
||||
if (sNotifBtn && sNotifDropdown) {
|
||||
@@ -3818,9 +3545,6 @@
|
||||
} else if (n.type === 'report') {
|
||||
link = '/mod/reports'; user = i18n.notif_moderation || 'Moderator';
|
||||
msg = i18n.notif_new_report || 'New user report';
|
||||
} else if (n.type === 'warning') {
|
||||
link = `/notifications?tab=system#notif-${n.id}`; user = i18n.notif_system || 'System';
|
||||
msg = (i18n.account_warning && i18n.account_warning.title) || 'Account Warning';
|
||||
} else {
|
||||
link = `/${n.item_id}#c${n.reference_id}`;
|
||||
if (n.type === 'comment_reply') msg = i18n.notif_replied || 'replied to you';
|
||||
@@ -3828,14 +3552,8 @@
|
||||
else if (n.type === 'mention') msg = i18n.notif_mentioned || 'highlighted you';
|
||||
else msg = i18n.notif_commented || 'commented';
|
||||
}
|
||||
let thumb;
|
||||
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`;
|
||||
const 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}
|
||||
<div class="notif-content">
|
||||
@@ -3873,24 +3591,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
// 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), {
|
||||
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);
|
||||
await fetch('/api/notifications/read', { method: 'POST' });
|
||||
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) {}
|
||||
});
|
||||
}
|
||||
@@ -3901,12 +3610,7 @@
|
||||
if (!item) return;
|
||||
const nid = item.dataset.id;
|
||||
if (nid && item.classList.contains('unread')) {
|
||||
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
|
||||
fetch(`/api/notifications/${nid}/read`, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
|
||||
}).catch(() => {});
|
||||
fetch(`/api/notifications/${nid}/read`, { method: 'POST', keepalive: true }).catch(() => {});
|
||||
item.classList.remove('unread');
|
||||
// Update cache
|
||||
const cached = sCachedNotifs.find(n => String(n.id) === String(nid));
|
||||
@@ -3939,7 +3643,7 @@
|
||||
|
||||
const restored = tryRestoreFromCache();
|
||||
if (!restored) {
|
||||
if (chanHashPending && !applied.externalUrl) {
|
||||
if (chanHashPending) {
|
||||
chanHashPending.then(() => fetchItems());
|
||||
} else {
|
||||
fetchItems();
|
||||
|
||||
+37
-1238
File diff suppressed because it is too large
Load Diff
+111
-1983
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) {
|
||||
@@ -81,13 +71,11 @@ window.TagAutocomplete = (() => {
|
||||
|
||||
// Flag to prevent focusout from destroying dropdown while touching it
|
||||
let dropdownTouching = false;
|
||||
// Flag set when we intentionally blur to dismiss the keyboard on mobile
|
||||
let keyboardDismissed = false;
|
||||
dropdown.addEventListener('touchstart', () => { dropdownTouching = true; }, { passive: true });
|
||||
dropdown.addEventListener('touchend', () => {
|
||||
dropdownTouching = false;
|
||||
// Note: do NOT re-focus input here — that would reopen the mobile keyboard.
|
||||
// The keyboard only comes back when the user explicitly taps the input.
|
||||
// Re-focus input so user can keep typing after scrolling
|
||||
input.focus();
|
||||
}, { passive: true });
|
||||
dropdown.addEventListener('touchcancel', () => { dropdownTouching = false; }, { passive: true });
|
||||
|
||||
@@ -226,21 +214,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;
|
||||
}
|
||||
|
||||
@@ -290,59 +267,18 @@ window.TagAutocomplete = (() => {
|
||||
open(opts);
|
||||
});
|
||||
|
||||
// Close when clicking/tapping outside.
|
||||
// Desktop (mousedown): close immediately.
|
||||
// Mobile (touchstart): first tap outside dismisses the keyboard only (blur),
|
||||
// leaving suggestions visible so the user can scroll up to see them.
|
||||
// A second tap outside within 500 ms actually closes the dropdown.
|
||||
let outsideTapCount = 0;
|
||||
let outsideTapTimer = null;
|
||||
|
||||
const onDocMousedown = (e) => {
|
||||
// Close when clicking/tapping outside
|
||||
const onDocClick = (e) => {
|
||||
if (!wrapper.contains(e.target) && e.target !== anchorEl) {
|
||||
document.removeEventListener('mousedown', onDocMousedown);
|
||||
document.removeEventListener('mousedown', onDocClick);
|
||||
document.removeEventListener('touchstart', onDocClick);
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
const onDocTouchstart = (e) => {
|
||||
if (wrapper.contains(e.target) || e.target === anchorEl) return;
|
||||
|
||||
outsideTapCount++;
|
||||
|
||||
if (outsideTapCount === 1) {
|
||||
// First outside tap — just blur to dismiss the keyboard.
|
||||
// Suggestions remain visible so the user can scroll up to see them.
|
||||
keyboardDismissed = true;
|
||||
input.blur();
|
||||
// Reset the flag after the blur event has fired.
|
||||
setTimeout(() => { keyboardDismissed = false; }, 100);
|
||||
|
||||
// Reset the counter after the double-tap window expires.
|
||||
outsideTapTimer = setTimeout(() => {
|
||||
outsideTapCount = 0;
|
||||
}, 500);
|
||||
} else {
|
||||
// Second outside tap — close the dropdown.
|
||||
clearTimeout(outsideTapTimer);
|
||||
outsideTapCount = 0;
|
||||
document.removeEventListener('touchstart', onDocTouchstart);
|
||||
destroy();
|
||||
}
|
||||
};
|
||||
|
||||
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.
|
||||
// Delay attaching to avoid capturing the opening click
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', onDocMousedown);
|
||||
document.addEventListener('touchstart', onDocTouchstart, { passive: true });
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
document.addEventListener('touchstart', onDocClick, { passive: true });
|
||||
}, 0);
|
||||
|
||||
// Click on the wrapper area should refocus the input
|
||||
@@ -357,7 +293,6 @@ window.TagAutocomplete = (() => {
|
||||
// Delay to allow suggestion tap/scroll to complete first
|
||||
setTimeout(() => {
|
||||
if (dropdownTouching) return; // user is interacting with dropdown
|
||||
if (keyboardDismissed) return; // intentional blur to hide mobile keyboard
|
||||
// Don't close if focus is still within the wrapper
|
||||
if (activeInstance && wrapper.contains(document.activeElement)) return;
|
||||
if (activeInstance && input.value.length === 0 && document.activeElement !== input) {
|
||||
@@ -367,5 +302,5 @@ window.TagAutocomplete = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
return { open, destroy, isOpen };
|
||||
return { open, destroy };
|
||||
})();
|
||||
|
||||
+105
-1619
File diff suppressed because it is too large
Load Diff
+77
-415
@@ -1,26 +1,14 @@
|
||||
(async () => {
|
||||
// Helper to get dynamic context from the DOM
|
||||
const getContext = () => {
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
const favoEl = document.querySelector("#a_favo");
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
const idLinkEl = document.querySelector("a.id-link");
|
||||
|
||||
const rawId = favoEl?.dataset?.localId || commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
|
||||
if (!rawId) return null;
|
||||
|
||||
const idLink = document.querySelector("a.id-link");
|
||||
if (!idLink) return null;
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
|
||||
const currentSub = (window.albumGallery && typeof window.albumGallery.getCurrentSubf0ck === 'function')
|
||||
? window.albumGallery.getCurrentSubf0ck()
|
||||
: null;
|
||||
const subf0ck_id = currentSub ? (currentSub.slug || currentSub.id) : (tagsContainer?.dataset?.subf0ckSlug || tagsContainer?.dataset?.subf0ckId || null);
|
||||
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
return {
|
||||
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
|
||||
subf0ck_id,
|
||||
postid: +idLink.innerText,
|
||||
poster: document.querySelector("a#a_username")?.innerText,
|
||||
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
|
||||
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
|
||||
};
|
||||
};
|
||||
|
||||
@@ -52,16 +40,6 @@
|
||||
const tagsContainer = document.querySelector("#tags");
|
||||
if (!tagsContainer) return;
|
||||
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
|
||||
const activePostId = tagsContainer.dataset.itemId || (typeof window.getCurrentItemId === 'function' ? window.getCurrentItemId() : null);
|
||||
|
||||
const canManage = !!(
|
||||
document.querySelector('#tags[data-can-manage="true"]') ||
|
||||
(window.f0ckSession && (window.f0ckSession.is_admin || window.f0ckSession.is_moderator)) ||
|
||||
(window.f0ckSession && window.f0ckSession.user && document.querySelector('#a_username[data-username]')?.dataset?.username?.toLowerCase() === window.f0ckSession.user.toLowerCase())
|
||||
);
|
||||
|
||||
// Anonymous uploaders may change the rating of their own item without full manage rights
|
||||
const canRate = canManage || !!document.querySelector('#tags[data-can-rate="true"]');
|
||||
|
||||
// Only remove existing dynamically generated tags
|
||||
[...inner.querySelectorAll(".badge")].forEach(tag => {
|
||||
@@ -71,66 +49,24 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Deduplicate: ensure only at most ONE rating tag exists in the tags list
|
||||
const ratingTags = _tags.filter(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized));
|
||||
const lastRatingTag = ratingTags.length ? ratingTags[ratingTags.length - 1] : null;
|
||||
const cleanTags = _tags.filter(t => !['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t === lastRatingTag);
|
||||
|
||||
const tagsCopy = [...cleanTags];
|
||||
tagsCopy.reverse().forEach(tag => {
|
||||
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tag.normalized);
|
||||
let contentEl;
|
||||
if (isRating) {
|
||||
contentEl = document.createElement("span");
|
||||
contentEl.className = "rating-label";
|
||||
contentEl.textContent = tag.tag;
|
||||
} else {
|
||||
contentEl = document.createElement("a");
|
||||
contentEl.href = "/tag/" + tag.normalized;
|
||||
contentEl.style = "color: inherit !important";
|
||||
contentEl.textContent = tag.tag;
|
||||
}
|
||||
_tags.reverse().forEach(tag => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/tag/${tag.normalized}`;
|
||||
a.style = "color: inherit !important";
|
||||
a.textContent = tag.tag;
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.classList.add("badge");
|
||||
span.classList.add("badge", "mr-2");
|
||||
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 +77,9 @@
|
||||
span.insertAdjacentElement("beforeend", del);
|
||||
}
|
||||
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
inner.insertAdjacentElement("afterbegin", span);
|
||||
});
|
||||
|
||||
const hasRating = !!lastRatingTag;
|
||||
if (!hasRating) {
|
||||
const untaggedSpan = document.createElement("span");
|
||||
untaggedSpan.className = `badge badge-untagged rating-tag is-untagged${canRate ? ' can-cycle' : ''}`;
|
||||
untaggedSpan.dataset.rating = 'untagged';
|
||||
if (activePostId) untaggedSpan.dataset.itemId = activePostId;
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "rating-label";
|
||||
lbl.textContent = "unrated";
|
||||
untaggedSpan.appendChild(lbl);
|
||||
inner.insertAdjacentElement("afterbegin", untaggedSpan);
|
||||
}
|
||||
|
||||
// Safeguard: remove any extra rating tags in DOM
|
||||
const allRatingEls = inner.querySelectorAll('.rating-tag, .badge-success, .badge-danger, .badge-nsfl, .badge-untagged, [data-rating]');
|
||||
if (allRatingEls.length > 1) {
|
||||
for (let i = 1; i < allRatingEls.length; i++) {
|
||||
allRatingEls[i].remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Update thumbnail data-mode in background grid (ensures div.posts > a > p::before updates in Onara)
|
||||
if (activePostId) {
|
||||
const modeAttr = lastRatingTag ? lastRatingTag.normalized : 'null';
|
||||
document.querySelectorAll(`.posts > a.thumb[data-item-id="${activePostId}"], .posts a[data-item-id="${activePostId}"], .posts a[href$="/${activePostId}"]`).forEach(th => {
|
||||
th.setAttribute('data-mode', modeAttr);
|
||||
});
|
||||
if (document.body.classList.contains('onara-modal-open')) {
|
||||
const onaraThumb = document.querySelector('.posts > a.thumb.onara-active');
|
||||
if (onaraThumb) onaraThumb.setAttribute('data-mode', modeAttr);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle show more/less toggle visibility and count
|
||||
const allBadges = [...inner.querySelectorAll(".badge")];
|
||||
const realTags = allBadges.filter(b => !b.querySelector('#a_addtag') && !b.querySelector('#a_toggle') && !b.classList.contains('tag-ac-wrapper'));
|
||||
@@ -209,7 +112,7 @@
|
||||
if (e) e.preventDefault();
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid, subf0ck_id, tags } = ctx;
|
||||
const { postid, tags } = ctx;
|
||||
const anchor = document.querySelector("a#a_addtag");
|
||||
if (!anchor) return;
|
||||
|
||||
@@ -217,329 +120,88 @@
|
||||
postid,
|
||||
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);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
|
||||
renderTags
|
||||
});
|
||||
};
|
||||
|
||||
const toggleEvent = async (e) => {
|
||||
if (e) e.preventDefault();
|
||||
const ratingEl = document.querySelector('.rating-tag.can-cycle, button#a_toggle');
|
||||
if (window.cycleRating) {
|
||||
window.cycleRating(ratingEl);
|
||||
const ctx = getContext();
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const res = await (await fetch('/api/v2/tags/' + encodeURIComponent(postid) + '/toggle', {
|
||||
method: 'PUT',
|
||||
headers: { "X-CSRF-Token": window.f0ckSession?.csrf_token }
|
||||
})).json();
|
||||
|
||||
renderTags(res.tags);
|
||||
const isNsfw = res.tags.some(t => t.id == 2);
|
||||
const isUntagged = res.tags.length === 0;
|
||||
const toggleBtn = document.querySelector('button#a_toggle');
|
||||
if (toggleBtn) {
|
||||
toggleBtn.classList.toggle('is-nsfw', isNsfw && !isUntagged);
|
||||
toggleBtn.classList.toggle('is-sfw', !isNsfw && !isUntagged);
|
||||
toggleBtn.classList.toggle('is-untagged', isUntagged);
|
||||
const labels = { true: 'NSFW', false: 'SFW' };
|
||||
toggleBtn.textContent = isUntagged ? '?' : (isNsfw ? 'NSFW' : 'SFW');
|
||||
}
|
||||
};
|
||||
|
||||
let favSeq = 0;
|
||||
|
||||
const toggleFavEvent = async (e) => {
|
||||
// e is the click event or undefined
|
||||
if (e && typeof e.preventDefault === 'function') e.preventDefault();
|
||||
if (window.f0ckSession?.is_anon && window.f0ckSession?.anon_permissions && window.f0ckSession.anon_permissions.favorite === false) {
|
||||
if (typeof window.flashMessage === 'function') {
|
||||
window.flashMessage('Anonymous favoriting is disabled.', 3000, 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
const favoBtns = document.querySelectorAll("#a_favo");
|
||||
if (!favoBtns.length) return;
|
||||
const firstFavoBtn = favoBtns[0];
|
||||
const chanRehostBtn = document.querySelector('#chan-item-rehost-btn');
|
||||
const isChan = firstFavoBtn?.dataset?.isChan === 'true' || !!chanRehostBtn;
|
||||
const chanUrl = firstFavoBtn?.dataset?.chanUrl || chanRehostBtn?.dataset?.url;
|
||||
let localId = firstFavoBtn?.dataset?.localId;
|
||||
|
||||
const ctx = getContext();
|
||||
const postid = localId ? Number(localId) : ctx?.postid;
|
||||
if (!postid && !chanUrl) return;
|
||||
if (!ctx) return;
|
||||
const { postid } = ctx;
|
||||
|
||||
const wasAlreadyFav = favoBtns[0].classList.contains('fa-solid') && !favoBtns[0].classList.contains('fa-spinner');
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
// Read state BEFORE the API call so we know which direction to toggle
|
||||
const favoBtn = document.querySelector("#a_favo");
|
||||
const wasAlreadyFav = favoBtn && favoBtn.classList.contains('fa-solid');
|
||||
|
||||
const setFavoUi = (isFav) => {
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fa-spinner', 'fa-spin');
|
||||
btn.classList.add('iconset', 'fa-heart');
|
||||
btn.classList.toggle('fa-solid', isFav);
|
||||
btn.classList.toggle('fa-regular', !isFav);
|
||||
btn.title = isFav ? (window.f0ckI18n?.fav_remove || 'Remove from favorites') : (window.f0ckI18n?.fav_add || 'Favorite');
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Instantly apply client UI
|
||||
setFavoUi(isNowFav);
|
||||
|
||||
// Micro-animation for tactile pop
|
||||
favoBtns.forEach(btn => {
|
||||
btn.classList.remove('fav-pop');
|
||||
void btn.offsetWidth;
|
||||
btn.classList.add('fav-pop');
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: postid
|
||||
});
|
||||
if (res.success) {
|
||||
// New state is the logical opposite of what it was before the API call
|
||||
const isNowFav = !wasAlreadyFav;
|
||||
|
||||
// Instant flash message & vibration feedback
|
||||
window.flashMessage((window.f0ckI18n && (isNowFav ? window.f0ckI18n.fav_added : window.f0ckI18n.fav_removed)) || (isNowFav ? 'ADDED TO FAVORITES' : 'REMOVED FROM FAVORITES'));
|
||||
if (navigator.vibrate) navigator.vibrate(50);
|
||||
|
||||
if (postid && window.invalidateItemCache) {
|
||||
window.invalidateItemCache(postid);
|
||||
}
|
||||
|
||||
// Optimistically update #favs badge list
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
const prevFavsHtml = favcontainer ? favcontainer.innerHTML : '';
|
||||
const prevFavsHidden = favcontainer ? favcontainer.hidden : true;
|
||||
|
||||
if (favcontainer) {
|
||||
const currentUser = (window.f0ckSession?.user || '').toLowerCase();
|
||||
const isAnon = !!(window.f0ckSession?.is_anon || window.f0ckSession?.user === 'anonymous');
|
||||
const isAnonymized = !!(window.f0ckSession?.is_anonymized ?? (window.f0ckSession?.guest_anonymize && !window.f0ckSession?.logged_in) ?? (window.f0ckSession?.is_anon && window.f0ckSession?.anon_anonymize));
|
||||
|
||||
const findSelfBadge = () => {
|
||||
const selfBadges = favcontainer.querySelectorAll('[data-self="true"]');
|
||||
if (selfBadges.length) return selfBadges[0];
|
||||
if (currentUser && currentUser !== 'anonymous') {
|
||||
const links = favcontainer.querySelectorAll('a[href]');
|
||||
for (const a of links) {
|
||||
const path = a.getAttribute('href') || '';
|
||||
if (path.toLowerCase().endsWith('/user/' + currentUser)) return a;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (!isNowFav) {
|
||||
const selfBadge = findSelfBadge();
|
||||
if (selfBadge) selfBadge.remove();
|
||||
if (favcontainer.children.length === 0) {
|
||||
favcontainer.hidden = true;
|
||||
}
|
||||
} else {
|
||||
let selfBadge = findSelfBadge();
|
||||
if (!selfBadge) {
|
||||
selfBadge = document.createElement('a');
|
||||
selfBadge.dataset.self = 'true';
|
||||
selfBadge.setAttribute('flow', 'up');
|
||||
if (isAnonymized || isAnon) {
|
||||
selfBadge.className = 'ghost-fav';
|
||||
selfBadge.setAttribute('tooltip', 'anonymous');
|
||||
selfBadge.style.cursor = 'default';
|
||||
const img = document.createElement('img');
|
||||
img.src = '/a/default.png';
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
selfBadge.appendChild(img);
|
||||
} else {
|
||||
selfBadge.href = `/user/${currentUser}`;
|
||||
selfBadge.setAttribute('tooltip', window.f0ckSession?.display_name || window.f0ckSession?.user || 'anonymous');
|
||||
const img = document.createElement('img');
|
||||
const avatarFile = window.f0ckSession?.avatar_file;
|
||||
const avatar = window.f0ckSession?.avatar;
|
||||
img.src = avatarFile ? `/a/${avatarFile}` : (avatar ? `/t/${avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (window.f0ckSession?.username_color) img.style.borderColor = window.f0ckSession.username_color;
|
||||
selfBadge.appendChild(img);
|
||||
}
|
||||
favcontainer.appendChild(selfBadge);
|
||||
}
|
||||
favcontainer.hidden = false;
|
||||
if (favoBtn) {
|
||||
favoBtn.classList.toggle('fa-solid', isNowFav);
|
||||
favoBtn.classList.toggle('fa-regular', !isNowFav);
|
||||
}
|
||||
}
|
||||
|
||||
const mySeq = ++favSeq;
|
||||
// span#favs
|
||||
const favcontainer = document.querySelector('#favs');
|
||||
favcontainer.innerHTML = "";
|
||||
if (res.favs.length > 0) {
|
||||
res.favs.forEach(f => {
|
||||
const a = document.createElement('a');
|
||||
a.href = `/user/${f.user}`;
|
||||
a.setAttribute('tooltip', f.display_name || f.user);
|
||||
a.setAttribute('flow', 'up');
|
||||
|
||||
// 2. Run server operation in background — can take as long as it needs
|
||||
(async () => {
|
||||
try {
|
||||
// If viewing un-rehosted 4chan post, auto-rehost first
|
||||
if (isChan && !localId && chanUrl) {
|
||||
if (mySeq !== favSeq) return;
|
||||
const csrfToken = window.f0ckSession?.csrf_token || '';
|
||||
const rehostResp = await fetch('/api/v2/scroller/rehost', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {})
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
url: chanUrl,
|
||||
original_filename: firstFavoBtn?.dataset?.filename || chanRehostBtn?.dataset?.filename || '',
|
||||
width: firstFavoBtn?.dataset?.width || chanRehostBtn?.dataset?.width || '',
|
||||
height: firstFavoBtn?.dataset?.height || chanRehostBtn?.dataset?.height || ''
|
||||
})
|
||||
});
|
||||
const rehostData = await rehostResp.json();
|
||||
if (rehostData.success && rehostData.item_id) {
|
||||
localId = rehostData.item_id;
|
||||
favoBtns.forEach(btn => {
|
||||
btn.dataset.itemId = localId;
|
||||
btn.dataset.localId = localId;
|
||||
});
|
||||
const commentsEl = document.querySelector("#comments-container");
|
||||
if (commentsEl) commentsEl.dataset.itemId = localId;
|
||||
const infoEl = document.querySelector("#a_info");
|
||||
if (infoEl) infoEl.dataset.itemId = localId;
|
||||
const img = document.createElement('img');
|
||||
img.src = f.avatar_file ? `/a/${f.avatar_file}` : (f.avatar ? `/t/${f.avatar}.webp` : '/a/default.png');
|
||||
img.style.height = "32px";
|
||||
img.style.width = "32px";
|
||||
if (f.username_color) img.style.borderColor = f.username_color;
|
||||
|
||||
const timeEl = document.querySelector('time.timeago');
|
||||
if (timeEl) {
|
||||
const nowIso = new Date().toISOString();
|
||||
timeEl.dataset.iso = nowIso;
|
||||
const fullDate = typeof window.f0ckFormatDateFull === 'function' ? window.f0ckFormatDateFull(nowIso) : new Date().toLocaleString();
|
||||
timeEl.setAttribute('tooltip', fullDate);
|
||||
timeEl.textContent = (window.f0ckTimeAgo ? window.f0ckTimeAgo(nowIso) : (window.f0ckI18n?.timeago_just_now || 'just now'));
|
||||
}
|
||||
|
||||
if (chanRehostBtn) {
|
||||
chanRehostBtn.classList.add('rehosted');
|
||||
chanRehostBtn.outerHTML = `
|
||||
<span class="chan-rehosted-wrap" style="display:inline-flex;gap:4px;align-items:center;">
|
||||
<a href="/${localId}" class="chan-rehost-action-btn rehosted" title="Already rehosted on f0ckm (Post #${localId})" style="display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:6px;font-size:0.8rem;background:rgba(74,222,128,0.2);color:#4ade80;border:1px solid rgba(74,222,128,0.4);text-decoration:none;font-weight:600;"><i class="fa-solid fa-check"></i> #${localId}</a>
|
||||
<button type="button" class="chan-rehost-edit-btn" data-item-id="${localId}" title="Edit rating" style="display:inline-flex;align-items:center;padding:3px 7px;border-radius:6px;font-size:0.8rem;background:rgba(255,255,255,0.1);color:#fff;border:1px solid rgba(255,255,255,0.2);cursor:pointer;"><i class="fa-solid fa-pen"></i></button>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
} else {
|
||||
throw new Error(rehostData.msg || 'Rehost failed');
|
||||
}
|
||||
}
|
||||
|
||||
const effectivePostId = localId ? Number(localId) : (postid || getContext()?.postid);
|
||||
if (!effectivePostId) {
|
||||
throw new Error('Missing post ID');
|
||||
}
|
||||
|
||||
if (mySeq !== favSeq) return;
|
||||
|
||||
const res = await post('/api/v2/togglefav', {
|
||||
postid: effectivePostId,
|
||||
chan_url: chanUrl,
|
||||
action: isNowFav ? 'add' : 'delete',
|
||||
favorited: isNowFav
|
||||
a.appendChild(img);
|
||||
favcontainer.appendChild(a);
|
||||
favcontainer.appendChild(document.createTextNode('\u00A0'));
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
+49
-182
@@ -1,15 +1,12 @@
|
||||
if (!window.UserCommentSystem) {
|
||||
window.UserCommentSystem = class UserCommentSystem {
|
||||
constructor() {
|
||||
this.container = document.getElementById('user-comments-container');
|
||||
this.username = this.container ? this.container.dataset.user : null;
|
||||
this.page = 1;
|
||||
this.loading = false;
|
||||
this.finished = false;
|
||||
this.userColor = null;
|
||||
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||
|
||||
|
||||
class UserCommentSystem {
|
||||
constructor() {
|
||||
this.container = document.getElementById('user-comments-container');
|
||||
this.username = this.container ? this.container.dataset.user : null;
|
||||
this.page = 1;
|
||||
this.loading = false;
|
||||
this.finished = false;
|
||||
this.userColor = null;
|
||||
this.customEmojis = UserCommentSystem.emojiCache || {};
|
||||
|
||||
this.icons = {
|
||||
reply: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 10 20 15 15 20"></polyline><path d="M4 4v7a4 4 0 0 0 4 4h12"></path></svg>`,
|
||||
@@ -26,16 +23,12 @@ if (!window.UserCommentSystem) {
|
||||
}
|
||||
|
||||
handleLiveEdit(data) {
|
||||
if (!this.container || !document.body.contains(this.container)) {
|
||||
window.removeEventListener('f0ck:comment_edited', this.editListener);
|
||||
return;
|
||||
}
|
||||
if (!this.container) return;
|
||||
const el = document.getElementById('c' + data.comment_id);
|
||||
if (el && this.container.contains(el)) {
|
||||
const contentEl = el.querySelector('.comment-content');
|
||||
if (contentEl) {
|
||||
contentEl.innerHTML = this.renderCommentContent(data.content, data.item_id);
|
||||
this.playEmojiVideos(contentEl);
|
||||
el.classList.remove('new-item-fade');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('new-item-fade');
|
||||
@@ -44,7 +37,7 @@ if (!window.UserCommentSystem) {
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.loadEmojis();
|
||||
this.loadEmojis();
|
||||
this.loadMore();
|
||||
this.loadMore();
|
||||
this.bindEvents();
|
||||
@@ -119,7 +112,6 @@ if (!window.UserCommentSystem) {
|
||||
const html = this.renderComment(c);
|
||||
this.container.insertAdjacentHTML('beforeend', html);
|
||||
});
|
||||
this.playEmojiVideos(this.container);
|
||||
this.page++;
|
||||
} else {
|
||||
this.finished = true;
|
||||
@@ -135,89 +127,13 @@ if (!window.UserCommentSystem) {
|
||||
}
|
||||
}
|
||||
|
||||
playEmojiVideos(container) {
|
||||
if (!container) return;
|
||||
container.querySelectorAll('video.emoji').forEach(v => {
|
||||
v.play().catch(() => {
|
||||
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderEmoji(match, name) {
|
||||
if (this.customEmojis && this.customEmojis[name]) {
|
||||
const url = this.customEmojis[name];
|
||||
if (url.endsWith('.webm')) {
|
||||
return `<video src="${url}" class="emoji" autoplay loop muted playsinline title=":${name}:"></video>`;
|
||||
}
|
||||
return `<img src="${url}" class="emoji" alt="${match}" title="${match}">`;
|
||||
return `<img src="${this.customEmojis[name]}" style="height:60px;vertical-align:middle;" alt="${name}">`;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
renderCommentAttachments(files, content = '') {
|
||||
if (!files || files.length === 0) return '';
|
||||
const items = files.map(f => {
|
||||
const url = `/c/${f.dest}`;
|
||||
if (content && content.includes(url)) return ''; // Skip if already rendered in content
|
||||
if (f.mime && f.mime.startsWith('image/')) {
|
||||
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" alt="${this.escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
|
||||
} else if (f.mime && f.mime.startsWith('video/')) {
|
||||
return `<div class="cf-attachment cf-video"><video src="${url}" controls preload="metadata"></video></div>`;
|
||||
} else if (f.mime && f.mime.startsWith('audio/')) {
|
||||
return `<div class="cf-attachment cf-audio"><audio src="${url}" controls preload="metadata"></audio></div>`;
|
||||
}
|
||||
return '';
|
||||
}).join('');
|
||||
return items ? `<div class="comment-attachments">${items}</div>` : '';
|
||||
}
|
||||
|
||||
renderCommentPoll(poll, commentId) {
|
||||
if (!poll) return '';
|
||||
const i18n = window.f0ckI18n || {};
|
||||
const session = window.f0ckSession || {};
|
||||
const total = poll.total_votes || 0;
|
||||
const voted = !!poll.user_vote_option_id;
|
||||
const expired = poll.expires_at && new Date(poll.expires_at) < new Date();
|
||||
const isAnon = poll.is_anonymous !== false;
|
||||
|
||||
const optionsHtml = (poll.options || []).map(opt => {
|
||||
const pct = total > 0 ? Math.round((opt.vote_count / total) * 100) : 0;
|
||||
const isVoted = poll.user_vote_option_id === opt.id;
|
||||
const clickable = session.logged_in && !expired && !voted;
|
||||
const voterAvatars = (!isAnon && Array.isArray(opt.voters) && opt.voters.length > 0)
|
||||
? `<div class="poll-option-voters">${opt.voters.map(v => {
|
||||
const u = (v && typeof v === 'object') ? v : { username: String(v || ''), avatar: null, avatar_file: null };
|
||||
const name = String(u.username || '');
|
||||
const src = u.avatar_file ? `/a/${u.avatar_file}` : u.avatar ? `/t/${u.avatar}.webp` : '/a/default.png';
|
||||
return name ? `<a href="/user/${this.escapeHtml(name)}" title="${this.escapeHtml(name)}"><img class="poll-voter-avatar" src="${src}" alt="${this.escapeHtml(name)}" loading="lazy"></a>` : '';
|
||||
}).join('')}</div>`
|
||||
: '';
|
||||
return `<div class="poll-option ${isVoted ? 'poll-option-voted' : ''} ${clickable ? 'poll-option-clickable' : ''}"
|
||||
data-option-id="${opt.id}" data-poll-id="${poll.id}" data-comment-id="${commentId}">
|
||||
<div class="poll-option-bar" style="width:${pct}%"></div>
|
||||
<span class="poll-option-text">${this.escapeHtml(opt.text)}</span>
|
||||
<span class="poll-option-pct">${pct}%</span>
|
||||
${isVoted ? `<i class="fa-solid fa-check poll-vote-check" title="${i18n.poll_voted || 'You voted'}"></i>` : ''}
|
||||
${voterAvatars}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const anonBadge = isAnon
|
||||
? `<span class="poll-anon-badge" title="${i18n.poll_anonymous || 'Anonymous'}"><i class="fa-solid fa-user-secret"></i></span>`
|
||||
: `<span class="poll-anon-badge poll-public-badge" title="${i18n.poll_public || 'Public votes'}"><i class="fa-solid fa-eye"></i></span>`;
|
||||
|
||||
return `<div class="comment-poll" data-poll-id="${poll.id}" data-is-anonymous="${isAnon ? '1' : '0'}">
|
||||
<div class="poll-question">${this.escapeHtml(poll.question)}</div>
|
||||
<div class="poll-options">${optionsHtml}</div>
|
||||
<div class="poll-footer">
|
||||
<span class="poll-total">${total} ${total === 1 ? (i18n.poll_vote_single || 'vote') : (i18n.poll_votes || 'votes')}</span>
|
||||
${anonBadge}
|
||||
${expired ? `<span class="poll-expired-badge">${i18n.poll_expired || 'Poll closed'}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderCommentContent(content, itemId = null) {
|
||||
if (!content) return '';
|
||||
|
||||
@@ -236,7 +152,7 @@ if (!window.UserCommentSystem) {
|
||||
let escaped = this.escapeHtml(content).replace(/>/g, ">");
|
||||
|
||||
// 2. Mentions
|
||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
|
||||
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])/g;
|
||||
|
||||
const siteOrigin = window.location.origin;
|
||||
const renderer = new marked.Renderer();
|
||||
@@ -275,60 +191,19 @@ if (!window.UserCommentSystem) {
|
||||
return `<a href="${href}"${titleAttr}>${displayText}</a>`;
|
||||
};
|
||||
|
||||
renderer.image = (href, title, text) => {
|
||||
const src = (typeof href === 'object' && href !== null) ? (href.href || '') : (href || '');
|
||||
const imgHtml = `<img src="${src}" alt="${text || ''}"${title ? ` title="${title}"` : ''} onerror="this.onerror=null; this.outerHTML='<span class=\\'broken-image-text\\'>[image not found]</span>';">`;
|
||||
if (window.f0ckSession?.is_admin && src && src.startsWith('/c/')) {
|
||||
const filename = src.substring(3); // Remove '/c/'
|
||||
return `<span class="image-embed-wrap">${imgHtml}<button class="admin-delete-attachment-btn" data-filename="${filename}" title="Delete attachment">[x]</button></span>`;
|
||||
}
|
||||
return imgHtml;
|
||||
};
|
||||
|
||||
// Pre-compile regexes for image/video/audio embeds matching comments.js
|
||||
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const allowedHosts = [escapedSiteHost];
|
||||
if (window.f0ckAllowedImages && Array.isArray(window.f0ckAllowedImages)) {
|
||||
window.f0ckAllowedImages.forEach(h => {
|
||||
const escapedHost = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
allowedHosts.push(`(?:[a-z0-9-]+\\.)*${escapedHost}`);
|
||||
});
|
||||
}
|
||||
const hostsRegexPart = allowedHosts.join('|');
|
||||
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
|
||||
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
|
||||
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?(?:#gif)?))(?![\\)\\]])`, 'gi');
|
||||
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
|
||||
const rawAudioRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp3|ogg|wav|flac|aac|opus|m4a)(?:\\?[^\\s\\[\\]\\(\\)]+)?))`, 'gi');
|
||||
|
||||
// 3. Line-by-line rendering to avoid paragraph collapsing and recursion
|
||||
const renderedLines = escaped.split('\n').map(line => {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith('>') && !trimmed.match(/^>>\d+/)) {
|
||||
const quoteContent = line.substring(line.indexOf('>') + 1);
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
const escapedQuote = quoteContent.replace(/>/g, '>');
|
||||
const rendered = quoteEmojis
|
||||
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
|
||||
: escapedQuote;
|
||||
return `<span class="greentext">>${rendered}</span>`;
|
||||
return `<span class="greentext">>${quoteContent}</span>`;
|
||||
}
|
||||
|
||||
// Per-line limit
|
||||
if (line.length > 10000) return line;
|
||||
if (!line.trim()) return ' ';
|
||||
|
||||
let processedLine = line;
|
||||
|
||||
// Handle Mentions
|
||||
const mentionStore = [];
|
||||
processedLine = processedLine.replace(mentionRegex, (match, g1, g2, g3, g4) => {
|
||||
const user = g1 || g2 || g4;
|
||||
const html = `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
|
||||
const idx = mentionStore.length;
|
||||
mentionStore.push(html);
|
||||
return `\x02MNT${idx}\x03`;
|
||||
});
|
||||
let processedLine = line.replace(mentionRegex, '[@$1](/user/$1)');
|
||||
|
||||
// Handle Comment Context Links (>>ID)
|
||||
processedLine = processedLine.replace(/(?<!\w)>>(\d+)/g, (match, id) => {
|
||||
@@ -336,34 +211,9 @@ if (!window.UserCommentSystem) {
|
||||
return `<a href="${targetHref}" class="comment-context-link" data-id="${id}">>>${id}</a>`;
|
||||
});
|
||||
|
||||
// Handle Image Embeds
|
||||
processedLine = processedLine.replace(imageRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) {
|
||||
fullUrl = '//' + url;
|
||||
}
|
||||
return ``;
|
||||
});
|
||||
|
||||
// Handle Raw Video/Audio links so Marked converts them to <a>
|
||||
processedLine = processedLine.replace(rawVideoRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
||||
return `[video](${fullUrl})`;
|
||||
});
|
||||
|
||||
processedLine = processedLine.replace(rawAudioRegex, (match, url) => {
|
||||
let fullUrl = url;
|
||||
if (!url.startsWith('http') && !url.startsWith('//') && !url.startsWith('/')) fullUrl = '//' + url;
|
||||
return `[audio](${fullUrl})`;
|
||||
});
|
||||
|
||||
const escapedAsterisks = processedLine.replace(/\*/g, '\\*');
|
||||
let rendered = marked.parseInline ? marked.parseInline(escapedAsterisks, { renderer: renderer }) : marked.parse(escapedAsterisks, { renderer: renderer }).replace(/<p>|<\/p>/g, '');
|
||||
|
||||
// Restore Mentions
|
||||
rendered = rendered.replace(/\x02MNT(\d+)\x03/g, (_, i) => mentionStore[+i]);
|
||||
|
||||
// Render emojis ONLY if this is NOT a quote line OR if the user prefers it
|
||||
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
|
||||
if (!trimmed.startsWith('>') || quoteEmojis) {
|
||||
@@ -410,16 +260,39 @@ if (!window.UserCommentSystem) {
|
||||
}
|
||||
|
||||
renderComment(c) {
|
||||
const itemKey = c.item_slug || c.slug || c.item_id;
|
||||
const timeAgo = this.timeAgo(c.created_at);
|
||||
const fullDate = new Date(c.created_at).toISOString();
|
||||
const content = this.renderCommentContent(c.content, itemKey);
|
||||
const content = this.renderCommentContent(c.content, c.item_id);
|
||||
|
||||
const bannerStyle = (c.banner_file && c.banner_file !== 'null')
|
||||
? `style="--author-banner: url('/a/${c.banner_file}'); --author-banner-position: ${c.banner_position === 'center' ? 'center top' : (c.banner_position || 'center top')}; --author-banner-size: ${(c.banner_size && c.banner_size !== 'cover') ? c.banner_size : '100% auto'}; --author-banner-repeat: no-repeat;"`
|
||||
: '';
|
||||
// Replicating the structure of comments.js but adapting for the list view
|
||||
// We add a header indicating which item this comment belongs to
|
||||
|
||||
return `<div class="comment" id="c${c.id}" ${bannerStyle}><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions"><button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button></div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
|
||||
return `
|
||||
<div class="comment" id="c${c.id}">
|
||||
<div class="comment-avatar">
|
||||
<a href="/${c.item_id}">
|
||||
<img src="/t/${c.item_id}.webp" alt="">
|
||||
</a>
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-header">
|
||||
<div class="comment-header-left">
|
||||
<span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span>
|
||||
</div>
|
||||
<span class="comment-time timeago" title="${fullDate}">${timeAgo}</span>
|
||||
</div>
|
||||
<div class="comment-content">${content}</div>
|
||||
<div class="comment-footer">
|
||||
<div class="comment-footer-right">
|
||||
<div class="comment-actions">
|
||||
${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a href="/${c.item_id}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
startLiveTimestamps() {
|
||||
@@ -460,24 +333,18 @@ if (!window.UserCommentSystem) {
|
||||
if (!unsafe) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = unsafe;
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initializer for AJAX and standard load
|
||||
window.initUserComments = () => {
|
||||
const container = document.getElementById('user-comments-container');
|
||||
if (container && !container.dataset.initialized) {
|
||||
container.dataset.initialized = 'true';
|
||||
// Prevent multiple instances if already running on this container
|
||||
if (document.getElementById('user-comments-container')) {
|
||||
new UserCommentSystem();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.initUserComments();
|
||||
});
|
||||
} else {
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
window.initUserComments();
|
||||
}
|
||||
});
|
||||
|
||||
+50
-643
@@ -5,32 +5,33 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
||||
<div class="v0ck_progress_filled"></div>
|
||||
<div class="v0ck_seek_marker"></div>
|
||||
</div>
|
||||
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play" tabindex="-1">
|
||||
<button class="v0ck_player_button v0ck_tplay v0ck_toggle" title="Play">
|
||||
<svg style="width: 20px; height: 20px;">
|
||||
<use id="v0ck_svg_play" href="${svg}#play"></use>
|
||||
<use id="v0ck_svg_pause" class="v0ck_hidden" href="${svg}#pause"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="v0ck_volume_group">
|
||||
<button class="v0ck_player_button v0ck_volume" tabindex="-1">
|
||||
<button class="v0ck_player_button v0ck_volume">
|
||||
<svg style="width: 20px; height: 20px;">
|
||||
<use id="v0ck_svg_volume_full" href="${svg}#volume_full"></use>
|
||||
<use id="v0ck_svg_volume_mid" class="v0ck_hidden" href="${svg}#volume_mid"></use>
|
||||
<use id="v0ck_svg_volume_mute" class="v0ck_hidden" href="${svg}#volume_mute"></use>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" tabindex="-1" />
|
||||
<input type="range" name="volume" min="0" max="1" step="0.01" value="1" />
|
||||
</div>
|
||||
<button class="v0ck_player_button v0ck_playtime" tabindex="-1">00:00 / 00:00</button>
|
||||
<button class="v0ck_player_button v0ck_playtime">00:00 / 00:00</button>
|
||||
<span style="flex: 30"></span>
|
||||
|
||||
<div class="v0ck_settings_container">
|
||||
<button class="v0ck_player_button v0ck_settings_btn" title="Settings" tabindex="-1">
|
||||
<button class="v0ck_player_button v0ck_settings_btn" title="Settings">
|
||||
<svg viewBox="0 0 24 24" style="width: 20px; height: 20px;">
|
||||
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.06-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61 l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41 h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87 C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.06,0.94l-2.03,1.58 c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54 c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96 c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6 s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="v0ck_settings_menu v0ck_hidden">
|
||||
<button id="toggleswf" class="v0ck_menu_item" title="Flash Yank">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>
|
||||
@@ -43,32 +44,22 @@ const tpl_player = (svg, size) => `<div class="v0ck_player_controls">
|
||||
<span class="v0ck_switch_label">Danmaku</span>
|
||||
<div id="toggledanmaku" class="v0ck_cool_switch" title="Toggle Danmaku comments"></div>
|
||||
</div>
|
||||
<button id="v0ck_download" class="v0ck_menu_item" title="Download File" tabindex="-1">Download${size ? ` (${size})` : ''}</button>
|
||||
<button id="v0ck_download" class="v0ck_menu_item" title="Download File">Download${size ? ` (${size})` : ''}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen" tabindex="-1">
|
||||
<button class="v0ck_player_button v0ck_toggle v0ck_fs_btn" title="Full Screen">
|
||||
<svg style="width: 20px; height: 20px;"><use id="v0ck_svg_fullscreen" href="${svg}#fullscreen"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="v0ck_loader v0ck_hidden"><div></div></div>
|
||||
<div class="v0ck_speed_indicator v0ck_hidden">
|
||||
<svg viewBox="0 0 24 24" style="width: 16px; height: 16px; fill: currentColor; display: inline-block; vertical-align: middle; margin-right: 6px;">
|
||||
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/>
|
||||
</svg>
|
||||
<span>2X Speed</span>
|
||||
</div>
|
||||
<div class="v0ck_overlay">
|
||||
<svg style="width: 60px; height: 60px;">
|
||||
<use href="${svg}#play"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="v0ck_hud v0ck_hidden">
|
||||
<svg>
|
||||
<use class="v0ck_hud_icon v0ck_hud_volume_full" href="${svg}#volume_full"></use>
|
||||
<use class="v0ck_hud_icon v0ck_hud_volume_mid v0ck_hidden" href="${svg}#volume_mid"></use>
|
||||
<use class="v0ck_hud_icon v0ck_hud_volume_mute v0ck_hidden" href="${svg}#volume_mute"></use>
|
||||
</svg>
|
||||
<svg><use class="v0ck_hud_icon" href="${svg}#volume_full"></use></svg>
|
||||
<div class="v0ck_hud_bar_container">
|
||||
<div class="v0ck_hud_bar"></div>
|
||||
</div>
|
||||
@@ -82,15 +73,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,13 +107,14 @@ 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);
|
||||
}
|
||||
|
||||
|
||||
if (!isMobile) {
|
||||
parent.addEventListener('mouseenter', () => parent.classList.add("v0ck_hover"));
|
||||
parent.addEventListener('mouseleave', () => parent.classList.remove("v0ck_hover"));
|
||||
}
|
||||
|
||||
if (!document.querySelector('link[href^="/s/css/v0ck.css"]')) {
|
||||
document.head.insertAdjacentHTML("beforeend", `<link rel="stylesheet" href="/s/css/v0ck.css">`); // inject css
|
||||
@@ -138,73 +126,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');
|
||||
@@ -224,52 +157,18 @@ class v0ck {
|
||||
const playtime = player.querySelector('.v0ck_playtime');
|
||||
const overlay = player.querySelector('.v0ck_overlay');
|
||||
const volumeButton = player.querySelector('.v0ck_volume');
|
||||
const volumeSymbols = volumeButton.querySelectorAll('use');
|
||||
const volumeSymbols = volumeButton.querySelectorAll('.v0ck use');
|
||||
|
||||
const defaultVolume = 0.5;
|
||||
let mousedown = false;
|
||||
let _volume;
|
||||
|
||||
// Hold to speedup (2x) states
|
||||
let speedUpTimeout;
|
||||
let isSpeedingUp = false;
|
||||
let restorePlaybackRate = 1;
|
||||
let ignoreNextClick = false;
|
||||
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)
|
||||
|
||||
function handleVolumeButton(vol) {
|
||||
[...volumeSymbols].forEach(s => s.classList.add('v0ck_hidden'));
|
||||
let targetId = 'v0ck_svg_volume_full';
|
||||
if (vol === 0) {
|
||||
targetId = 'v0ck_svg_volume_mute';
|
||||
} else if (vol <= 0.5) {
|
||||
targetId = 'v0ck_svg_volume_mid';
|
||||
}
|
||||
const activeSymbol = [...volumeSymbols].find(s => s.id === targetId);
|
||||
if (activeSymbol) {
|
||||
activeSymbol.classList.remove('v0ck_hidden');
|
||||
[...volumeSymbols].forEach(s => !s.classList.contains('v0ck_hidden') ? s.classList.add('v0ck_hidden') : null);
|
||||
switch (true) {
|
||||
case (vol === 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mute")[0].classList.toggle('v0ck_hidden'); break;
|
||||
case (vol <= 0.5 && vol > 0): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_mid")[0].classList.toggle('v0ck_hidden'); break;
|
||||
case (vol > 0.5): [...volumeSymbols].filter(s => s.id === "v0ck_svg_volume_full")[0].classList.toggle('v0ck_hidden'); break;
|
||||
}
|
||||
localStorage.setItem("volume", vol);
|
||||
}
|
||||
@@ -277,21 +176,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 +243,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
|
||||
@@ -374,38 +262,21 @@ class v0ck {
|
||||
player.classList.toggle('v0ck_fullscreen', !!isThisPlayerFS);
|
||||
}
|
||||
|
||||
// Mobile: on touchstart, record whether controls were hidden so the
|
||||
// subsequent click can decide whether to show controls or toggle play.
|
||||
player.addEventListener('touchstart', () => {
|
||||
if (isMobile) {
|
||||
controlsJustShown = !player.classList.contains('v0ck_hover');
|
||||
}
|
||||
}, { passive: true, capture: true });
|
||||
|
||||
player.addEventListener('click', e => {
|
||||
if (ignoreNextClick) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
ignoreNextClick = false;
|
||||
return;
|
||||
}
|
||||
const path = e.path || (e.composedPath && e.composedPath());
|
||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||
if (!isControls) {
|
||||
if (isMobile && controlsJustShown) {
|
||||
// First tap: controls were just revealed by this touch — don't toggle play
|
||||
controlsJustShown = false;
|
||||
setHover(true);
|
||||
if (isMobile && !player.classList.contains('v0ck_hover')) {
|
||||
player.classList.add('v0ck_hover');
|
||||
return;
|
||||
}
|
||||
controlsJustShown = false;
|
||||
togglePlay(e);
|
||||
}
|
||||
});
|
||||
toggle.addEventListener('click', togglePlay);
|
||||
overlay.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
setHover(true);
|
||||
player.classList.add('v0ck_hover');
|
||||
togglePlay();
|
||||
});
|
||||
video.addEventListener('play', updatePlayIcon);
|
||||
@@ -430,18 +301,9 @@ class v0ck {
|
||||
}
|
||||
});
|
||||
|
||||
// Loader events with debounce to prevent flicker on fast starts/seeking
|
||||
let loaderTimeout = null;
|
||||
const showLoader = () => {
|
||||
if (loaderTimeout) clearTimeout(loaderTimeout);
|
||||
loaderTimeout = setTimeout(() => {
|
||||
loader.classList.remove('v0ck_hidden');
|
||||
}, 250);
|
||||
};
|
||||
const hideLoader = () => {
|
||||
if (loaderTimeout) clearTimeout(loaderTimeout);
|
||||
loader.classList.add('v0ck_hidden');
|
||||
};
|
||||
// Loader events
|
||||
const showLoader = () => loader.classList.remove('v0ck_hidden');
|
||||
const hideLoader = () => loader.classList.add('v0ck_hidden');
|
||||
video.addEventListener('waiting', showLoader);
|
||||
video.addEventListener('stalled', showLoader);
|
||||
video.addEventListener('canplay', hideLoader);
|
||||
@@ -463,21 +325,11 @@ class v0ck {
|
||||
hud.classList.remove('v0ck_hidden');
|
||||
hudBar.style.width = `${vol * 100}%`;
|
||||
|
||||
// Update HUD icon based on volume by toggling hidden class
|
||||
const hudSymbols = hud.querySelectorAll('.v0ck_hud_icon');
|
||||
hudSymbols.forEach(s => s.classList.add('v0ck_hidden'));
|
||||
|
||||
let targetClass = 'v0ck_hud_volume_full';
|
||||
if (vol === 0) {
|
||||
targetClass = 'v0ck_hud_volume_mute';
|
||||
} else if (vol <= 0.5) {
|
||||
targetClass = 'v0ck_hud_volume_mid';
|
||||
}
|
||||
|
||||
const activeSymbol = [...hudSymbols].find(s => s.classList.contains(targetClass));
|
||||
if (activeSymbol) {
|
||||
activeSymbol.classList.remove('v0ck_hidden');
|
||||
}
|
||||
// Update HUD icon based on volume
|
||||
let icon = 'volume_full';
|
||||
if (vol === 0) icon = 'volume_mute';
|
||||
else if (vol <= 0.5) icon = 'volume_mid';
|
||||
hudIcon.setAttribute('href', `${hudIcon.getAttribute('href').split('#')[0]}#${icon}`);
|
||||
|
||||
clearTimeout(hudTimer);
|
||||
hudTimer = setTimeout(() => hud.classList.add('v0ck_hidden'), 1000);
|
||||
@@ -496,7 +348,7 @@ class v0ck {
|
||||
startY = touch.clientY;
|
||||
startVol = video.volume;
|
||||
}
|
||||
}, { passive: false });
|
||||
}, { passive: true });
|
||||
|
||||
player.addEventListener('touchmove', e => {
|
||||
if (!isMobile || !isRightSide || gestureType === 'other') return;
|
||||
@@ -509,8 +361,6 @@ class v0ck {
|
||||
if (gestureType === 'none') {
|
||||
if (dy > dx && dy > 5) {
|
||||
gestureType = 'volume';
|
||||
clearTimeout(speedUpTimeout);
|
||||
endSpeedUp();
|
||||
} else if (dx > dy && dx > 5) {
|
||||
gestureType = 'other'; // Probably seeking or horizontal swipe
|
||||
return;
|
||||
@@ -520,9 +370,6 @@ class v0ck {
|
||||
}
|
||||
|
||||
if (gestureType === 'volume') {
|
||||
clearTimeout(speedUpTimeout);
|
||||
endSpeedUp();
|
||||
|
||||
const deltaY = startY - touch.clientY; // swipe up is positive
|
||||
const sensitivity = 200; // pixels for 0 to 1 range (reverted to original)
|
||||
let newVol = startVol + (deltaY / sensitivity);
|
||||
@@ -537,86 +384,9 @@ class v0ck {
|
||||
if (e.cancelable) e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
// Desktop mouse volume gesture support (clicking and dragging vertically on the player)
|
||||
let activeMouseGesture = false;
|
||||
|
||||
player.addEventListener('mousedown', e => {
|
||||
if (isMobile) return;
|
||||
if (e.button !== 0) return;
|
||||
const path = e.path || (e.composedPath && e.composedPath());
|
||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||
if (isControls) return;
|
||||
|
||||
gestureType = 'none';
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
startVol = video.volume;
|
||||
activeMouseGesture = true;
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', e => {
|
||||
if (!activeMouseGesture || gestureType === 'other') return;
|
||||
|
||||
const dx = Math.abs(e.clientX - startX);
|
||||
const dy = Math.abs(e.clientY - startY);
|
||||
|
||||
if (gestureType === 'none') {
|
||||
if (dy > dx && dy > 5) {
|
||||
gestureType = 'volume';
|
||||
clearTimeout(speedUpTimeout);
|
||||
endSpeedUp();
|
||||
} else if (dx > dy && dx > 5) {
|
||||
gestureType = 'other';
|
||||
return;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (gestureType === 'volume') {
|
||||
clearTimeout(speedUpTimeout);
|
||||
endSpeedUp();
|
||||
ignoreNextClick = true;
|
||||
|
||||
const deltaY = startY - e.clientY; // swipe up is positive
|
||||
const sensitivity = 200;
|
||||
let newVol = startVol + (deltaY / sensitivity);
|
||||
newVol = Math.max(0, Math.min(1, newVol));
|
||||
|
||||
video.volume = newVol;
|
||||
volumeSlider.value = newVol;
|
||||
_volume = newVol;
|
||||
handleVolumeButton(newVol);
|
||||
showHUD(newVol);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (activeMouseGesture) {
|
||||
activeMouseGesture = false;
|
||||
setTimeout(() => {
|
||||
ignoreNextClick = false;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
|
||||
skipButtons.forEach(button => button.addEventListener('click', skip));
|
||||
ranges.forEach(range => range.addEventListener('change', handleRangeUpdate));
|
||||
ranges.forEach(range => range.addEventListener('input', handleRangeUpdate));
|
||||
ranges.forEach(range => range.addEventListener('mousemove', handleRangeUpdate));
|
||||
|
||||
// Prevent touch events on the volume slider from bubbling to the player container (avoiding gesture conflicts and page scrolls)
|
||||
if (volumeSlider) {
|
||||
['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(evt => {
|
||||
volumeSlider.addEventListener(evt, e => {
|
||||
e.stopPropagation();
|
||||
}, { passive: false });
|
||||
});
|
||||
}
|
||||
|
||||
progress.addEventListener('mousedown', scrub);
|
||||
progress.addEventListener('touchstart', scrub, { passive: false });
|
||||
progress.addEventListener('touchmove', scrub, { passive: false });
|
||||
@@ -624,225 +394,19 @@ class v0ck {
|
||||
document.addEventListener('fullscreenchange', toggleFullScreenClasses);
|
||||
toggleFullScreenClasses(); // Check initial state (important for transitions)
|
||||
|
||||
let keyHoldTimer = null;
|
||||
let isHoldingKey = false;
|
||||
let heldKey = null;
|
||||
let reverseInterval = null;
|
||||
|
||||
function handleKeyDown(e) {
|
||||
if (!document.body.contains(video)) {
|
||||
cleanupKeyboardListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// Block interaction if content warning is visible
|
||||
const cwModal = document.getElementById('content-warning-modal');
|
||||
if (cwModal && cwModal.style.display !== 'none') return;
|
||||
|
||||
// Don't fire if user is typing in an input, textarea or editing content
|
||||
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.isContentEditable) return;
|
||||
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
let newVol = video.volume;
|
||||
if (e.key === "ArrowUp") {
|
||||
newVol = Math.min(1, newVol + 0.05);
|
||||
} else {
|
||||
newVol = Math.max(0, newVol - 0.05);
|
||||
}
|
||||
video.volume = newVol;
|
||||
if (volumeSlider) {
|
||||
volumeSlider.value = newVol;
|
||||
}
|
||||
_volume = newVol;
|
||||
handleVolumeButton(newVol);
|
||||
showHUD(newVol);
|
||||
}
|
||||
|
||||
if (e.key === "." || e.key === ",") {
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
e.preventDefault();
|
||||
if (!Number.isFinite(video.currentTime)) return;
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
|
||||
const direction = e.key === "." ? 1 : -1;
|
||||
|
||||
if (heldKey && heldKey !== e.key) return;
|
||||
const isCurrentHold = heldKey === e.key;
|
||||
|
||||
if (video.paused || isCurrentHold) {
|
||||
if (!e.repeat) {
|
||||
heldKey = e.key;
|
||||
isHoldingKey = false;
|
||||
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
||||
keyHoldTimer = setTimeout(() => {
|
||||
isHoldingKey = true;
|
||||
if (heldKey === ".") {
|
||||
video.play();
|
||||
} else if (heldKey === ",") {
|
||||
if (reverseInterval) clearInterval(reverseInterval);
|
||||
reverseInterval = setInterval(() => {
|
||||
if (video.currentTime > 0) {
|
||||
video.currentTime = Math.max(0, video.currentTime - 0.04);
|
||||
} else {
|
||||
clearInterval(reverseInterval);
|
||||
reverseInterval = null;
|
||||
}
|
||||
}, 40);
|
||||
}
|
||||
}, 200);
|
||||
} else {
|
||||
if (keyHoldTimer && !isHoldingKey) {
|
||||
clearTimeout(keyHoldTimer);
|
||||
keyHoldTimer = null;
|
||||
isHoldingKey = true;
|
||||
if (heldKey === ".") {
|
||||
video.play();
|
||||
} else if (heldKey === ",") {
|
||||
if (reverseInterval) clearInterval(reverseInterval);
|
||||
reverseInterval = setInterval(() => {
|
||||
if (video.currentTime > 0) {
|
||||
video.currentTime = Math.max(0, video.currentTime - 0.04);
|
||||
} else {
|
||||
clearInterval(reverseInterval);
|
||||
reverseInterval = null;
|
||||
}
|
||||
}, 40);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * 5));
|
||||
}
|
||||
showControlsAndReset();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(e) {
|
||||
if (!document.body.contains(video)) {
|
||||
cleanupKeyboardListeners();
|
||||
return;
|
||||
}
|
||||
if (e.key === "." || e.key === ",") {
|
||||
if (heldKey === e.key) {
|
||||
if (keyHoldTimer) {
|
||||
clearTimeout(keyHoldTimer);
|
||||
keyHoldTimer = null;
|
||||
}
|
||||
if (isHoldingKey) {
|
||||
if (heldKey === ".") {
|
||||
video.pause();
|
||||
} else if (heldKey === ",") {
|
||||
if (reverseInterval) {
|
||||
clearInterval(reverseInterval);
|
||||
reverseInterval = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (Number.isFinite(video.currentTime)) {
|
||||
const duration = Number.isFinite(video.duration) ? video.duration : video.currentTime;
|
||||
const direction = heldKey === "." ? 1 : -1;
|
||||
const frameTime = 1 / 30;
|
||||
video.currentTime = Math.max(0, Math.min(duration, video.currentTime + direction * frameTime));
|
||||
}
|
||||
}
|
||||
heldKey = null;
|
||||
isHoldingKey = false;
|
||||
showControlsAndReset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (!document.body.contains(video)) {
|
||||
cleanupKeyboardListeners();
|
||||
return;
|
||||
}
|
||||
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
||||
if (reverseInterval) clearInterval(reverseInterval);
|
||||
keyHoldTimer = null;
|
||||
reverseInterval = null;
|
||||
if (isHoldingKey) {
|
||||
if (heldKey === ".") {
|
||||
video.pause();
|
||||
}
|
||||
}
|
||||
heldKey = null;
|
||||
isHoldingKey = false;
|
||||
}
|
||||
|
||||
function cleanupKeyboardListeners() {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
document.removeEventListener('keyup', handleKeyUp);
|
||||
window.removeEventListener('blur', handleBlur);
|
||||
if (keyHoldTimer) clearTimeout(keyHoldTimer);
|
||||
if (reverseInterval) clearInterval(reverseInterval);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keyup', handleKeyUp);
|
||||
window.addEventListener('blur', handleBlur);
|
||||
|
||||
video.volume = _volume = volumeSlider.value = +(localStorage.getItem('volume') ?? defaultVolume);
|
||||
handleVolumeButton(video.volume);
|
||||
|
||||
const mediaObj = player.closest('.media-object');
|
||||
let isBlurredDetail = false;
|
||||
if (mediaObj && localStorage.getItem('blurDetail') !== 'false') {
|
||||
const mode = mediaObj.getAttribute('data-mode');
|
||||
const blurNsfw = localStorage.getItem('blurNsfw') === 'true';
|
||||
const blurNsfl = localStorage.getItem('blurNsfl') === 'true';
|
||||
const blurSfw = localStorage.getItem('blurSfw') === 'true';
|
||||
const blurUntagged = localStorage.getItem('blurUntagged') === 'true';
|
||||
|
||||
let shouldBlurThis = false;
|
||||
if (mode === 'nsfw') shouldBlurThis = blurNsfw;
|
||||
else if (mode === 'nsfl') shouldBlurThis = blurNsfl;
|
||||
else if (mode === 'sfw') shouldBlurThis = blurSfw;
|
||||
else if (mode === 'untagged') shouldBlurThis = blurUntagged;
|
||||
|
||||
if (shouldBlurThis && !mediaObj.classList.contains('revealed')) {
|
||||
isBlurredDetail = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Upgrade preload from 'metadata' → 'auto' for video elements so the browser
|
||||
// starts buffering data immediately during player setup. With just 'metadata',
|
||||
// play() triggers a brand-new range request and the user sees the loader spinner
|
||||
// until enough data arrives. 'auto' closes that gap for both autoplay and manual play.
|
||||
if (video.tagName === 'VIDEO' && video.preload !== 'auto') {
|
||||
video.preload = 'auto';
|
||||
}
|
||||
|
||||
// Attempt autoplay and show overlay if blocked
|
||||
const shouldAutoplay = !isBlurredDetail && window.f0ckSession?.disable_autoplay !== true;
|
||||
const shouldAutoplay = 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 +456,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 +467,7 @@ class v0ck {
|
||||
}
|
||||
|
||||
if (isMobile && !isInsidePlayer && !isFlashYankUI) {
|
||||
setHover(false);
|
||||
player.classList.remove('v0ck_hover');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -978,162 +541,6 @@ class v0ck {
|
||||
else toggleDanmakuSwitch.addEventListener('click', handleDanmakuToggle);
|
||||
}
|
||||
}
|
||||
|
||||
// Controls auto-hide logic (auto hide controls after 2.5 seconds of inactivity)
|
||||
let controlsTimer;
|
||||
// True while the cursor is physically inside .v0ck_player_controls
|
||||
let mouseIsOverControls = false;
|
||||
|
||||
// Track real mouse position at document level — completely independent of
|
||||
// any element animation or synthetic events.
|
||||
let docMouseX = -1;
|
||||
let docMouseY = -1;
|
||||
const onDocMouseMove = (e) => {
|
||||
docMouseX = e.clientX;
|
||||
docMouseY = e.clientY;
|
||||
};
|
||||
document.addEventListener('mousemove', onDocMouseMove, { passive: true });
|
||||
|
||||
function resetControlsTimer() {
|
||||
clearTimeout(controlsTimer);
|
||||
// Never schedule auto-hide while the user is mousing over the controls bar
|
||||
if (mouseIsOverControls) return;
|
||||
const isFullscreen = player.classList.contains('v0ck_fullscreen');
|
||||
if (!video.paused || isFullscreen) {
|
||||
controlsTimer = setTimeout(() => {
|
||||
setHover(false);
|
||||
if (settingsMenu && !settingsMenu.classList.contains('v0ck_hidden')) {
|
||||
settingsMenu.classList.add('v0ck_hidden');
|
||||
document.dispatchEvent(new CustomEvent('v0ck_settings_closed'));
|
||||
}
|
||||
}, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
function showControlsAndReset(e) {
|
||||
// Ignore synthetic pointer events fired by the browser during CSS animations
|
||||
// (element shifts under stationary cursor). We compare against docMouseX/Y
|
||||
// which is only ever updated by genuine user mouse movement.
|
||||
if (e && e.clientX !== undefined && e.clientY !== undefined) {
|
||||
if (e.clientX === docMouseX && e.clientY === docMouseY &&
|
||||
player.classList.contains('v0ck_hover')) {
|
||||
// Coordinates unchanged and controls already visible — synthetic event, skip.
|
||||
return;
|
||||
}
|
||||
docMouseX = e.clientX;
|
||||
docMouseY = e.clientY;
|
||||
}
|
||||
setHover(true);
|
||||
resetControlsTimer();
|
||||
}
|
||||
|
||||
// Events that should show controls and reset/extend the auto-hide timer
|
||||
const resetEvents = ['touchstart', 'touchmove', 'touchend', 'click', 'mousemove', 'mouseenter'];
|
||||
resetEvents.forEach(evt => {
|
||||
player.addEventListener(evt, showControlsAndReset, { capture: true, passive: true });
|
||||
});
|
||||
|
||||
// While hovering the controls bar: freeze the auto-hide timer completely.
|
||||
const controlsBar = player.querySelector('.v0ck_player_controls');
|
||||
if (controlsBar) {
|
||||
controlsBar.addEventListener('mouseenter', () => {
|
||||
mouseIsOverControls = true;
|
||||
clearTimeout(controlsTimer); // cancel any countdown already in progress
|
||||
}, { passive: true });
|
||||
controlsBar.addEventListener('mouseleave', (e) => {
|
||||
mouseIsOverControls = false;
|
||||
// Only restart the timer if the cursor re-entered the player area
|
||||
// (not when it left the player entirely — the player mouseleave handles that)
|
||||
const r = player.getBoundingClientRect();
|
||||
const stillInPlayer = e.clientX >= r.left && e.clientX <= r.right &&
|
||||
e.clientY >= r.top && e.clientY <= r.bottom;
|
||||
if (stillInPlayer) resetControlsTimer();
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Hide when cursor leaves the player entirely.
|
||||
// NO capture:true — that would incorrectly intercept mouseleave events from
|
||||
// child elements (e.g. the progress bar animating away from the cursor).
|
||||
// Without capture, this only fires when the mouse truly leaves .v0ck itself.
|
||||
player.addEventListener('mouseleave', (e) => {
|
||||
const r = player.getBoundingClientRect();
|
||||
// Grace zone: the controls bar peeks ~3px below the player when hidden.
|
||||
// If the cursor is still in that strip, don't hide.
|
||||
if (e.clientX >= r.left && e.clientX <= r.right &&
|
||||
e.clientY > r.bottom && e.clientY <= r.bottom + 8) {
|
||||
return;
|
||||
}
|
||||
// If the cursor moved into the controls bar itself (or any child of it),
|
||||
// keep the controls visible — the user is interacting with them.
|
||||
const controls = player.querySelector('.v0ck_player_controls');
|
||||
if (controls && e.relatedTarget && controls.contains(e.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
docMouseX = -1;
|
||||
docMouseY = -1;
|
||||
setHover(false);
|
||||
clearTimeout(controlsTimer);
|
||||
});
|
||||
|
||||
video.addEventListener('play', resetControlsTimer);
|
||||
video.addEventListener('playing', resetControlsTimer);
|
||||
video.addEventListener('pause', () => clearTimeout(controlsTimer));
|
||||
|
||||
// Speedup 2x on Hold logic
|
||||
function startSpeedUp(e) {
|
||||
if (e.type === 'mousedown' && isMobile) return;
|
||||
// Only left mouse click or touch triggers speedup
|
||||
if (e.type === 'mousedown' && e.button !== 0) return;
|
||||
|
||||
// Don't speed up if clicking on controls or settings panel
|
||||
const path = e.path || (e.composedPath && e.composedPath());
|
||||
const isControls = !!path.filter(f => f.classList?.contains('v0ck_player_controls')).length;
|
||||
if (isControls) return;
|
||||
|
||||
clearTimeout(speedUpTimeout);
|
||||
speedUpTimeout = setTimeout(() => {
|
||||
isSpeedingUp = true;
|
||||
ignoreNextClick = true;
|
||||
wasPausedWhenStarted = video.paused;
|
||||
restorePlaybackRate = video.playbackRate;
|
||||
video.playbackRate = 2.0;
|
||||
|
||||
if (wasPausedWhenStarted) {
|
||||
video.play();
|
||||
}
|
||||
|
||||
if (speedIndicator) {
|
||||
speedIndicator.classList.remove('v0ck_hidden');
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function endSpeedUp(e) {
|
||||
clearTimeout(speedUpTimeout);
|
||||
if (isSpeedingUp) {
|
||||
isSpeedingUp = false;
|
||||
video.playbackRate = restorePlaybackRate;
|
||||
if (wasPausedWhenStarted) {
|
||||
video.pause();
|
||||
wasPausedWhenStarted = false;
|
||||
}
|
||||
if (speedIndicator) {
|
||||
speedIndicator.classList.add('v0ck_hidden');
|
||||
}
|
||||
// Brief timeout before allowing normal clicking again to bypass the immediate click event
|
||||
setTimeout(() => {
|
||||
ignoreNextClick = false;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
player.addEventListener('mousedown', startSpeedUp);
|
||||
player.addEventListener('touchstart', startSpeedUp, { passive: true });
|
||||
player.addEventListener('mouseup', endSpeedUp);
|
||||
player.addEventListener('mouseleave', endSpeedUp);
|
||||
player.addEventListener('touchend', endSpeedUp);
|
||||
player.addEventListener('touchcancel', endSpeedUp);
|
||||
|
||||
this.toggleFullScreen = toggleFullScreen;
|
||||
this.enterFullScreen = enterFullScreen;
|
||||
|
||||
|
||||
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,162 +0,0 @@
|
||||
/**
|
||||
* Backfill script: populate width/height for existing image and video items.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/backfill_dimensions.mjs
|
||||
* node scripts/backfill_dimensions.mjs --dry-run (print without writing)
|
||||
* node scripts/backfill_dimensions.mjs --limit 500 (process first N items)
|
||||
*
|
||||
* Skips: audio, flash, PDF, YouTube items (width/height left as NULL).
|
||||
* Skips: items where the physical file is missing.
|
||||
* Safe to run multiple times — only processes rows where width IS NULL.
|
||||
*/
|
||||
|
||||
import { spawn as _spawn } from 'child_process';
|
||||
import db from '../src/inc/sql.mjs';
|
||||
import cfg from '../src/inc/config.mjs';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
|
||||
const isDryRun = process.argv.includes('--dry-run');
|
||||
const limitArg = process.argv.indexOf('--limit');
|
||||
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
|
||||
const BATCH = 50;
|
||||
|
||||
// ---- tiny spawn helper (no shell) ----
|
||||
const spawn = (cmd, args, opts = {}) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const child = _spawn(cmd, args, opts);
|
||||
let out = '';
|
||||
let err = '';
|
||||
child.stdout?.on('data', d => { out += d; });
|
||||
child.stderr?.on('data', d => { err += d; });
|
||||
child.on('close', code => {
|
||||
if (code !== 0 && !opts.ignoreExitCode) {
|
||||
const e = new Error(`${cmd} exited ${code}`);
|
||||
e.stderr = err;
|
||||
return reject(e);
|
||||
}
|
||||
resolve(out);
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
|
||||
// ---- probe helpers ----
|
||||
async function getDimsImage(filePath) {
|
||||
try {
|
||||
// magick identify -format "%wx%h" reports raw pixel dimensions
|
||||
const out = await spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { ignoreExitCode: true });
|
||||
// Take the first line (multi-frame GIF etc. may have many)
|
||||
const line = out.trim().split('\n')[0];
|
||||
const match = line.match(/^(\d+)x(\d+)$/);
|
||||
if (match) return { w: parseInt(match[1], 10), h: parseInt(match[2], 10) };
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getDimsVideo(filePath) {
|
||||
try {
|
||||
const out = await spawn('ffprobe', [
|
||||
'-v', 'error', '-select_streams', 'v:0',
|
||||
'-show_entries', 'stream=width,height',
|
||||
'-of', 'csv=p=0', filePath
|
||||
], { ignoreExitCode: true });
|
||||
const parts = out.trim().split(',');
|
||||
if (parts.length < 2) return null;
|
||||
const w = parseInt(parts[0], 10);
|
||||
const h = parseInt(parts[1], 10);
|
||||
if (w > 0 && h > 0) return { w, h };
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- main ----
|
||||
async function run() {
|
||||
console.log(`[BACKFILL] Starting dimension backfill${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (limit: ${limit})` : ''}`);
|
||||
|
||||
// Count pending
|
||||
const [{ count }] = await db`
|
||||
SELECT count(*) FROM items
|
||||
WHERE width IS NULL
|
||||
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
|
||||
`;
|
||||
const total = parseInt(count, 10);
|
||||
const toProcess = limit ? Math.min(total, limit) : total;
|
||||
console.log(`[BACKFILL] ${total} items need backfill${limit ? `, processing up to ${toProcess}` : ''}`);
|
||||
|
||||
let processed = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
// NOTE: Always query at OFFSET 0 — updated rows leave the WHERE width IS NULL set,
|
||||
// so the result set shrinks naturally each batch. Using a moving OFFSET would skip
|
||||
// as many rows as were just updated (pagination-with-mutation bug).
|
||||
while (processed < toProcess) {
|
||||
const batchSize = limit ? Math.min(BATCH, toProcess - processed) : BATCH;
|
||||
const rows = await db`
|
||||
SELECT id, dest, mime FROM items
|
||||
WHERE width IS NULL
|
||||
AND (mime LIKE 'image/%' OR (mime LIKE 'video/%' AND mime != 'video/youtube'))
|
||||
ORDER BY id DESC
|
||||
LIMIT ${batchSize}
|
||||
`;
|
||||
|
||||
if (rows.length === 0) break;
|
||||
|
||||
for (const row of rows) {
|
||||
if (processed >= toProcess) break;
|
||||
processed++;
|
||||
|
||||
const filePath = path.join(cfg.paths.b, row.dest);
|
||||
|
||||
// Check file exists (may be deleted/purged)
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
console.log(`[BACKFILL] SKIP #${row.id} — file missing: ${row.dest}`);
|
||||
skipped++;
|
||||
// Mark with 0 so it doesn't re-appear in future runs
|
||||
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
let dims = null;
|
||||
try {
|
||||
if (row.mime.startsWith('image/')) {
|
||||
dims = await getDimsImage(filePath);
|
||||
} else if (row.mime.startsWith('video/')) {
|
||||
dims = await getDimsVideo(filePath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[BACKFILL] PROBE ERROR #${row.id}: ${e.message}`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dims) {
|
||||
console.log(`[BACKFILL] SKIP #${row.id} — no dimensions found (${row.mime})`);
|
||||
skipped++;
|
||||
// Mark with 0 so it doesn't re-appear in future runs and cause infinite loops
|
||||
if (!isDryRun) await db`UPDATE items SET width = 0, height = 0 WHERE id = ${row.id}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[BACKFILL] ${isDryRun ? '[DRY]' : 'UPDATE'} #${row.id} → ${dims.w}×${dims.h}`);
|
||||
if (!isDryRun) {
|
||||
await db`UPDATE items SET width = ${dims.w}, height = ${dims.h} WHERE id = ${row.id}`;
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
console.log(`[BACKFILL] Progress: ${processed} / ${toProcess} (updated=${updated}, skipped=${skipped}, failed=${failed})`);
|
||||
}
|
||||
|
||||
console.log(`[BACKFILL] Done. updated=${updated}, skipped=${skipped}, failed=${failed}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('[BACKFILL] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
import db from '../src/inc/sql.mjs';
|
||||
import cfg from '../src/inc/config.mjs';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import crypto from 'crypto';
|
||||
|
||||
const isDryRun = process.argv.includes('--dry-run');
|
||||
const limitArg = process.argv.indexOf('--limit');
|
||||
const limit = limitArg !== -1 ? parseInt(process.argv[limitArg + 1], 10) : null;
|
||||
const BATCH_SIZE = 1000;
|
||||
|
||||
async function run() {
|
||||
console.log(`[BACKFILL] Starting long UUID migration & backfill script${isDryRun ? ' (DRY RUN)' : ''}${limit ? ` (Limit: ${limit})` : ''}`);
|
||||
|
||||
if (!isDryRun) {
|
||||
console.log('[MIGRATION] Applying database schema migrations...');
|
||||
// Drop views first because they depend on columns we want to alter
|
||||
await db`DROP VIEW IF EXISTS public.items_sfw CASCADE`;
|
||||
await db`DROP VIEW IF EXISTS public.items_li CASCADE`;
|
||||
|
||||
// Alter dest columns in items and comment_files
|
||||
await db`ALTER TABLE public.items ALTER COLUMN dest TYPE character varying(255)`;
|
||||
await db`ALTER TABLE public.comment_files ALTER COLUMN dest TYPE character varying(255)`;
|
||||
|
||||
// Recreate public.items_li view
|
||||
await db`
|
||||
CREATE OR REPLACE VIEW public.items_li AS
|
||||
SELECT items.id,
|
||||
items.src,
|
||||
items.dest,
|
||||
items.mime,
|
||||
items.size,
|
||||
items.checksum,
|
||||
items.username,
|
||||
items.userchannel,
|
||||
items.usernetwork,
|
||||
items.stamp
|
||||
FROM ((public.items
|
||||
JOIN public.tags_assign ta1 ON (((ta1.tag_id = 1) AND (ta1.item_id = items.id))))
|
||||
JOIN public.tags_assign ta2 ON (((NOT (ta2.tag_id IN ( SELECT tags_nsfp.id
|
||||
FROM public.tags_nsfp))) AND (ta2.item_id = items.id))))
|
||||
WHERE items.active
|
||||
GROUP BY items.id;
|
||||
`;
|
||||
|
||||
// Recreate public.items_sfw view
|
||||
await db`
|
||||
CREATE OR REPLACE VIEW public.items_sfw AS
|
||||
SELECT ( SELECT
|
||||
CASE
|
||||
WHEN (tags_assign.tag_id > 0) THEN tags_assign.tag_id
|
||||
ELSE 0
|
||||
END AS "case"
|
||||
FROM public.tags_assign
|
||||
WHERE ((tags_assign.tag_id = ANY (ARRAY[1, 2])) AND (tags_assign.item_id = items.id))) AS sfw,
|
||||
( SELECT
|
||||
CASE
|
||||
WHEN (tags_assign.tag_id > 0) THEN 1
|
||||
ELSE 0
|
||||
END AS "case"
|
||||
FROM public.tags_assign
|
||||
WHERE ((tags_assign.tag_id IN ( SELECT tags_nsfp.id
|
||||
FROM public.tags_nsfp)) AND (tags_assign.item_id = items.id))
|
||||
LIMIT 1) AS nsfp,
|
||||
id,
|
||||
src,
|
||||
dest,
|
||||
mime,
|
||||
size,
|
||||
checksum,
|
||||
username,
|
||||
userchannel,
|
||||
usernetwork,
|
||||
stamp,
|
||||
active
|
||||
FROM public.items;
|
||||
`;
|
||||
console.log('[MIGRATION] Schema changes applied and views successfully recreated.');
|
||||
}
|
||||
|
||||
// Map to keep track of all renamed files: old_filename -> new_filename
|
||||
const renameMap = new Map();
|
||||
|
||||
// 1. Process items
|
||||
console.log('[BACKFILL] Fetching items to process...');
|
||||
const items = await db`SELECT id, dest, mime FROM items`;
|
||||
console.log(`[BACKFILL] Found ${items.length} items in DB.`);
|
||||
|
||||
let itemsProcessed = 0;
|
||||
let itemsRenamedCount = 0;
|
||||
|
||||
for (const item of items) {
|
||||
if (limit && itemsProcessed >= limit) break;
|
||||
itemsProcessed++;
|
||||
|
||||
// YouTube embeds store dest as "yt:VIDEO_ID" — not a real file, skip entirely
|
||||
if (item.mime === 'video/youtube') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ext = path.extname(item.dest);
|
||||
const base = path.basename(item.dest, ext);
|
||||
|
||||
// If name is already 48 characters long, skip
|
||||
if (base.length === 48) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newUuid = crypto.randomBytes(24).toString('hex');
|
||||
const newDest = `${newUuid}${ext}`;
|
||||
renameMap.set(item.dest, newDest);
|
||||
|
||||
// Physical files could be in active, pending or deleted folders
|
||||
const pathsToCheck = [
|
||||
path.join(cfg.paths.b, item.dest),
|
||||
path.join(cfg.paths.pending, 'b', item.dest),
|
||||
path.join(cfg.paths.deleted, 'b', item.dest)
|
||||
];
|
||||
|
||||
let foundPath = null;
|
||||
for (const p of pathsToCheck) {
|
||||
try {
|
||||
const stat = await fs.lstat(p);
|
||||
if (!stat.isSymbolicLink()) {
|
||||
foundPath = p;
|
||||
break;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (foundPath) {
|
||||
const newPath = path.join(path.dirname(foundPath), newDest);
|
||||
console.log(`[BACKFILL] Renaming item file: ${foundPath} -> ${newPath}`);
|
||||
if (!isDryRun) {
|
||||
await fs.rename(foundPath, newPath);
|
||||
}
|
||||
} else {
|
||||
console.log(`[BACKFILL] [WARNING] Item physical file not found/is symlink for: ${item.dest}`);
|
||||
}
|
||||
|
||||
if (!isDryRun) {
|
||||
await db`UPDATE items SET dest = ${newDest} WHERE id = ${item.id}`;
|
||||
}
|
||||
itemsRenamedCount++;
|
||||
|
||||
if (itemsRenamedCount % BATCH_SIZE === 0) {
|
||||
console.log(`[BACKFILL] Processed ${itemsRenamedCount} item renames...`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Process comment_files
|
||||
console.log('[BACKFILL] Fetching comment files to process...');
|
||||
const commentFiles = await db`SELECT id, dest FROM comment_files`;
|
||||
console.log(`[BACKFILL] Found ${commentFiles.length} comment files in DB.`);
|
||||
|
||||
let commentsProcessed = 0;
|
||||
let commentsRenamedCount = 0;
|
||||
|
||||
for (const cf of commentFiles) {
|
||||
if (limit && commentsProcessed >= limit) break;
|
||||
commentsProcessed++;
|
||||
|
||||
const ext = path.extname(cf.dest);
|
||||
const base = path.basename(cf.dest, ext);
|
||||
|
||||
if (base.length === 48) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newUuid = crypto.randomBytes(24).toString('hex');
|
||||
const newDest = `${newUuid}${ext}`;
|
||||
renameMap.set(cf.dest, newDest);
|
||||
|
||||
const oldPath = path.join(cfg.paths.c, cf.dest);
|
||||
const newPath = path.join(cfg.paths.c, newDest);
|
||||
|
||||
try {
|
||||
const stat = await fs.lstat(oldPath);
|
||||
if (!stat.isSymbolicLink()) {
|
||||
console.log(`[BACKFILL] Renaming comment file: ${oldPath} -> ${newPath}`);
|
||||
if (!isDryRun) {
|
||||
await fs.rename(oldPath, newPath);
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Rename corresponding thumbnail in t/ (cf_${old_uuid}.webp -> cf_${new_uuid}.webp)
|
||||
const oldThumbPath = path.join(cfg.paths.t, `cf_${base}.webp`);
|
||||
const newThumbPath = path.join(cfg.paths.t, `cf_${newUuid}.webp`);
|
||||
try {
|
||||
await fs.access(oldThumbPath);
|
||||
console.log(`[BACKFILL] Renaming comment thumbnail: ${oldThumbPath} -> ${newThumbPath}`);
|
||||
if (!isDryRun) {
|
||||
await fs.rename(oldThumbPath, newThumbPath);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (!isDryRun) {
|
||||
await db`UPDATE comment_files SET dest = ${newDest} WHERE id = ${cf.id}`;
|
||||
}
|
||||
commentsRenamedCount++;
|
||||
|
||||
if (commentsRenamedCount % BATCH_SIZE === 0) {
|
||||
console.log(`[BACKFILL] Processed ${commentsRenamedCount} comment file renames...`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update Symlinks in c/
|
||||
console.log('[BACKFILL] Scanning /c/ directory for symlinks to update...');
|
||||
try {
|
||||
const filesInC = await fs.readdir(cfg.paths.c);
|
||||
for (const f of filesInC) {
|
||||
const filePath = path.join(cfg.paths.c, f);
|
||||
try {
|
||||
const stat = await fs.lstat(filePath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = await fs.readlink(filePath);
|
||||
const targetBasename = path.basename(target);
|
||||
if (renameMap.has(targetBasename)) {
|
||||
const newTargetBasename = renameMap.get(targetBasename);
|
||||
// Re-construct the symlink target path, pointing to public/b or public/c
|
||||
const resolvedTargetAbs = path.resolve(path.dirname(filePath), target);
|
||||
const resolvedTargetNewAbs = path.join(path.dirname(resolvedTargetAbs), newTargetBasename);
|
||||
const relativeNewTarget = path.relative(path.dirname(filePath), resolvedTargetNewAbs);
|
||||
|
||||
console.log(`[BACKFILL] Updating symlink: ${filePath} -> ${relativeNewTarget}`);
|
||||
if (!isDryRun) {
|
||||
await fs.unlink(filePath);
|
||||
await fs.symlink(relativeNewTarget, filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[BACKFILL] [ERROR] Failed processing file/symlink ${filePath}:`, e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[BACKFILL] Error reading /c/ directory:', e.message);
|
||||
}
|
||||
|
||||
console.log(`[BACKFILL] Completed. Items renamed: ${itemsRenamedCount}. Comment files renamed: ${commentsRenamedCount}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('[BACKFILL] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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,6 +1,7 @@
|
||||
import db from "../src/inc/sql.mjs";
|
||||
import lib from "../src/inc/lib.mjs";
|
||||
import cfg from "../src/inc/config.mjs";
|
||||
import { getDefaultLayout } from "../src/inc/settings.mjs";
|
||||
|
||||
const [username, password] = process.argv.slice(2);
|
||||
|
||||
@@ -47,7 +48,7 @@ async function createAdmin() {
|
||||
|
||||
await db`
|
||||
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping)
|
||||
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${(cfg.websrv.default_layout ?? 'modern') !== 'legacy'}, false, false)
|
||||
values (${userId}, 3, 'amoled', 0, null, 'default.png', ${getDefaultLayout() === 'modern'}, false, false)
|
||||
`;
|
||||
|
||||
console.log(`--- Admin User ${username} Created Successfully ---`);
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* fix_youtube_dest.mjs
|
||||
*
|
||||
* One-time fix: the backfill_uuid_filenames.mjs script failed to exclude
|
||||
* YouTube items, which store dest as "yt:VIDEO_ID" rather than a real file.
|
||||
* As a result, those dest values were replaced with random UUIDs, breaking
|
||||
* embed URLs like youtube.com/embed/<uuid>.
|
||||
*
|
||||
* This script:
|
||||
* 1. Finds all items with mime = 'video/youtube'
|
||||
* 2. For each, re-extracts the video ID from the src column
|
||||
* (src holds the original YouTube watch URL: https://www.youtube.com/watch?v=VIDEO_ID)
|
||||
* 3. Restores dest to "yt:VIDEO_ID"
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/fix_youtube_dest.mjs # live run
|
||||
* node scripts/fix_youtube_dest.mjs --dry-run # preview only, no DB changes
|
||||
*/
|
||||
|
||||
import db from '../src/inc/sql.mjs';
|
||||
|
||||
const isDryRun = process.argv.includes('--dry-run');
|
||||
|
||||
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
|
||||
|
||||
async function run() {
|
||||
console.log(`[FIX-YT-DEST] Starting${isDryRun ? ' (DRY RUN)' : ''}...`);
|
||||
|
||||
const rows = await db`
|
||||
SELECT id, dest, src
|
||||
FROM items
|
||||
WHERE mime = 'video/youtube'
|
||||
ORDER BY id
|
||||
`;
|
||||
|
||||
console.log(`[FIX-YT-DEST] Found ${rows.length} YouTube item(s) to check.`);
|
||||
|
||||
let fixed = 0;
|
||||
let skipped = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
// Already correct format
|
||||
if (row.dest && row.dest.startsWith('yt:')) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to extract video ID from src URL
|
||||
const match = row.src && row.src.match(ytRegex);
|
||||
if (!match) {
|
||||
console.warn(`[FIX-YT-DEST] [WARN] Item ${row.id}: cannot extract video ID from src="${row.src}", dest="${row.dest}" — skipping`);
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const videoId = match[1];
|
||||
const newDest = `yt:${videoId}`;
|
||||
|
||||
console.log(`[FIX-YT-DEST] Item ${row.id}: "${row.dest}" → "${newDest}" (src: ${row.src})`);
|
||||
|
||||
if (!isDryRun) {
|
||||
await db`UPDATE items SET dest = ${newDest} WHERE id = ${row.id}`;
|
||||
}
|
||||
fixed++;
|
||||
}
|
||||
|
||||
console.log(`\n[FIX-YT-DEST] Done.`);
|
||||
console.log(` Fixed: ${fixed}`);
|
||||
console.log(` Skipped: ${skipped} (already correct)`);
|
||||
console.log(` Failed: ${failed} (no video ID recoverable from src)`);
|
||||
|
||||
if (isDryRun) {
|
||||
console.log('\n[FIX-YT-DEST] DRY RUN — no changes were written to the database.');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error('[FIX-YT-DEST] Fatal error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import db from "../src/inc/sql.mjs";
|
||||
import lib from "../src/inc/lib.mjs";
|
||||
import cfg from "../src/inc/config.mjs";
|
||||
import queue from "../src/inc/queue.mjs";
|
||||
import f0cklib from "../src/inc/routeinc/f0cklib.mjs";
|
||||
import { getBypassDuplicateCheck } from "../src/inc/settings.mjs";
|
||||
|
||||
// Helper for parsing CLI flags
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
const options = {
|
||||
user: null,
|
||||
rating: null,
|
||||
tags: [],
|
||||
dir: cfg.paths.import || path.resolve("f0ckm-data/import"),
|
||||
keep: false,
|
||||
bypass: null, // null means use config default, true = force bypass, false = force check
|
||||
is_oc: false,
|
||||
title: null,
|
||||
help: false
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else if (arg === '--user' || arg === '-u') {
|
||||
options.user = args[++i];
|
||||
} else if (arg === '--rating' || arg === '-r') {
|
||||
options.rating = args[++i]?.toLowerCase();
|
||||
} else if (arg === '--tags' || arg === '-t') {
|
||||
const rawTags = args[++i];
|
||||
if (rawTags) {
|
||||
options.tags = rawTags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
} else if (arg === '--dir' || arg === '-d') {
|
||||
options.dir = path.resolve(args[++i]);
|
||||
} else if (arg === '--keep' || arg === '-k') {
|
||||
options.keep = true;
|
||||
} else if (arg === '--bypass' || arg === '-b') {
|
||||
options.bypass = true;
|
||||
} else if (arg === '--check') {
|
||||
options.bypass = false;
|
||||
} else if (arg === '--oc') {
|
||||
options.is_oc = true;
|
||||
} else if (arg === '--title') {
|
||||
options.title = args[++i];
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
console.log(`
|
||||
f0ckm Bulk Import Script
|
||||
========================
|
||||
Imports all media files in f0ckm-data/import (or custom directory) into f0ckm.
|
||||
|
||||
Usage:
|
||||
node scripts/import.mjs [options]
|
||||
|
||||
Options:
|
||||
--user, -u <username> User to attribute uploads to (defaults to first admin)
|
||||
--rating, -r <sfw|nsfw|nsfl|untagged> Default rating for imported items
|
||||
--tags, -t <tag1,tag2> Global tags to assign to imported items
|
||||
--dir, -d <path> Directory to scan (default: f0ckm-data/import)
|
||||
--keep, -k Keep source files in import dir after import (default: delete)
|
||||
--bypass, -b Force bypass of duplicate checksum/pHash checks
|
||||
--check Force duplicate checking even if config enables bypass
|
||||
--oc Mark all imported files as Original Content (OC)
|
||||
--title <title> Set title for imported files
|
||||
--help, -h Show this help screen
|
||||
|
||||
Folder & Sidecar JSON Features:
|
||||
- Rating Subfolders: Files in 'import/sfw/', 'import/nsfw/', or 'import/nsfl/' automatically receive that rating.
|
||||
- Sidecar Metadata: Place a file.jpg.json or file.json alongside file.jpg to supply metadata:
|
||||
{ "title": "My Post", "rating": "sfw", "tags": ["funny", "cat"], "comment": "First comment", "is_oc": true }
|
||||
`);
|
||||
}
|
||||
|
||||
// Derive archive MIME types from cfg.mimes
|
||||
const ARCHIVE_MIMES = new Set(
|
||||
Object.entries(cfg.mimes || {})
|
||||
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
|
||||
.map(([mime]) => mime)
|
||||
);
|
||||
const isArchiveMime = (mime) => ARCHIVE_MIMES.has(mime);
|
||||
|
||||
// Recursively collect all target files in directory (follows symlinked dirs and files)
|
||||
async function collectFiles(dir, visitedDirs = new Set()) {
|
||||
const results = [];
|
||||
|
||||
let canonicalDir;
|
||||
try {
|
||||
canonicalDir = await fs.promises.realpath(dir);
|
||||
} catch (_) {
|
||||
canonicalDir = dir;
|
||||
}
|
||||
|
||||
if (visitedDirs.has(canonicalDir)) {
|
||||
return results; // Avoid infinite loops on circular symlink references
|
||||
}
|
||||
visitedDirs.add(canonicalDir);
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(dir);
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: unable to read directory '${dir}':`, e.message);
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const name of entries) {
|
||||
if (name.startsWith('.')) continue; // skip hidden files & .gitkeep
|
||||
const fullPath = path.join(dir, name);
|
||||
|
||||
try {
|
||||
// fs.promises.stat follows symlinks to inspect the underlying file/dir
|
||||
const st = await fs.promises.stat(fullPath);
|
||||
|
||||
if (st.isDirectory()) {
|
||||
const subResults = await collectFiles(fullPath, visitedDirs);
|
||||
results.push(...subResults);
|
||||
} else if (st.isFile()) {
|
||||
if (name.endsWith('.json')) continue; // skip sidecar metadata files
|
||||
results.push(fullPath);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[IMPORT] Warning: unable to stat '${fullPath}' (broken symlink?):`, err.message);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Read optional sidecar JSON metadata
|
||||
async function readSidecarMetadata(filePath) {
|
||||
const candidates = [
|
||||
`${filePath}.json`,
|
||||
path.join(path.dirname(filePath), `${path.parse(filePath).name}.json`)
|
||||
];
|
||||
|
||||
for (const cand of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(cand)) {
|
||||
const raw = await fs.promises.readFile(cand, 'utf-8');
|
||||
return { meta: JSON.parse(raw), sidecarPath: cand };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[IMPORT] Warning: failed to parse sidecar JSON at ${cand}:`, e.message);
|
||||
}
|
||||
}
|
||||
return { meta: {}, sidecarPath: null };
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
const opts = parseArgs();
|
||||
|
||||
if (opts.help) {
|
||||
showHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`--- f0ckm Bulk Import Starting ---`);
|
||||
console.log(`Scan Directory: ${opts.dir}`);
|
||||
|
||||
if (!fs.existsSync(opts.dir)) {
|
||||
console.error(`[IMPORT ERROR] Directory does not exist: ${opts.dir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 1. Resolve Target User
|
||||
let targetUser = null;
|
||||
if (opts.user) {
|
||||
const userRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE LOWER(login) = ${opts.user.toLowerCase()} OR LOWER("user") = ${opts.user.toLowerCase()}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (userRows.length > 0) {
|
||||
targetUser = userRows[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] Specified user '${opts.user}' not found in database.`);
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
const adminRows = await db`
|
||||
SELECT id, "user", login, admin, is_moderator
|
||||
FROM "user"
|
||||
WHERE admin = true
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (adminRows.length > 0) {
|
||||
targetUser = adminRows[0];
|
||||
} else {
|
||||
const anyUser = await db`SELECT id, "user", login, admin, is_moderator FROM "user" ORDER BY id ASC LIMIT 1`;
|
||||
if (anyUser.length > 0) {
|
||||
targetUser = anyUser[0];
|
||||
} else {
|
||||
console.error(`[IMPORT ERROR] No users found in database. Please run npm run create-admin first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Attributing uploads to user: ${targetUser.user} (ID: ${targetUser.id})`);
|
||||
|
||||
// Ensure target directories exist
|
||||
await fs.promises.mkdir(cfg.paths.b, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.t, { recursive: true });
|
||||
await fs.promises.mkdir(cfg.paths.ca, { recursive: true });
|
||||
|
||||
// 2. Discover files
|
||||
const fileList = await collectFiles(opts.dir);
|
||||
|
||||
if (fileList.length === 0) {
|
||||
console.log(`[IMPORT] No files found in ${opts.dir}. Place files in '${opts.dir}' and re-run.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found ${fileList.length} file(s) to process.\n`);
|
||||
|
||||
const allowedCats = Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes.map(c => c.toLowerCase()) : null;
|
||||
const allowedMimes = allowedCats
|
||||
? Object.keys(cfg.mimes).filter(m => allowedCats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)))
|
||||
: Object.keys(cfg.mimes);
|
||||
|
||||
let successCount = 0;
|
||||
let skippedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// 3. Process each file sequentially
|
||||
for (let index = 0; index < fileList.length; index++) {
|
||||
const filePath = fileList[index];
|
||||
const baseName = path.basename(filePath);
|
||||
const relPath = path.relative(opts.dir, filePath);
|
||||
const prefix = `[${index + 1}/${fileList.length}]`;
|
||||
|
||||
try {
|
||||
// Check sidecar JSON metadata
|
||||
const { meta: sidecarMeta, sidecarPath } = await readSidecarMetadata(filePath);
|
||||
|
||||
// Determine Rating: Sidecar -> CLI option -> Folder name (import/sfw/ etc.) -> Default
|
||||
let effectiveRating = sidecarMeta.rating || opts.rating;
|
||||
if (!effectiveRating) {
|
||||
const parentDirName = path.basename(path.dirname(filePath)).toLowerCase();
|
||||
if (['sfw', 'nsfw', 'nsfl', 'untagged'].includes(parentDirName)) {
|
||||
effectiveRating = parentDirName;
|
||||
}
|
||||
}
|
||||
if (effectiveRating === 'untagged') effectiveRating = null;
|
||||
if (!effectiveRating && !cfg.websrv.shitpost_mode) {
|
||||
effectiveRating = 'sfw'; // Default fallback if rating required
|
||||
}
|
||||
|
||||
// Determine Title & Comments & OC
|
||||
const effectiveTitle = sidecarMeta.title || opts.title || null;
|
||||
const effectiveComment = sidecarMeta.comment || null;
|
||||
const effectiveIsOc = (sidecarMeta.is_oc !== undefined) ? !!sidecarMeta.is_oc : opts.is_oc;
|
||||
const effectiveVisibility = (typeof sidecarMeta.visibility === 'number') ? sidecarMeta.visibility : 0;
|
||||
|
||||
// Collect tags
|
||||
let mergedTags = [...opts.tags];
|
||||
if (Array.isArray(sidecarMeta.tags)) {
|
||||
mergedTags.push(...sidecarMeta.tags);
|
||||
} else if (typeof sidecarMeta.tags === 'string') {
|
||||
mergedTags.push(...sidecarMeta.tags.split(',').map(t => t.trim()));
|
||||
}
|
||||
mergedTags = mergedTags.filter(t => t && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase()));
|
||||
|
||||
// Verify file stats & MIME
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
if (stat.size === 0) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} (0 bytes)`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect actual MIME via file command (with -L to follow symlinks)
|
||||
let actualMime = (await queue.spawn('file', ['-L', '--mime-type', '-b', filePath])).stdout.trim();
|
||||
|
||||
if ((actualMime === 'application/octet-stream' || !actualMime) && baseName.toLowerCase().endsWith('.swf')) {
|
||||
actualMime = 'application/x-shockwave-flash';
|
||||
}
|
||||
|
||||
if (!allowedMimes.includes(actualMime)) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Unsupported MIME type: '${actualMime}'`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase A: SHA-256, PHash, Dimensions, Audio-MP4 probe
|
||||
const needsPHash = !isArchiveMime(actualMime);
|
||||
const needsDims = !isArchiveMime(actualMime);
|
||||
const needsAudioMP4 = (actualMime === 'video/mp4' || actualMime === 'video/quicktime');
|
||||
|
||||
const [checksum, phash, dimResult, audioMp4Result] = await Promise.all([
|
||||
queue.spawn('sha256sum', [filePath]).then(r => r.stdout.trim().split(' ')[0]),
|
||||
needsPHash ? queue.generatePHash(filePath).catch(e => { console.error(`[IMPORT] PHash error:`, e.message); return null; }) : Promise.resolve(null),
|
||||
needsDims ? (async () => {
|
||||
try {
|
||||
if (actualMime.startsWith('image/')) {
|
||||
const { stdout: magickOut } = await queue.spawn('magick', ['identify', '-format', '%wx%h\n', filePath + '[0]'], { quiet: true, ignoreExitCode: true });
|
||||
const match = magickOut.trim().split('\n')[0].match(/^(\d+)x(\d+)$/);
|
||||
if (match) return { width: parseInt(match[1], 10), height: parseInt(match[2], 10) };
|
||||
} else if (actualMime.startsWith('video/') && actualMime !== 'video/youtube') {
|
||||
const { stdout: probeOut } = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', filePath], { quiet: true, ignoreExitCode: true });
|
||||
const dimParts = probeOut.trim().split(',');
|
||||
if (dimParts.length >= 2) {
|
||||
const w = parseInt(dimParts[0], 10);
|
||||
const h = parseInt(dimParts[1], 10);
|
||||
if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) return { width: w, height: h };
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null),
|
||||
needsAudioMP4 ? (async () => {
|
||||
const ext = baseName.split('.').pop().toLowerCase();
|
||||
if (['m4a', 'aac'].includes(ext)) return 'audio/mp4';
|
||||
try {
|
||||
const probe = await queue.spawn('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=codec_type', '-of', 'csv=p=0', filePath]);
|
||||
if (!probe.stdout.trim()) return 'audio/mp4';
|
||||
} catch (_) {}
|
||||
return null;
|
||||
})() : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (audioMp4Result) {
|
||||
actualMime = audioMp4Result;
|
||||
}
|
||||
|
||||
// Phase B: Duplicate check
|
||||
const bypassDupes = (opts.bypass !== null) ? opts.bypass : (getBypassDuplicateCheck() || cfg.websrv.bypass_duplicate_check === true);
|
||||
if (!bypassDupes) {
|
||||
const [repostBySum, repostByPhash] = await Promise.all([
|
||||
queue.checkrepostsum(checksum),
|
||||
phash ? queue.checkrepostphash(phash) : Promise.resolve(null)
|
||||
]);
|
||||
|
||||
if (repostBySum) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Duplicate file (matches existing Item #${repostBySum})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
if (repostByPhash) {
|
||||
console.warn(`${prefix} [SKIP] ${relPath} - Visual duplicate (matches existing Item #${repostByPhash})`);
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate target filename & UUID
|
||||
const uuid = await queue.genuuid();
|
||||
const ext = cfg.mimes[actualMime] || 'bin';
|
||||
const filename = `${uuid}.${ext}`;
|
||||
const destPath = path.join(cfg.paths.b, filename);
|
||||
const nowStamp = ~~(Date.now() / 1000);
|
||||
const itemSlug = lib.generateSlug(11);
|
||||
|
||||
// Copy file to public/b
|
||||
await fs.promises.copyFile(filePath, destPath);
|
||||
|
||||
const insertChecksum = bypassDupes ? `${checksum}_bypass_${Date.now()}` : checksum;
|
||||
|
||||
// Insert into items table
|
||||
await db`
|
||||
INSERT INTO items ${db({
|
||||
src: '',
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
size: stat.size,
|
||||
checksum: insertChecksum,
|
||||
phash: phash,
|
||||
username: targetUser.user,
|
||||
userchannel: 'import_script',
|
||||
usernetwork: 'local',
|
||||
stamp: nowStamp,
|
||||
active: true,
|
||||
is_oc: effectiveIsOc,
|
||||
original_filename: baseName,
|
||||
title: effectiveTitle,
|
||||
width: dimResult?.width ?? null,
|
||||
height: dimResult?.height ?? null,
|
||||
visibility: effectiveVisibility,
|
||||
slug: itemSlug,
|
||||
expires_at: null
|
||||
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'title', 'width', 'height', 'visibility', 'slug', 'expires_at')}
|
||||
`;
|
||||
|
||||
const itemid = await queue.getItemID(filename);
|
||||
|
||||
// Subscribe uploader to comments
|
||||
try {
|
||||
await db`
|
||||
INSERT INTO comment_subscriptions (user_id, item_id)
|
||||
VALUES (${targetUser.id}, ${itemid})
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
} catch (_) {}
|
||||
|
||||
// Generate thumbnails
|
||||
try {
|
||||
await queue.genThumbnail(filename, actualMime, itemid, '', false, 512);
|
||||
if (actualMime.startsWith('audio/') && queue._lastCoverExtracted) {
|
||||
await db`UPDATE items SET has_coverart = TRUE WHERE id = ${itemid}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`${prefix} [WARN] Thumbnail generation warning for #${itemid}:`, err.message);
|
||||
}
|
||||
|
||||
// Blurred thumbnail
|
||||
await queue.genBlurredThumbnail(itemid, false);
|
||||
|
||||
// Assign Rating Tag
|
||||
if (effectiveRating) {
|
||||
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: targetUser.id })}
|
||||
`;
|
||||
}
|
||||
|
||||
// Assign Custom Tags
|
||||
for (const tagName of mergedTags) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagName }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagName}) LIMIT 1`;
|
||||
}
|
||||
const tagId = tagRow[0].id;
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagId, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
|
||||
// Auto OC tags
|
||||
if (effectiveIsOc) {
|
||||
for (const tagname of ['oc', 'original content']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto Flash tags
|
||||
if (actualMime === 'application/x-shockwave-flash' || actualMime === 'application/vnd.adobe.flash.movie') {
|
||||
for (const tagname of ['Flash', 'SWF']) {
|
||||
let tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
if (tagRow.length === 0) {
|
||||
await db`INSERT INTO tags ${db({ tag: tagname }, 'tag')}`;
|
||||
tagRow = await db`SELECT id FROM tags WHERE normalized = slugify(${tagname}) LIMIT 1`;
|
||||
}
|
||||
await db`
|
||||
INSERT INTO tags_assign ${db({ item_id: itemid, tag_id: tagRow[0].id, user_id: targetUser.id })}
|
||||
ON CONFLICT DO NOTHING
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Optional first comment
|
||||
if (effectiveComment && effectiveComment.trim().length > 0) {
|
||||
await db`
|
||||
INSERT INTO comments ${db({
|
||||
item_id: itemid,
|
||||
user_id: targetUser.id,
|
||||
content: effectiveComment.trim()
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
// PostgreSQL Live Notification
|
||||
try {
|
||||
await db`SELECT pg_notify('new_item', ${JSON.stringify({
|
||||
id: itemid,
|
||||
dest: filename,
|
||||
mime: actualMime,
|
||||
username: targetUser.user,
|
||||
display_name: targetUser.display_name || null,
|
||||
tag_id: effectiveRating ? (effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3))) : 0,
|
||||
is_oc: !!effectiveIsOc
|
||||
})})`;
|
||||
} catch (_) {}
|
||||
|
||||
// Clear cache
|
||||
f0cklib.clearCountCache();
|
||||
|
||||
// Cleanup imported source file unless --keep was passed
|
||||
if (!opts.keep) {
|
||||
await fs.promises.unlink(filePath).catch(() => {});
|
||||
if (sidecarPath) {
|
||||
await fs.promises.unlink(sidecarPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${prefix} [SUCCESS] Imported '${relPath}' -> Item #${itemid} (Rating: ${effectiveRating || 'untagged'}, MIME: ${actualMime})`);
|
||||
successCount++;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`${prefix} [ERROR] Failed to import '${relPath}':`, err.message);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n========================================`);
|
||||
console.log(`Import Complete!`);
|
||||
console.log(`Total Scanned : ${fileList.length}`);
|
||||
console.log(`Successful : ${successCount}`);
|
||||
console.log(`Skipped : ${skippedCount}`);
|
||||
console.log(`Failed : ${failedCount}`);
|
||||
console.log(`========================================`);
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
runImport().catch(err => {
|
||||
console.error(`[IMPORT FATAL ERROR]`, err);
|
||||
process.exit(1);
|
||||
});
|
||||
+13
-116
@@ -8,10 +8,6 @@
|
||||
* node regen.mjs --all - Regenerate ALL items
|
||||
* node regen.mjs --audio - Regenerate all audio items
|
||||
* node regen.mjs --pdf - Regenerate all PDF items
|
||||
* node regen.mjs --blur - Regenerate ONLY the blurred thumbnails for all items
|
||||
*
|
||||
* Flash (SWF) items are always excluded — their thumbnails are set via the
|
||||
* Ruffle snapshot mechanism and must never be touched by this script.
|
||||
*/
|
||||
|
||||
import db from "../src/inc/sql.mjs";
|
||||
@@ -19,10 +15,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);
|
||||
|
||||
@@ -34,69 +26,14 @@ if (args.length === 0) {
|
||||
console.log(' node regen.mjs --audio - Regenerate all audio items');
|
||||
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);
|
||||
}
|
||||
|
||||
// Flash mime types — never regenerate these
|
||||
const FLASH_MIMES = [
|
||||
'application/x-shockwave-flash',
|
||||
'application/vnd.adobe.flash.movie',
|
||||
];
|
||||
const isFlash = (mime) => FLASH_MIMES.includes(mime?.toLowerCase());
|
||||
|
||||
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;
|
||||
|
||||
if (isFlash(mime)) {
|
||||
console.log(`[${id}] Skipped (Flash/SWF — thumbnail managed by Ruffle snapshot)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blurOnly) {
|
||||
console.log(`[${id}] Regenerating blurred thumbnail only: ${dest}`);
|
||||
try {
|
||||
await queue.genBlurredThumbnail(id, false);
|
||||
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
||||
} catch (err) {
|
||||
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${id}] Regenerating: ${dest} (${mime})`);
|
||||
|
||||
try {
|
||||
@@ -112,91 +49,51 @@ const regen = async (item) => {
|
||||
console.log(`[${id}] ✓ Thumbnail regenerated`);
|
||||
}
|
||||
|
||||
// Regenerate blurred thumbnail unconditionally
|
||||
await queue.genBlurredThumbnail(id, false);
|
||||
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
||||
// Regenerate blurred thumbnail if item has NSFW tag
|
||||
const nsfw = await db`SELECT 1 FROM tags_assign WHERE item_id = ${id} AND tag_id = 2 LIMIT 1`;
|
||||
if (nsfw.length > 0) {
|
||||
await queue.genBlurredThumbnail(id, false);
|
||||
console.log(`[${id}] ✓ Blurred thumbnail regenerated`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[${id}] ✗ FAILED:`, err.message || err);
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
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`;
|
||||
console.log(`Regenerating ALL ${items.length} non-Flash items...\n`);
|
||||
items = await db`SELECT id, dest, mime, src FROM items WHERE active = true AND is_deleted = false ORDER BY id`;
|
||||
console.log(`Regenerating ALL ${items.length} 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}
|
||||
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) {
|
||||
console.error('Fatal error:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
+1
-25
@@ -19,8 +19,7 @@ const sendJson = (res, data, code = 200) => {
|
||||
|
||||
// Generate UUID using the same method as video uploads
|
||||
const genuuid = async () => {
|
||||
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
||||
return raw.substring(0, 48);
|
||||
return (await db`select gen_random_uuid() as uuid`)[0].uuid.substring(0, 8);
|
||||
};
|
||||
|
||||
export const handleAvatarUpload = async (req, res) => {
|
||||
@@ -44,10 +43,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);
|
||||
|
||||
@@ -166,14 +161,6 @@ export const handleAvatarUpload = async (req, res) => {
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: req.session.id,
|
||||
user: req.session.user,
|
||||
avatar_file: finalFilename,
|
||||
avatar: null
|
||||
});
|
||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||
|
||||
console.log('[AVATAR HANDLER] Upload complete:', finalFilename);
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
@@ -210,10 +197,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
|
||||
@@ -241,13 +224,6 @@ export const handleAvatarDelete = async (req, res) => {
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: req.session.id,
|
||||
user: req.session.user,
|
||||
avatar_file: null
|
||||
});
|
||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||
|
||||
console.log('[AVATAR HANDLER] Delete complete');
|
||||
return sendJson(res, { success: true, msg: 'Custom avatar removed' }, 200);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,305 +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";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
// Helper for JSON response
|
||||
const sendJson = (res, data, code = 200) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
// Generate UUID using the same method as video uploads
|
||||
const genuuid = async () => {
|
||||
const raw = (await db`select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid`)[0].uuid;
|
||||
return raw.substring(0, 48);
|
||||
};
|
||||
|
||||
export const handleBannerUpload = async (req, res) => {
|
||||
console.log('[BANNER HANDLER] Upload started');
|
||||
|
||||
// Manual Session Lookup
|
||||
let user = [];
|
||||
if (req.cookies && req.cookies.session) {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (user.length === 0) {
|
||||
console.log('[BANNER HANDLER] Unauthorized - No valid session found');
|
||||
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);
|
||||
|
||||
// CSRF validation
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked banner upload for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=(.+)$/);
|
||||
|
||||
if (!boundaryMatch) {
|
||||
console.log('[BANNER HANDLER] No boundary');
|
||||
return sendJson(res, { success: false, msg: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
console.log('[BANNER HANDLER] Collecting body...');
|
||||
const body = await collectBody(req);
|
||||
console.log('[BANNER HANDLER] Body collected, size:', body.length);
|
||||
|
||||
console.log('[BANNER HANDLER] Parsing multipart...');
|
||||
const parts = parseMultipart(body, boundaryMatch[1]);
|
||||
const file = parts.file;
|
||||
console.log('[BANNER HANDLER] Parsed, file present:', !!file, 'keys:', Object.keys(parts));
|
||||
|
||||
if (!file || !file.data) {
|
||||
return sendJson(res, { success: false, msg: 'No file provided' }, 400);
|
||||
}
|
||||
|
||||
// Validate file size (5MB max)
|
||||
const maxSize = 5 * 1024 * 1024;
|
||||
if (file.data.length > maxSize) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `File too large. Maximum size is 5MB, got ${(file.data.length / 1024 / 1024).toFixed(2)}MB`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Allowed MIME types
|
||||
const allowedMimes = [
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
// Validate MIME type from content-type header
|
||||
let mime = (file.contentType || '').toLowerCase().split(';')[0].trim();
|
||||
console.log('[BANNER HANDLER] File MIME from header:', mime);
|
||||
if (!allowedMimes.includes(mime)) {
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `Invalid file type. Allowed: gif, jpg, jpeg, png, webp. Got: ${mime}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Save to tmp and verify with file magic
|
||||
console.log('[BANNER HANDLER] Generating UUID...');
|
||||
const uuid = await genuuid();
|
||||
const tmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_tmp`);
|
||||
const finalFilename = `banner_${uuid}.webp`;
|
||||
const finalPath = path.join(cfg.paths.a, finalFilename);
|
||||
|
||||
await fs.mkdir(cfg.paths.tmp, { recursive: true });
|
||||
await fs.mkdir(cfg.paths.a, { recursive: true });
|
||||
|
||||
console.log('[BANNER HANDLER] Writing tmp file:', tmpPath);
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
|
||||
// Verify MIME with file magic
|
||||
console.log('[BANNER HANDLER] Checking MIME with file magic...');
|
||||
const { stdout: actualMime } = await execFile('file', ['--mime-type', '-b', tmpPath]);
|
||||
console.log('[BANNER HANDLER] Actual MIME:', actualMime.trim());
|
||||
const allowedActualMimes = [
|
||||
'image/gif',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp'
|
||||
];
|
||||
|
||||
if (!allowedActualMimes.includes(actualMime.trim())) {
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
return sendJson(res, {
|
||||
success: false,
|
||||
msg: `Invalid file type detected: ${actualMime.trim()}`
|
||||
}, 400);
|
||||
}
|
||||
|
||||
// Convert to webp using ImageMagick — landscape banner crop (1200x400)
|
||||
// NOTE: [0] frame selector must be appended to the input path, not a separate arg
|
||||
console.log('[BANNER HANDLER] Running magick...');
|
||||
try {
|
||||
await execFile('magick', [`${tmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalPath]);
|
||||
} catch (err) {
|
||||
console.error('[BANNER HANDLER] Magick error:', err.message, err.stderr);
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
return sendJson(res, { success: false, msg: 'Failed to process image: ' + err.message }, 500);
|
||||
}
|
||||
console.log('[BANNER HANDLER] Magick done, output:', finalPath);
|
||||
|
||||
const file_orig = parts.file_orig;
|
||||
if (file_orig && file_orig.data) {
|
||||
console.log('[BANNER HANDLER] Processing original file...');
|
||||
const origTmpPath = path.join(cfg.paths.tmp, `banner_${uuid}_orig_tmp`);
|
||||
const finalOrigPath = path.join(cfg.paths.a, `banner_${uuid}_orig.webp`);
|
||||
await fs.writeFile(origTmpPath, file_orig.data);
|
||||
try {
|
||||
await execFile('magick', [`${origTmpPath}[0]`, '-resize', '3840x3840>', '-quality', '85', finalOrigPath]);
|
||||
} catch (err) {
|
||||
console.error('[BANNER HANDLER] Magick error on original:', err.message);
|
||||
}
|
||||
await fs.unlink(origTmpPath).catch(() => { });
|
||||
}
|
||||
|
||||
// Get current banner_file to delete old one (after magick succeeds)
|
||||
let currentBanner = null;
|
||||
try {
|
||||
currentBanner = (await db`
|
||||
select banner_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0]?.banner_file;
|
||||
} catch (dbErr) {
|
||||
console.error('[BANNER HANDLER] Could not fetch current banner (column may not exist yet):', dbErr.message);
|
||||
}
|
||||
|
||||
// Clean up tmp file
|
||||
await fs.unlink(tmpPath).catch(() => { });
|
||||
|
||||
// Delete old banner file if exists
|
||||
if (currentBanner) {
|
||||
const oldPath = path.join(cfg.paths.a, currentBanner);
|
||||
await fs.unlink(oldPath).catch(() => { });
|
||||
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
|
||||
await fs.unlink(oldOrigPath).catch(() => { });
|
||||
}
|
||||
|
||||
// Update database
|
||||
console.log('[BANNER HANDLER] Updating database...');
|
||||
try {
|
||||
await db`
|
||||
update user_options
|
||||
set banner_file = ${finalFilename},
|
||||
banner_position = ${parts.banner_position || 'center'},
|
||||
banner_size = ${parts.banner_size || 'cover'},
|
||||
banner_repeat = ${parts.banner_repeat || 'repeat'}
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
banner_file: finalFilename,
|
||||
banner_position: parts.banner_position || 'center',
|
||||
banner_size: parts.banner_size || 'cover',
|
||||
banner_repeat: parts.banner_repeat || 'repeat'
|
||||
});
|
||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||
} catch (dbErr) {
|
||||
console.error('[BANNER HANDLER] DB update failed:', dbErr.message);
|
||||
await fs.unlink(finalPath).catch(() => {});
|
||||
return sendJson(res, { success: false, msg: 'DB error — did you run the migration? ' + dbErr.message }, 500);
|
||||
}
|
||||
|
||||
console.log('[BANNER HANDLER] Upload complete:', finalFilename);
|
||||
return sendJson(res, {
|
||||
success: true,
|
||||
banner_file: finalFilename,
|
||||
msg: 'Banner uploaded successfully'
|
||||
}, 200);
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'BODY_TOO_LARGE') {
|
||||
return sendJson(res, { success: false, msg: 'File too large (5 MB max for banners)' }, 413);
|
||||
}
|
||||
console.error('[BANNER HANDLER ERROR]', err.message, err.stack);
|
||||
try {
|
||||
return sendJson(res, { success: false, msg: err.message || 'Banner upload failed' }, 500);
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
export const handleBannerDelete = async (req, res) => {
|
||||
console.log('[BANNER HANDLER] Delete started');
|
||||
|
||||
// Manual Session Lookup
|
||||
let user = [];
|
||||
if (req.cookies && req.cookies.session) {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
}
|
||||
|
||||
if (user.length === 0) {
|
||||
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
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked banner delete for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, msg: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const currentBanner = (await db`
|
||||
select banner_file from user_options where user_id = ${+req.session.id}
|
||||
`)[0]?.banner_file;
|
||||
|
||||
if (currentBanner) {
|
||||
const oldPath = path.join(cfg.paths.a, currentBanner);
|
||||
await fs.unlink(oldPath).catch(() => { });
|
||||
const oldOrigPath = path.join(cfg.paths.a, currentBanner.replace('.webp', '_orig.webp'));
|
||||
await fs.unlink(oldOrigPath).catch(() => { });
|
||||
}
|
||||
|
||||
await db`
|
||||
update user_options
|
||||
set banner_file = null
|
||||
where user_id = ${+req.session.id}
|
||||
`;
|
||||
if (global._invalidateSessionCache && req.cookies && req.cookies.session) {
|
||||
global._invalidateSessionCache(lib.sha256(req.cookies.session));
|
||||
}
|
||||
const payloadStr = JSON.stringify({
|
||||
user_id: +req.session.id,
|
||||
user: req.session.user,
|
||||
banner_file: null
|
||||
});
|
||||
await db`select pg_notify('profile_update', ${payloadStr})`;
|
||||
|
||||
console.log('[BANNER HANDLER] Delete complete');
|
||||
return sendJson(res, { success: true, msg: 'Custom banner removed' }, 200);
|
||||
} catch (err) {
|
||||
console.error('[BANNER DELETE ERROR]', err);
|
||||
return sendJson(res, { success: false, msg: 'Failed to remove banner' }, 500);
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
+62
-131
@@ -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) => {
|
||||
@@ -13,6 +12,7 @@ const sendJson = (res, data, code = 200) => {
|
||||
res.end(JSON.stringify(data));
|
||||
};
|
||||
|
||||
// One-time migration: ensure comment_files table exists
|
||||
db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
||||
id SERIAL PRIMARY KEY,
|
||||
comment_id INTEGER REFERENCES public.comments(id) ON DELETE CASCADE,
|
||||
@@ -25,12 +25,8 @@ db`CREATE TABLE IF NOT EXISTS public.comment_files (
|
||||
original_filename TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)`.catch(() => { });
|
||||
db`CREATE SEQUENCE IF NOT EXISTS comment_files_id_seq`.catch(() => { });
|
||||
db`ALTER TABLE comment_files ALTER COLUMN id SET DEFAULT nextval('comment_files_id_seq')`.catch(() => { });
|
||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_comment_id ON public.comment_files(comment_id)`.catch(() => { });
|
||||
db`CREATE INDEX IF NOT EXISTS idx_comment_files_checksum ON public.comment_files(checksum)`.catch(() => { });
|
||||
db`ALTER TABLE public.comment_files ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id)`.catch(() => { });
|
||||
db`ALTER TABLE public.comment_files REPLICA IDENTITY DEFAULT`.catch(() => { });
|
||||
|
||||
/**
|
||||
* Parse multipart form data supporting multiple files with the same field name.
|
||||
@@ -96,19 +92,12 @@ const parseMultipartFiles = (buffer, boundary) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the allowed MIME list for comment uploads.
|
||||
* Respects cfg.websrv.fileupload_comments_mimes (e.g. ["image", "video", "audio"]) to
|
||||
* allow a different set of categories than the global allowedMimes used for page uploads.
|
||||
* Falls back to image/video/audio if the setting is absent.
|
||||
* Build the allowed MIME list for comment uploads (image/*, video/*, audio/*).
|
||||
* Filters from cfg.mimes, excluding PDF, SWF, etc.
|
||||
*/
|
||||
const getAllowedCommentMimes = () => {
|
||||
const allowedCats = Array.isArray(cfg.websrv.fileupload_comments_mimes)
|
||||
? cfg.websrv.fileupload_comments_mimes.map(c => c.toLowerCase())
|
||||
: ['image', 'video', 'audio'];
|
||||
return Object.keys(cfg.mimes).filter(mime =>
|
||||
allowedCats.some(cat =>
|
||||
cat.includes('/') ? mime === cat : mime.startsWith(`${cat}/`)
|
||||
)
|
||||
mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')
|
||||
);
|
||||
};
|
||||
|
||||
@@ -142,12 +131,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);
|
||||
@@ -191,7 +174,6 @@ export const handleCommentUpload = async (req, res) => {
|
||||
return sendJson(res, { success: false, msg: 'Only one file allowed per comment' }, 400);
|
||||
}
|
||||
|
||||
|
||||
const allowedMimes = getAllowedCommentMimes();
|
||||
const results = [];
|
||||
|
||||
@@ -392,23 +374,29 @@ export const handleCommentUpload = async (req, res) => {
|
||||
try {
|
||||
phash = await queue.generatePHash(tmpPath);
|
||||
if (phash && !linkedToExisting) {
|
||||
// Check comment_files for visual duplicate using fast SQL query
|
||||
const commentMatch = await queue.checkcommentrepostphash(phash);
|
||||
if (commentMatch) {
|
||||
const existingAbsPath = path.join(cfg.paths.c, commentMatch.dest);
|
||||
try {
|
||||
const realTarget = await fs.realpath(existingAbsPath);
|
||||
const destPath = path.join(cfg.paths.c, filename);
|
||||
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
||||
await fs.symlink(relTarget, destPath);
|
||||
linkedToExisting = true;
|
||||
console.log(`[COMMENT_UPLOAD] PHash match in comment_files: ${filename} → ${relTarget}`);
|
||||
} catch (e) {
|
||||
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
|
||||
// Check comment_files for visual duplicate
|
||||
const cfItems = await db`
|
||||
SELECT id, phash, dest FROM comment_files
|
||||
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
|
||||
`;
|
||||
for (const cf of cfItems) {
|
||||
if (isPhashMatch(phash, cf.phash)) {
|
||||
const existingAbsPath = path.join(cfg.paths.c, cf.dest);
|
||||
try {
|
||||
const realTarget = await fs.realpath(existingAbsPath);
|
||||
const destPath = path.join(cfg.paths.c, filename);
|
||||
const relTarget = path.relative(path.dirname(destPath), realTarget);
|
||||
await fs.symlink(relTarget, destPath);
|
||||
linkedToExisting = true;
|
||||
console.log(`[COMMENT_UPLOAD] PHash match in comment_files: ${filename} → ${relTarget}`);
|
||||
} catch (e) {
|
||||
console.error(`[COMMENT_UPLOAD] PHash symlink failed:`, e.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check items table for visual duplicate using fast SQL query
|
||||
// Also check items table for visual duplicate
|
||||
if (!linkedToExisting) {
|
||||
const phashMatch = await queue.checkrepostphash(phash);
|
||||
if (phashMatch) {
|
||||
@@ -490,74 +478,6 @@ export const handleCommentUpload = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* DELETE /api/v2/comments/upload/:id
|
||||
* Called by the client when the user removes a staged (not-yet-posted) attachment
|
||||
* from the compose area. Only deletes if the row still has comment_id = NULL
|
||||
* (i.e. it was never linked to a real comment) and belongs to the requesting user.
|
||||
*/
|
||||
export const handleCommentUploadCancel = async (req, res, fileId) => {
|
||||
// Manual session lookup (same pattern as handleCommentUpload)
|
||||
if (req.cookies?.session) {
|
||||
try {
|
||||
const user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "user_sessions".id as sess_id, "user_sessions".csrf_token, "user_options".*
|
||||
from "user_sessions"
|
||||
left join "user" on "user".id = "user_sessions".user_id
|
||||
left join "user_options" on "user_options".user_id = "user_sessions".user_id
|
||||
where "user_sessions".session = ${lib.sha256(req.cookies.session)}
|
||||
limit 1
|
||||
`;
|
||||
if (user.length > 0) {
|
||||
req.session = user[0];
|
||||
}
|
||||
} catch (err) {
|
||||
// Session lookup failed
|
||||
}
|
||||
}
|
||||
|
||||
if (!req.session) {
|
||||
return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
const id = parseInt(fileId, 10);
|
||||
if (!id || isNaN(id)) {
|
||||
return sendJson(res, { success: false, msg: 'Invalid file ID' }, 400);
|
||||
}
|
||||
|
||||
try {
|
||||
// Only allow deletion of own unlinked files
|
||||
const rows = await db`
|
||||
SELECT id, dest FROM comment_files
|
||||
WHERE id = ${id}
|
||||
AND user_id = ${req.session.id}
|
||||
AND comment_id IS NULL
|
||||
`;
|
||||
|
||||
if (!rows.length) {
|
||||
// Either doesn't exist, belongs to someone else, or already linked — silently OK
|
||||
return sendJson(res, { success: true });
|
||||
}
|
||||
|
||||
const { dest } = rows[0];
|
||||
await db`DELETE FROM comment_files WHERE id = ${id}`;
|
||||
|
||||
// Delete file and thumbnail from disk
|
||||
const filePath = path.join(cfg.paths.c, dest);
|
||||
const uuid = dest.split('.')[0];
|
||||
const thumbPath = path.join(cfg.paths.t, `cf_${uuid}.webp`);
|
||||
await fs.unlink(filePath).catch(() => {});
|
||||
await fs.unlink(thumbPath).catch(() => {});
|
||||
|
||||
console.log(`[COMMENT_UPLOAD] Cancelled (user-removed) attachment deleted: ${dest}`);
|
||||
return sendJson(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[COMMENT_UPLOAD] Cancel error:', err);
|
||||
return sendJson(res, { success: false, msg: 'Delete failed' }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Generate thumbnail for a comment file.
|
||||
* Outputs to /t/cf_<uuid>.webp
|
||||
@@ -581,33 +501,7 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
|
||||
const ffThumbSize = Math.max(size, 512);
|
||||
const seeks = ['20%', '40%', '60%', '80%'];
|
||||
for (const seek of seeks) {
|
||||
try {
|
||||
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||
} catch (err) {
|
||||
console.warn(`[COMMENT_UPLOAD] ffmpegthumbnailer failed at ${seek} for ${filename}, trying ffmpeg fallback: ${err.message}`);
|
||||
let seekSeconds = 0;
|
||||
try {
|
||||
const durationStr = (await queue.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', realSource])).stdout.trim();
|
||||
const duration = parseFloat(durationStr);
|
||||
if (!isNaN(duration) && duration > 0) {
|
||||
const pct = parseFloat(seek) / 100;
|
||||
seekSeconds = duration * pct;
|
||||
}
|
||||
} catch (probeErr) {
|
||||
seekSeconds = seek === '20%' ? 2 : seek === '40%' ? 5 : seek === '60%' ? 8 : 10;
|
||||
}
|
||||
// Fallback to ffmpeg, overriding the color transfer characteristic to standard bt709 (1) in case of unsupported trc properties (e.g. log316)
|
||||
await queue.spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-ss', String(seekSeconds),
|
||||
'-color_trc', '1',
|
||||
'-i', realSource,
|
||||
'-frames:v', '1',
|
||||
'-update', '1',
|
||||
'-vf', `scale=${ffThumbSize}:${ffThumbSize}:force_original_aspect_ratio=increase,crop=${ffThumbSize}:${ffThumbSize}`,
|
||||
tmpFile
|
||||
]);
|
||||
}
|
||||
await queue.spawn('ffmpegthumbnailer', ['-i', realSource, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||
try {
|
||||
const { stdout } = await queue.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
||||
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||
@@ -647,4 +541,41 @@ async function generateCommentThumbnail(filename, mime, uuid, size = 512) {
|
||||
await fs.unlink(tmpFile).catch(() => { });
|
||||
}
|
||||
|
||||
/**
|
||||
* PHash matching helper (same logic as queue.checkrepostphash)
|
||||
*/
|
||||
function isPhashMatch(newHash, dbHash) {
|
||||
if (!newHash || !dbHash) return false;
|
||||
const newHashes = newHash.split('_');
|
||||
const dbHashes = dbHash.split('_');
|
||||
const THRESHOLD = 15;
|
||||
|
||||
const getHammingDistance = (h1, h2) => {
|
||||
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
||||
let distance = 0;
|
||||
for (let i = 0; i < h1.length; i += 2) {
|
||||
const v1 = parseInt(h1.substr(i, 2), 16);
|
||||
const v2 = parseInt(h2.substr(i, 2), 16);
|
||||
let xor = v1 ^ v2;
|
||||
while (xor) {
|
||||
distance += xor & 1;
|
||||
xor >>= 1;
|
||||
}
|
||||
}
|
||||
return distance;
|
||||
};
|
||||
|
||||
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
|
||||
let matches = 0;
|
||||
|
||||
for (let i = 0; i < framesToCompare; i++) {
|
||||
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
|
||||
if (dist <= THRESHOLD) matches++;
|
||||
}
|
||||
|
||||
if (framesToCompare >= 3 && matches >= 2) return true;
|
||||
if (framesToCompare === 1 && matches === 1) return true;
|
||||
if (framesToCompare === 2 && matches >= 2) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
/**
|
||||
* dm_attachment_handler.mjs — Server-side handler for encrypted DM attachments.
|
||||
*
|
||||
* All uploaded content is opaque AES-GCM ciphertext — the server cannot read it.
|
||||
* Files are stored at /e/<id> (configured via DM_ATTACHMENT_DIR env or /e/).
|
||||
*
|
||||
* Routes (registered as bypass middlewares in index.mjs):
|
||||
* POST /api/dm/attachment/upload/:recipientId — receive ciphertext blob, store on disk
|
||||
* GET /api/dm/attachment/:id — stream ciphertext back (auth required)
|
||||
* DELETE /api/dm/attachment/:id — delete (sender only or admin)
|
||||
*/
|
||||
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import db from './inc/sql.mjs';
|
||||
import lib from './inc/lib.mjs';
|
||||
import cfg from './inc/config.mjs';
|
||||
import { collectBody } from './inc/multipart.mjs';
|
||||
import { getDmAttachments, getDmAttachmentExpiryDays } from './inc/settings.mjs';
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const ATTACHMENT_DIR = process.env.DM_ATTACHMENT_DIR || cfg.paths.e;
|
||||
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB plaintext; ciphertext is ~1.33× due to base64url
|
||||
|
||||
// Ensure storage dir exists at startup
|
||||
fs.mkdir(ATTACHMENT_DIR, { recursive: true }).catch(e =>
|
||||
console.error('[DM_ATT] Failed to create attachment dir:', e.message)
|
||||
);
|
||||
|
||||
// ─── Expiry cleanup ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function cleanupExpiredAttachments() {
|
||||
try {
|
||||
const expired = await db`
|
||||
SELECT id, file_path FROM dm_attachments
|
||||
WHERE expires_at < now()
|
||||
`;
|
||||
if (!expired.length) return;
|
||||
|
||||
let deleted = 0;
|
||||
for (const row of expired) {
|
||||
await fs.unlink(row.file_path).catch(() => {}); // ignore already-missing files
|
||||
deleted++;
|
||||
}
|
||||
|
||||
const ids = expired.map(r => r.id);
|
||||
await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`;
|
||||
|
||||
console.log(`[DM_ATT] Cleanup: removed ${deleted} expired attachment(s)`);
|
||||
} catch (e) {
|
||||
console.error('[DM_ATT] Cleanup error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Run once at startup, then every 6 hours
|
||||
cleanupExpiredAttachments();
|
||||
setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);
|
||||
|
||||
|
||||
// ─── Session helper ───────────────────────────────────────────────────────────
|
||||
|
||||
async function resolveSession(req) {
|
||||
if (!req.cookies?.session) return null;
|
||||
try {
|
||||
const rows = await db`
|
||||
SELECT u.id, u.login, u.user, u.admin, u.is_moderator, s.csrf_token
|
||||
FROM user_sessions s
|
||||
LEFT JOIN "user" u ON u.id = s.user_id
|
||||
WHERE s.session = ${lib.sha256(req.cookies.session)}
|
||||
LIMIT 1
|
||||
`;
|
||||
return rows.length ? rows[0] : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function sendJson(res, data, code = 200) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(data));
|
||||
}
|
||||
|
||||
function validateCsrf(req, session) {
|
||||
const token = req.headers['x-csrf-token'];
|
||||
return session?.csrf_token && token && token === session.csrf_token;
|
||||
}
|
||||
|
||||
// ─── Multipart parser (minimal — only parses fields + one binary file part) ───
|
||||
|
||||
function parseAttachmentMultipart(buffer, boundary) {
|
||||
const boundaryBuf = Buffer.from(`--${boundary}`);
|
||||
const segments = [];
|
||||
let start = 0, idx;
|
||||
while ((idx = buffer.indexOf(boundaryBuf, start)) !== -1) {
|
||||
if (start !== 0) segments.push(buffer.slice(start, idx - 2));
|
||||
start = idx + boundaryBuf.length + 2;
|
||||
}
|
||||
|
||||
const result = { fields: {}, file: null };
|
||||
for (const seg of segments) {
|
||||
const hdrEnd = seg.indexOf('\r\n\r\n');
|
||||
if (hdrEnd === -1) continue;
|
||||
const headers = seg.slice(0, hdrEnd).toString();
|
||||
const body = seg.slice(hdrEnd + 4);
|
||||
const nameM = headers.match(/name="([^"]+)"/);
|
||||
if (!nameM) continue;
|
||||
const hasFile = headers.includes('filename=') || headers.includes('filename*=');
|
||||
if (hasFile && !result.file) {
|
||||
const ctM = headers.match(/Content-Type:\s*([^\r\n]+)/i);
|
||||
result.file = {
|
||||
data: body,
|
||||
contentType: ctM ? ctM[1].trim() : 'application/octet-stream'
|
||||
};
|
||||
} else if (!hasFile) {
|
||||
result.fields[nameM[1]] = body.toString().trim();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Upload handler ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleDmAttachmentUpload(req, res, recipientId) {
|
||||
if (!getDmAttachments()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
||||
|
||||
const session = await resolveSession(req);
|
||||
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
||||
|
||||
const rid = parseInt(recipientId, 10);
|
||||
if (!rid || rid === session.id) return sendJson(res, { success: false, msg: 'Invalid recipient' }, 400);
|
||||
|
||||
// Check recipient exists
|
||||
const recip = await db`SELECT id FROM "user" WHERE id = ${rid} LIMIT 1`;
|
||||
if (!recip.length) return sendJson(res, { success: false, msg: 'Recipient not found' }, 404);
|
||||
|
||||
const ct = req.headers['content-type'] || '';
|
||||
const bndM = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/);
|
||||
if (!ct.includes('multipart/form-data') || !bndM) {
|
||||
return sendJson(res, { success: false, msg: 'Expected multipart/form-data' }, 400);
|
||||
}
|
||||
|
||||
let rawBody;
|
||||
try {
|
||||
// MAX_BYTES * 1.4 overhead for base64url encoding + multipart framing
|
||||
rawBody = await collectBody(req, Math.ceil(MAX_BYTES * 1.4) + 4096);
|
||||
} catch (e) {
|
||||
if (e.code === 'BODY_TOO_LARGE') return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
|
||||
throw e;
|
||||
}
|
||||
|
||||
const boundary = bndM[1] || bndM[2];
|
||||
const { fields, file } = parseAttachmentMultipart(rawBody, boundary);
|
||||
|
||||
if (!file) return sendJson(res, { success: false, msg: 'No file part found' }, 400);
|
||||
|
||||
const iv = (fields.iv || '').trim();
|
||||
const originalName = (fields.original_name || '').slice(0, 255);
|
||||
const mimeHint = (fields.mime_hint || '').slice(0, 100);
|
||||
const sizeBytes = parseInt(fields.size_bytes || '0', 10) || 0;
|
||||
|
||||
if (!iv || iv.length > 32) return sendJson(res, { success: false, msg: 'Missing or invalid iv' }, 400);
|
||||
if (file.data.length > Math.ceil(MAX_BYTES * 1.4)) {
|
||||
return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
|
||||
}
|
||||
|
||||
try {
|
||||
// Insert DB record first to get an ID
|
||||
const expiresAt = new Date(Date.now() + getDmAttachmentExpiryDays() * 86400000);
|
||||
const [row] = await db`
|
||||
INSERT INTO dm_attachments ${db({
|
||||
sender_id: session.id,
|
||||
recipient_id: rid,
|
||||
iv,
|
||||
file_path: '',
|
||||
original_name: originalName,
|
||||
mime_hint: mimeHint,
|
||||
size_bytes: sizeBytes,
|
||||
expires_at: expiresAt
|
||||
})}
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const filePath = path.join(ATTACHMENT_DIR, String(row.id));
|
||||
await fs.writeFile(filePath, file.data);
|
||||
|
||||
// Update file_path now that we know the ID
|
||||
await db`UPDATE dm_attachments SET file_path = ${filePath} WHERE id = ${row.id}`;
|
||||
|
||||
return sendJson(res, { success: true, id: String(row.id) });
|
||||
} catch (err) {
|
||||
console.error('[DM_ATT] Upload error:', err);
|
||||
return sendJson(res, { success: false, msg: 'Server error' }, 500);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Download handler ─────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleDmAttachmentDownload(req, res, attachmentId) {
|
||||
const session = await resolveSession(req);
|
||||
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
|
||||
const id = parseInt(attachmentId, 10);
|
||||
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
|
||||
|
||||
const rows = await db`
|
||||
SELECT id, sender_id, recipient_id, iv, file_path, original_name, mime_hint
|
||||
FROM dm_attachments
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
`;
|
||||
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
||||
|
||||
const att = rows[0];
|
||||
// Only sender or recipient may download
|
||||
if (att.sender_id !== session.id && att.recipient_id !== session.id && !session.admin) {
|
||||
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(att.file_path);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Length': String(data.length),
|
||||
'Cache-Control': 'private, no-store',
|
||||
'X-DM-IV': att.iv,
|
||||
'X-Original-Name': encodeURIComponent(att.original_name || 'attachment'),
|
||||
'X-Mime-Hint': att.mime_hint || ''
|
||||
});
|
||||
res.end(data);
|
||||
} catch (err) {
|
||||
console.error('[DM_ATT] Download error:', err);
|
||||
return sendJson(res, { success: false, msg: 'File not found on disk' }, 404);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Delete handler ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function handleDmAttachmentDelete(req, res, attachmentId) {
|
||||
const session = await resolveSession(req);
|
||||
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
|
||||
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
|
||||
|
||||
const id = parseInt(attachmentId, 10);
|
||||
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
|
||||
|
||||
const rows = await db`SELECT id, sender_id, file_path FROM dm_attachments WHERE id = ${id} LIMIT 1`;
|
||||
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
|
||||
|
||||
const att = rows[0];
|
||||
if (att.sender_id !== session.id && !session.admin) {
|
||||
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.unlink(att.file_path).catch(() => {});
|
||||
await db`DELETE FROM dm_attachments WHERE id = ${id}`;
|
||||
return sendJson(res, { success: true });
|
||||
} catch (err) {
|
||||
console.error('[DM_ATT] Delete error:', err);
|
||||
return sendJson(res, { success: false, msg: 'Server error' }, 500);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { execFile as _execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import crypto from "crypto";
|
||||
|
||||
const execFile = promisify(_execFile);
|
||||
|
||||
@@ -81,11 +80,11 @@ export const handleEmojiUpload = async (req, res) => {
|
||||
|
||||
const file = parts.file;
|
||||
if (file && file.data && file.data.length > 0) {
|
||||
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||
const randSuffix = Math.random().toString(36).substring(7);
|
||||
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
||||
|
||||
const webpFilename = `${randSuffix}.webp`;
|
||||
const webpFilename = `${name}_${randSuffix}.webp`;
|
||||
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
||||
|
||||
if (originalExt === 'webp') {
|
||||
@@ -136,133 +135,3 @@ export const handleEmojiUpload = async (req, res) => {
|
||||
return sendJson(res, { success: false, message: err.message }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleEmojiEdit = async (req, res) => {
|
||||
// Manual Session Lookup
|
||||
let user = [];
|
||||
if (req.cookies && req.cookies.session) {
|
||||
user = await db`
|
||||
select "user".id, "user".login, "user".user, "user".admin, "user".is_moderator, "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) {
|
||||
return sendJson(res, { success: false, message: 'Unauthorized' }, 403);
|
||||
}
|
||||
|
||||
req.session = user[0];
|
||||
|
||||
// CSRF validation
|
||||
if (req.session.csrf_token) {
|
||||
const csrfToken = req.headers['x-csrf-token'];
|
||||
if (!csrfToken || csrfToken !== req.session.csrf_token) {
|
||||
console.warn(`[CSRF] Blocked emoji edit for user ${req.session.user}. Invalid token.`);
|
||||
return sendJson(res, { success: false, message: 'Invalid CSRF token' }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
const id = req.params.id;
|
||||
|
||||
try {
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
const boundaryMatch = contentType.match(/boundary=([^;]+)/);
|
||||
|
||||
if (!boundaryMatch) {
|
||||
return sendJson(res, { success: false, message: 'Invalid content type' }, 400);
|
||||
}
|
||||
|
||||
let boundary = boundaryMatch[1].trim();
|
||||
if (boundary.startsWith('"') && boundary.endsWith('"')) {
|
||||
boundary = boundary.substring(1, boundary.length - 1);
|
||||
}
|
||||
|
||||
const bodyBuffer = await collectBody(req);
|
||||
const parts = parseMultipart(bodyBuffer, boundary);
|
||||
|
||||
const name = (parts.name || '').trim().toLowerCase();
|
||||
let url = (parts.url || '').trim();
|
||||
|
||||
if (!name) {
|
||||
return sendJson(res, { success: false, message: 'Emoji name is required' }, 400);
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_-]+$/.test(name)) {
|
||||
return sendJson(res, { success: false, message: 'Invalid name. Use lowercase a-z, 0-9, _, - only.' }, 400);
|
||||
}
|
||||
|
||||
// Fetch the current emoji record
|
||||
const current = await db`SELECT id, name, url FROM custom_emojis WHERE id = ${id} LIMIT 1`;
|
||||
if (current.length === 0) {
|
||||
return sendJson(res, { success: false, message: 'Emoji not found' }, 404);
|
||||
}
|
||||
|
||||
// Check name collision (allow keeping the same name)
|
||||
if (name !== current[0].name) {
|
||||
const conflict = await db`SELECT id FROM custom_emojis WHERE name = ${name} AND id != ${id} LIMIT 1`;
|
||||
if (conflict.length > 0) {
|
||||
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
const file = parts.file;
|
||||
if (file && file.data && file.data.length > 0) {
|
||||
const randSuffix = crypto.randomBytes(24).toString('hex');
|
||||
const extMatch = file.filename.match(/\.([a-z0-9]+)$/i);
|
||||
const originalExt = extMatch ? extMatch[1].toLowerCase() : 'png';
|
||||
|
||||
const webpFilename = `${randSuffix}.webp`;
|
||||
const webpPath = path.join(cfg.paths.emojis, webpFilename);
|
||||
|
||||
if (originalExt === 'webp') {
|
||||
await fs.writeFile(webpPath, file.data);
|
||||
} else {
|
||||
const tmpFilename = `${name}_${randSuffix}_tmp.${originalExt}`;
|
||||
const tmpPath = path.join(cfg.paths.emojis, tmpFilename);
|
||||
await fs.writeFile(tmpPath, file.data);
|
||||
try {
|
||||
await execFile('magick', [tmpPath, '-coalesce', '-quality', '80', webpPath], { env: magickEnv });
|
||||
} finally {
|
||||
await fs.unlink(tmpPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const stat = await fs.stat(webpPath);
|
||||
if (!stat || stat.size === 0) throw new Error('File write/conversion verification failed');
|
||||
|
||||
// Delete the old local file if it was a hosted emoji
|
||||
if (current[0].url && current[0].url.startsWith('/s/emojis/')) {
|
||||
const oldFilename = path.basename(current[0].url);
|
||||
const oldPath = path.join(cfg.paths.emojis, oldFilename);
|
||||
await fs.unlink(oldPath).catch(() => {});
|
||||
}
|
||||
|
||||
url = `/s/emojis/${webpFilename}`;
|
||||
}
|
||||
|
||||
// If no new file and no new URL, keep the existing URL
|
||||
if (!url) {
|
||||
url = current[0].url;
|
||||
}
|
||||
|
||||
const updated = await db`
|
||||
UPDATE custom_emojis
|
||||
SET name = ${name}, url = ${url}
|
||||
WHERE id = ${id}
|
||||
RETURNING id, name, url
|
||||
`;
|
||||
|
||||
await db`NOTIFY emojis_updated, '{}'`;
|
||||
return sendJson(res, { success: true, emoji: updated[0] });
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === '23505') {
|
||||
return sendJson(res, { success: false, message: 'Emoji name already exists' }, 400);
|
||||
}
|
||||
console.error('[EMOJI EDIT ERROR]', err);
|
||||
return sendJson(res, { success: false, message: err.message }, 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -269,9 +269,6 @@ export const handleHallUpdate = async (req, res) => {
|
||||
|
||||
// POST /api/v2/admin/halls — create a new hall
|
||||
export const handleHallCreate = async (req, res) => {
|
||||
const session = await lookupSession(req);
|
||||
if (!session || (!session.admin && !session.is_moderator)) return sendJson(res, { success: false, msg: 'Unauthorized' }, 403);
|
||||
|
||||
// CSRF check
|
||||
const token = req.headers['x-csrf-token'] || req.url?.qs?.csrf_token;
|
||||
if (!token || token !== session.csrf_token) {
|
||||
|
||||
@@ -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 };
|
||||
+9
-81
@@ -12,23 +12,10 @@ config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.en
|
||||
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
|
||||
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
|
||||
|
||||
const isDocker = process.env.DB_HOST === 'f0ckm-db' || config.sql.host === 'f0ckm-db' || process.env.CONTAINER === 'true';
|
||||
if (process.env.NODE_ENV === 'production' || isDocker) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
config.main.development = false;
|
||||
}
|
||||
|
||||
if (config.main.development) {
|
||||
if (!process.env.DB_HOST && (config.sql.host === 'f0ckm-db' || !config.sql.host)) {
|
||||
config.sql.host = 'localhost';
|
||||
}
|
||||
if (!process.env.DB_PORT && config.sql.port === 5432) {
|
||||
config.sql.port = 5454;
|
||||
}
|
||||
if (!process.env.STORAGE_DIR) {
|
||||
process.env.STORAGE_DIR = 'f0ckm-data';
|
||||
}
|
||||
}
|
||||
|
||||
// Set timezone from config if not already set via environment variable
|
||||
if (!process.env.TZ && config.main.timezone) {
|
||||
process.env.TZ = config.main.timezone;
|
||||
@@ -39,42 +26,20 @@ const storage = process.env.STORAGE_DIR;
|
||||
|
||||
const resolvePath = (defaultRel) => {
|
||||
const local = path.resolve(path.join(base, defaultRel));
|
||||
let target = local;
|
||||
if (storage) {
|
||||
const absStorage = path.resolve(storage);
|
||||
if (defaultRel.startsWith('public/')) {
|
||||
const sub = defaultRel.replace('public/', '');
|
||||
if (sub === 's/emojis' || sub === 's/koepfe' || sub === 's/fonts') {
|
||||
if (sub === 's/emojis' || sub === 's/koepfe') {
|
||||
const storagePath = path.join(absStorage, sub.split('/').pop());
|
||||
if (fs.existsSync(storagePath)) target = path.resolve(storagePath);
|
||||
else target = local;
|
||||
} else {
|
||||
target = path.resolve(path.join(absStorage, sub));
|
||||
if (fs.existsSync(storagePath)) return path.resolve(storagePath);
|
||||
return local;
|
||||
}
|
||||
} else {
|
||||
target = path.resolve(path.join(absStorage, defaultRel));
|
||||
return path.resolve(path.join(absStorage, sub));
|
||||
}
|
||||
return path.resolve(path.join(absStorage, defaultRel));
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(target)) {
|
||||
return fs.realpathSync(target);
|
||||
}
|
||||
const parent = path.dirname(target);
|
||||
if (fs.existsSync(parent)) {
|
||||
return path.join(fs.realpathSync(parent), path.basename(target));
|
||||
}
|
||||
} catch (_) {}
|
||||
return target;
|
||||
};
|
||||
|
||||
const resolveImportPath = () => {
|
||||
const p = storage ? path.resolve(path.join(path.resolve(storage), 'import')) : path.resolve(path.join(base, 'f0ckm-data/import'));
|
||||
try {
|
||||
if (fs.existsSync(p)) return fs.realpathSync(p);
|
||||
const parent = path.dirname(p);
|
||||
if (fs.existsSync(parent)) return path.join(fs.realpathSync(parent), path.basename(p));
|
||||
} catch (_) {}
|
||||
return p;
|
||||
return local;
|
||||
};
|
||||
|
||||
config.paths = {
|
||||
@@ -83,51 +48,14 @@ 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()
|
||||
tmp: resolvePath('tmp')
|
||||
};
|
||||
|
||||
const configFilePath = path.resolve(base, "config.json");
|
||||
let watchDebounceTimer = null;
|
||||
try {
|
||||
if (fs.existsSync(configFilePath)) {
|
||||
const watcher = fs.watch(configFilePath, (eventType) => {
|
||||
if (eventType === 'change' || eventType === 'rename') {
|
||||
if (watchDebounceTimer) clearTimeout(watchDebounceTimer);
|
||||
watchDebounceTimer = setTimeout(() => {
|
||||
try {
|
||||
const raw = fs.readFileSync(configFilePath, 'utf8');
|
||||
const updated = JSON.parse(raw);
|
||||
Object.assign(config, updated);
|
||||
if (config.sql) {
|
||||
config.sql.host = process.env.DB_HOST || process.env.POSTGRES_HOST || process.env.PGHOST || config.sql.host;
|
||||
config.sql.port = parseInt(process.env.DB_PORT || process.env.POSTGRES_PORT || process.env.PGPORT || config.sql.port, 10);
|
||||
config.sql.user = process.env.DB_USER || process.env.POSTGRES_USER || process.env.PGUSER || config.sql.user;
|
||||
config.sql.password = process.env.DB_PASS || process.env.POSTGRES_PASSWORD || process.env.PGPASSWORD || config.sql.password;
|
||||
config.sql.database = process.env.DB_NAME || process.env.POSTGRES_DB || process.env.PGDATABASE || config.sql.database;
|
||||
}
|
||||
console.log('[CONFIG] config.json reloaded dynamically');
|
||||
} catch (_) {}
|
||||
}, 100);
|
||||
if (watchDebounceTimer.unref) watchDebounceTimer.unref();
|
||||
}
|
||||
});
|
||||
if (watcher.unref) watcher.unref();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -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; };
|
||||
+24
-357
@@ -4,7 +4,6 @@ import db from "./sql.mjs";
|
||||
|
||||
import cfg from "./config.mjs";
|
||||
import { createI18n } from "./i18n.mjs";
|
||||
import { getEnableAnonymousAccess, isAnonymizeSession, canAnonDo, canUseChan } from "./settings.mjs";
|
||||
|
||||
|
||||
|
||||
@@ -39,28 +38,7 @@ export default new class {
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
generateSlug(length = 11) {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
|
||||
let slug = '';
|
||||
const bytes = crypto.randomBytes(length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
slug += chars[bytes[i] % chars.length];
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
formatSize(size) {
|
||||
if (size === undefined || size === null || size === '') return '0 B';
|
||||
if (typeof size === 'string') {
|
||||
if (/^\d+(\.\d+)?\s*(B|kB|KB|MB|GB|TB)$/i.test(size.trim())) {
|
||||
return size.trim();
|
||||
}
|
||||
const num = Number(size);
|
||||
if (isNaN(num)) return '0 B';
|
||||
size = num;
|
||||
}
|
||||
if (isNaN(size) || size <= 0) return '0 B';
|
||||
const i = Math.min(4, Math.max(0, ~~(Math.log(size) / Math.log(1024))));
|
||||
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
|
||||
};
|
||||
calcSpeed(b, s) {
|
||||
@@ -75,29 +53,6 @@ export default new class {
|
||||
const timeStr = t(unitKey, { n: interval });
|
||||
return t('timeago.ago', { t: timeStr });
|
||||
};
|
||||
expiresIn(expiresAt) {
|
||||
if (!expiresAt) return null;
|
||||
const expiresNum = parseInt(expiresAt, 10);
|
||||
if (isNaN(expiresNum)) return null;
|
||||
const now = ~~(Date.now() / 1000);
|
||||
const diff = expiresNum - now;
|
||||
if (diff <= 0) return "expiring now";
|
||||
|
||||
if (diff < 60) return `in ${diff}s`;
|
||||
if (diff < 3600) {
|
||||
const mins = Math.floor(diff / 60);
|
||||
return `in ${mins} minute${mins === 1 ? '' : 's'}`;
|
||||
}
|
||||
if (diff < 86400) {
|
||||
const hours = Math.floor(diff / 3600);
|
||||
const mins = Math.floor((diff % 3600) / 60);
|
||||
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
|
||||
}
|
||||
const days = Math.floor(diff / 86400);
|
||||
const hours = Math.floor((diff % 86400) / 3600);
|
||||
return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
|
||||
};
|
||||
|
||||
md5(str) {
|
||||
return crypto.createHash('md5').update(str).digest("hex");
|
||||
};
|
||||
@@ -107,58 +62,25 @@ export default new class {
|
||||
getMode(mode) {
|
||||
let tmp;
|
||||
mode = Number(mode);
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
switch (mode) {
|
||||
case 1: // nsfw
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 2)";
|
||||
break;
|
||||
case 2: // untagged
|
||||
tmp = `not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`;
|
||||
tmp = "not exists (select 1 from tags_assign where item_id = items.id)";
|
||||
break;
|
||||
case 3: // all
|
||||
tmp = "1 = 1";
|
||||
break;
|
||||
case 4: // nsfl
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${nsflId})` : "1 = 0";
|
||||
tmp = cfg.enable_nsfl ? `items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})` : "1 = 0";
|
||||
break;
|
||||
default: // sfw
|
||||
tmp = `(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})))`;
|
||||
tmp = "items.id in (select item_id from tags_assign where tag_id = 1)";
|
||||
break;
|
||||
}
|
||||
return tmp;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a multi-rating SQL WHERE clause fragment from an array of rating strings.
|
||||
* Supported values: 'sfw', 'nsfw', 'nsfl', 'untagged'
|
||||
* Returns null if the ratings array is empty or contains all possible values (treat as ALL).
|
||||
*/
|
||||
getMultiRatingMode(ratings) {
|
||||
if (!Array.isArray(ratings) || ratings.length === 0) return null;
|
||||
const valid = ['sfw', 'nsfw', 'nsfl', 'untagged'];
|
||||
const filtered = ratings.filter(r => valid.includes(r));
|
||||
if (filtered.length === 0) return null;
|
||||
// If all 4 are selected, treat as ALL
|
||||
if (filtered.includes('sfw') && filtered.includes('nsfw') && filtered.includes('untagged') &&
|
||||
(!cfg.enable_nsfl || filtered.includes('nsfl'))) return '1 = 1';
|
||||
|
||||
const parts = [];
|
||||
if (filtered.includes('sfw')) {
|
||||
parts.push('items.id in (select item_id from tags_assign where tag_id = 1)');
|
||||
}
|
||||
if (filtered.includes('nsfw')) {
|
||||
parts.push('items.id in (select item_id from tags_assign where tag_id = 2)');
|
||||
}
|
||||
if (filtered.includes('nsfl') && cfg.enable_nsfl) {
|
||||
parts.push(`items.id in (select item_id from tags_assign where tag_id = ${parseInt(cfg.nsfl_tag_id, 10) || 3})`);
|
||||
}
|
||||
if (filtered.includes('untagged')) {
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
parts.push(`not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId}))`);
|
||||
}
|
||||
if (parts.length === 0) return null;
|
||||
return '(' + parts.join(' OR ') + ')';
|
||||
};
|
||||
createID() {
|
||||
return crypto.randomBytes(16).toString("hex") + Date.now().toString(24);
|
||||
};
|
||||
@@ -170,7 +92,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('/'))
|
||||
@@ -180,16 +101,9 @@ export default new class {
|
||||
|
||||
// Build suffix with query params
|
||||
let suffix = env.strict ? '?strict=1' : '';
|
||||
if (env.tagger) suffix += (suffix ? '&' : '?') + `tagger=${encodeURIComponent(env.tagger)}`;
|
||||
|
||||
// mainDisplay: decoded for human-readable display (e.g. div.location)
|
||||
// main: keeps percent-encoding for use in href attributes
|
||||
let mainDisplay = tmp;
|
||||
try { mainDisplay = decodeURIComponent(tmp); } catch (_) {}
|
||||
|
||||
return {
|
||||
main: tmp,
|
||||
mainDisplay,
|
||||
path: env.path ? env.path : '',
|
||||
suffix: suffix
|
||||
};
|
||||
@@ -215,28 +129,27 @@ export default new class {
|
||||
const tagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsflId = parseInt(cfg.nsfl_tag_id, 10) || 3;
|
||||
const untagged = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where not exists (select 1 from tags_assign where item_id = items.id and tag_id in (1, 2, ${nsflId})) and active = true and is_deleted = false
|
||||
where not exists (select 1 from tags_assign where item_id = items.id) and active = true
|
||||
`)[0].total;
|
||||
const sfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign where tag_id = 1 group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsfw = +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign where tag_id = 2 group by item_id) and active = true
|
||||
`)[0].total;
|
||||
const nsfl = cfg.enable_nsfl ? +(await db`
|
||||
select count(*) as total
|
||||
from "items"
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true and is_deleted = false
|
||||
where id in (select item_id from tags_assign where tag_id = ${cfg.nsfl_tag_id || 3} group by item_id) and active = true
|
||||
`)[0].total : 0;
|
||||
const deleted = +(await db`
|
||||
select count(*) as total
|
||||
@@ -270,135 +183,25 @@ export default new class {
|
||||
return "$f0ck$" + salt + ":" + derivedKey.toString("hex");
|
||||
};
|
||||
async verify(str, hash) {
|
||||
if (typeof hash !== 'string') return false;
|
||||
|
||||
if (hash.startsWith("$f0ck$")) {
|
||||
const parts = hash.substring(6).split(":");
|
||||
if (parts.length !== 2) return false;
|
||||
const [salt, key] = parts;
|
||||
try {
|
||||
const keyBuffer = Buffer.from(key, "hex");
|
||||
const derivedKey = await scrypt(str, salt, 64);
|
||||
return crypto.timingSafeEqual(keyBuffer, derivedKey);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hash.length === 32) {
|
||||
return this.md5(str) === hash;
|
||||
}
|
||||
|
||||
if (hash.length === 64) {
|
||||
return this.sha256(str) === hash;
|
||||
}
|
||||
|
||||
return false;
|
||||
const [salt, key] = hash.substring(6).split(":");
|
||||
const keyBuffer = Buffer.from(key, "hex");
|
||||
const derivedKey = await scrypt(str, salt, 64);
|
||||
return crypto.timingSafeEqual(keyBuffer, derivedKey);
|
||||
};
|
||||
|
||||
userHasFavorited(session, favorites) {
|
||||
if (!session || !Array.isArray(favorites) || favorites.length === 0) return false;
|
||||
const sessId = session.id ? Number(session.id) : (session.user_id ? Number(session.user_id) : null);
|
||||
const sessUser = (session.user || '').toLowerCase();
|
||||
const sessLogin = (session.login || '').toLowerCase();
|
||||
const sessAnonLogin = (session.anon_login || '').toLowerCase();
|
||||
|
||||
return favorites.some(f => {
|
||||
const fUserId = f.user_id ? Number(f.user_id) : (f.id ? Number(f.id) : null);
|
||||
if (sessId && fUserId && fUserId === sessId) return true;
|
||||
const fUser = (f.user || '').toLowerCase();
|
||||
const fLogin = (f.login || '').toLowerCase();
|
||||
if (sessUser && sessUser !== 'anonymous' && (fUser === sessUser || fLogin === sessUser)) return true;
|
||||
if (sessLogin && (fUser === sessLogin || fLogin === sessLogin)) return true;
|
||||
if (sessAnonLogin && (fUser === sessAnonLogin || fLogin === sessAnonLogin)) return true;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
async getTags(itemid, session = null, subf0ckId = null) {
|
||||
const isAnonymized = isAnonymizeSession(session);
|
||||
const hasSession = !isAnonymized && !!(session && !session.is_anon && (typeof session === 'object' ? (session.id || session.user) : session));
|
||||
|
||||
let albumItemId = null;
|
||||
if (subf0ckId !== null && subf0ckId !== undefined && subf0ckId !== '') {
|
||||
if (Number.isInteger(+subf0ckId) && +subf0ckId > 0) {
|
||||
const row = await db`
|
||||
SELECT id FROM album_items
|
||||
WHERE (id = ${+subf0ckId} OR (item_id = ${+itemid} AND order_index = ${+subf0ckId}))
|
||||
AND item_id = ${+itemid}
|
||||
LIMIT 1
|
||||
`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
if (!albumItemId) {
|
||||
const row = await db`
|
||||
SELECT id FROM album_items
|
||||
WHERE slug = ${String(subf0ckId)}
|
||||
AND item_id = ${+itemid}
|
||||
LIMIT 1
|
||||
`;
|
||||
albumItemId = row?.[0]?.id || null;
|
||||
}
|
||||
}
|
||||
|
||||
let tags;
|
||||
if (albumItemId) {
|
||||
tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "album_items_tags_assign"
|
||||
left join "tags" on "tags".id = "album_items_tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "album_items_tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "album_items_tags_assign".album_item_id = ${+albumItemId}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
|
||||
// If album sub-item has no rating tag of its own, inherit the parent post's rating tag
|
||||
const hasSubRating = tags.some(t => ['sfw', 'nsfw', 'nsfl'].includes(t.normalized) || t.id === 1 || t.id === 2);
|
||||
if (!hasSubRating) {
|
||||
const parentRatingTags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
and ("tags".id in (1, 2) or "tags".normalized in ('sfw', 'nsfw', 'nsfl'))
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc
|
||||
limit 1
|
||||
`;
|
||||
if (parentRatingTags.length > 0) {
|
||||
tags.unshift(parentRatingTags[0]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized${hasSession ? db`, "user".user, uo.display_name` : db``}
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
${hasSession ? db`left join "user" on "user".id = "tags_assign".user_id left join user_options uo on uo.user_id = "user".id` : db``}
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".normalized = 'nsfl' then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
}
|
||||
let hasRating = false;
|
||||
const cleanTags = [];
|
||||
const excludedTagIds = (session && Array.isArray(session.excluded_tags)) ? session.excluded_tags : [];
|
||||
const canExclude = (session && !session.is_anon) ? true : canAnonDo('exclude_tags');
|
||||
async getTags(itemid) {
|
||||
const tags = await db`
|
||||
select "tags".id, "tags".tag, "tags".normalized, "user".user, uo.display_name
|
||||
from "tags_assign"
|
||||
left join "tags" on "tags".id = "tags_assign".tag_id
|
||||
left join "user" on "user".id = "tags_assign".user_id
|
||||
left join user_options uo on uo.user_id = "user".id
|
||||
where "tags_assign".item_id = ${+itemid}
|
||||
order by (case when "tags".id = 1 then 0 when "tags".id = 2 then 1 when "tags".id = ${cfg.nsfl_tag_id || 3} then 2 else 3 end) asc, "tags".id asc
|
||||
`;
|
||||
for (let t = 0; t < tags.length; t++) {
|
||||
const isRating = ['sfw', 'nsfw', 'nsfl'].includes(tags[t].normalized);
|
||||
if (isRating) {
|
||||
if (hasRating) continue;
|
||||
hasRating = true;
|
||||
}
|
||||
tags[t].badge = this.getBadge(tags[t]);
|
||||
tags[t].is_excluded = !isRating && excludedTagIds.includes(tags[t].id);
|
||||
tags[t].can_exclude = canExclude;
|
||||
if (!hasSession) {
|
||||
delete tags[t].user;
|
||||
delete tags[t].display_name;
|
||||
}
|
||||
cleanTags.push(tags[t]);
|
||||
}
|
||||
return cleanTags;
|
||||
return tags;
|
||||
};
|
||||
getBadge(tagObj) {
|
||||
if (tagObj.tag.startsWith(">"))
|
||||
@@ -466,46 +269,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,88 +314,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'];
|
||||
if (!key) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: JSON.stringify({ success: false, msg: 'API key required' }),
|
||||
type: 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
let row;
|
||||
try {
|
||||
const rows = await db`
|
||||
SELECT
|
||||
u.id, u.user, u.login, u.admin, u.is_moderator, u.banned,
|
||||
uo.display_name, uo.mode, uo.theme, uo.avatar, uo.avatar_file,
|
||||
uo.username_color, uo.show_motd, uo.disable_autoplay,
|
||||
uo.disable_swiping, uo.use_new_layout, uo.excluded_tags,
|
||||
uo.ruffle_background, uo.ruffle_volume, uo.quote_emojis,
|
||||
uo.embed_youtube_in_comments, uo.hide_koepfe,
|
||||
uo.use_alternative_infobox, uo.language, uo.comment_display_mode,
|
||||
uo.force_comment_display_mode, uo.min_xd_score, uo.show_background,
|
||||
uo.font, uo.receive_system_notifications, uo.receive_user_notifications,
|
||||
uo.do_not_disturb, uo.description
|
||||
FROM user_api_keys k
|
||||
JOIN "user" u ON u.id = k.user_id
|
||||
LEFT JOIN user_options uo ON uo.user_id = u.id
|
||||
WHERE k.api_key = ${key}
|
||||
LIMIT 1
|
||||
`;
|
||||
row = rows[0];
|
||||
} catch (err) {
|
||||
console.error('[API KEY AUTH] DB error:', err);
|
||||
return res.reply({
|
||||
code: 500,
|
||||
body: JSON.stringify({ success: false, msg: 'Internal server error' }),
|
||||
type: 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return res.reply({
|
||||
code: 401,
|
||||
body: JSON.stringify({ success: false, msg: 'Invalid API key' }),
|
||||
type: 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
if (row.banned) {
|
||||
return res.reply({
|
||||
code: 403,
|
||||
body: JSON.stringify({ success: false, msg: 'Account banned' }),
|
||||
type: 'application/json'
|
||||
});
|
||||
}
|
||||
|
||||
req.session = { ...row, api_key_auth: true };
|
||||
return next();
|
||||
};
|
||||
|
||||
getCookieOptions(expires = null, httpOnly = true) {
|
||||
const isSecure = cfg.main.url.full && cfg.main.url.full.startsWith('https');
|
||||
let options = "Path=/; SameSite=Lax";
|
||||
@@ -653,25 +341,4 @@ export default new class {
|
||||
console.error(`[ERROR REF ${errId}] ${context}:`, err);
|
||||
return `Internal Error. Reference: ${errId}`;
|
||||
}
|
||||
|
||||
isOnionRequest(req) {
|
||||
if (!req || !req.headers) return false;
|
||||
const rawHost = req.headers['x-forwarded-host'] || req.headers['host'] || req.headers['x-forwarded-server'] || '';
|
||||
if (!rawHost) return false;
|
||||
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
|
||||
const firstHost = hostStr.split(',')[0].trim();
|
||||
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
|
||||
return hostNoPort.endsWith('.onion');
|
||||
}
|
||||
|
||||
isLocalhostRequest(req) {
|
||||
if (!req) return false;
|
||||
const rawHost = req.headers?.['x-forwarded-host'] || req.headers?.['host'] || req.headers?.['x-forwarded-server'] || '';
|
||||
if (!rawHost) return false;
|
||||
const hostStr = Array.isArray(rawHost) ? rawHost[0] : String(rawHost);
|
||||
const firstHost = hostStr.split(',')[0].trim();
|
||||
const hostNoPort = firstHost.split(':')[0].trim().toLowerCase();
|
||||
return hostNoPort === 'localhost' || hostNoPort === '127.0.0.1' || hostNoPort === '::1';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import db from './sql.mjs';
|
||||
import cfg from './config.mjs';
|
||||
import { removePrivateItem } from './private_items.mjs';
|
||||
|
||||
/**
|
||||
* Safely remove the media file for a deleted item.
|
||||
@@ -205,68 +204,3 @@ export async function moveToDeleted(dest, deletedId) {
|
||||
await fs.unlink(srcPath).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Periodically scan for and purge expired uploads (expires_at <= now).
|
||||
* Unlinks media files (thumbnails, coverarts, main image) via safeDeleteMediaFile,
|
||||
* and sets is_deleted = true, is_purged = true, active = false in DB.
|
||||
*/
|
||||
export async function purgeExpiredUploads() {
|
||||
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const now = ~~(Date.now() / 1000);
|
||||
const expiredItems = await db`
|
||||
SELECT id, dest, mime
|
||||
FROM items
|
||||
WHERE expires_at IS NOT NULL
|
||||
AND expires_at <= ${now}
|
||||
AND is_purged = false
|
||||
`;
|
||||
if (expiredItems.length > 0) {
|
||||
console.log(`[EXPIRING UPLOADS] Found ${expiredItems.length} expired item(s) to purge.`);
|
||||
for (const item of expiredItems) {
|
||||
try {
|
||||
if (item.dest) {
|
||||
await safeDeleteMediaFile(item.dest, item.id);
|
||||
}
|
||||
await safeDeleteAlbumFiles(item.id);
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
|
||||
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
|
||||
if (item.mime && item.mime.startsWith('audio')) {
|
||||
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => {});
|
||||
}
|
||||
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
|
||||
removePrivateItem(item.id, item.dest);
|
||||
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
|
||||
} catch (e) {
|
||||
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[EXPIRING UPLOADS] Failed running purgeExpiredUploads check:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely delete all album image files associated with an item.
|
||||
* @param {number} itemId
|
||||
*/
|
||||
export async function safeDeleteAlbumFiles(itemId) {
|
||||
try {
|
||||
const albumRows = await db`SELECT dest FROM album_items WHERE item_id = ${itemId}`;
|
||||
for (const row of albumRows) {
|
||||
if (row.dest) {
|
||||
await safeDeleteMediaFile(row.dest, itemId);
|
||||
const thumbName = row.dest.replace(/\.[^.]+$/, '.webp');
|
||||
await fs.unlink(path.join(cfg.paths.t, thumbName)).catch(() => {});
|
||||
}
|
||||
}
|
||||
await db`DELETE FROM album_items WHERE item_id = ${itemId}`.catch(() => {});
|
||||
} catch (e) {
|
||||
console.error(`[DELETE] Failed to delete album files for item #${itemId}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-176
@@ -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",
|
||||
@@ -71,17 +70,7 @@
|
||||
"btn_add_urls": "URL(s) hinzufügen",
|
||||
"tags_required_shitpost": "Alle Beiträge benötigen Tags",
|
||||
"url_queued_background": "URL Verarbeitung im Hintergrund gestartet.",
|
||||
"add_more": "Mehr hinzufügen",
|
||||
"url_tracker_title": "URL-Uploads",
|
||||
"url_tracker_queued": "Warteschlange",
|
||||
"url_tracker_downloading": "Wird heruntergeladen",
|
||||
"url_tracker_analyzing": "Wird analysiert",
|
||||
"url_tracker_saving": "Wird gespeichert",
|
||||
"url_tracker_processing": "Wird verarbeitet",
|
||||
"url_tracker_extracting": "Wird extrahiert",
|
||||
"url_tracker_complete": "Fertig!",
|
||||
"url_tracker_failed": "Upload fehlgeschlagen",
|
||||
"url_tracker_view": "Ansehen →"
|
||||
"add_more": "Mehr hinzufügen"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Wird registriert...",
|
||||
@@ -131,7 +120,6 @@
|
||||
"switching": "Wird umgeschaltet...",
|
||||
"generating": "Wird generiert...",
|
||||
"title": "Einstellungen",
|
||||
"profile": "Profil",
|
||||
"avatar": "Avatar",
|
||||
"current_avatar": "Aktueller Avatar",
|
||||
"upload_custom_avatar": "Eigenen Avatar hochladen",
|
||||
@@ -140,52 +128,30 @@
|
||||
"no_file_selected": "Keine Datei ausgewählt",
|
||||
"upload_btn": "Hochladen",
|
||||
"remove_custom": "Eigenen entfernen",
|
||||
"current_banner": "Aktuelles Banner",
|
||||
"banner_hint": "Max. 5 MB. Erlaubt: gif, jpg, png, webp",
|
||||
"no_banner_set": "Kein Banner gesetzt",
|
||||
"custom_description": "Eigene Beschreibung",
|
||||
"description_placeholder": "Beschreibung zum Profil hinzufügen",
|
||||
"save_description": "Beschreibung speichern",
|
||||
"clear": "Löschen",
|
||||
"preferences": "Einstellungen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"content_preferences_section": "Inhaltseinstellungen",
|
||||
"appearance_section": "Erscheinungsbild",
|
||||
"show_motd": "Nachricht des Tages (MOTD) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
"feed_layout": "Feed-Layout",
|
||||
"feed_layout_hint": "Wähle, wie die Hauptseite Beiträge anzeigt",
|
||||
"feed_layout_grid": "Raster (Kompakt)",
|
||||
"feed_layout_modern": "Raster (3-spaltig Modern)",
|
||||
"feed_layout_feed": "Feed (X / Instagram-Stil)",
|
||||
"feed_layout_youtube": "YouTube-Stil",
|
||||
"alternative_infobox": "Alternativer Autor-Infoblock",
|
||||
"alternative_infobox_hint": "Zeigt einen erweiterten Autor-Block mit Avatar und Bio auf Beitragsseiten",
|
||||
"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).",
|
||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||
"disable_autoplay_hint": "Verhindert die automatische Wiedergabe von Videos und Audio",
|
||||
"hide_item_ratings": "Item-Bewertungen ausblenden",
|
||||
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||
"disable_swiping": "Wischen deaktivieren",
|
||||
"disable_swiping_hint": "Navigation per Wischen auf Mobilgeräten deaktivieren",
|
||||
"favorites_private": "Private Favoriten",
|
||||
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||
"hide_fav_badge": "Favoriten-Badge-Avatar verbergen",
|
||||
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten stattdessen ein Geist-Icon ohne Profilverlinkung an",
|
||||
"default_upload_visibility": "Standard Upload-Sichtbarkeit",
|
||||
"default_upload_visibility_hint": "Lege die Standard-Sichtbarkeit für deine neuen Uploads fest.",
|
||||
"image_expand_on_click": "Bilder beim Klicken inline erweitern",
|
||||
"image_expand_on_click_hint": "Anstatt das Scroll-Zoom-Modal zu öffnen, wird ein Bild beim Klicken innerhalb der Seite auf volle Größe erweitert.",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
"enable_bg_blur_hint": "Unscharfen Hintergrund bei Beiträgen anzeigen",
|
||||
"blur_nsfw": "NSFW weichzeichnen",
|
||||
"blur_nsfw_hint": "Zeichnet NSFW-Vorschaubilder weich.",
|
||||
"blur_nsfl": "NSFL weichzeichnen",
|
||||
"blur_nsfl_hint": "Zeichnet NSFL-Vorschaubilder weich.",
|
||||
"blur_sfw": "SFW weichzeichnen",
|
||||
"blur_sfw_hint": "Zeichnet SFW-Vorschaubilder weich.",
|
||||
"blur_untagged": "Unmarkierte weichzeichnen",
|
||||
"blur_untagged_hint": "Zeichnet unmarkierte Vorschaubilder weich.",
|
||||
"blur_detail": "Beiträge weichzeichnen",
|
||||
"blur_detail_hint": "Erfordert das Anklicken zum Anzeigen von weichgezeichneten Medien auf der Beitragsseite.",
|
||||
"render_emojis": "Emojis in Zitatantworten anzeigen",
|
||||
"render_emojis_hint": ":emoji:-Bilder in >zitierten Zeilen anzeigen",
|
||||
"embed_yt": "YouTube-Videos in Kommentaren einbetten",
|
||||
@@ -194,7 +160,7 @@
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||
"comment_display_linear": "Linear",
|
||||
"comment_display_linear": "Linear / Flach (4chan-Stil)",
|
||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||
"forced_mode_notice": "Diese Einstellung wird von einem Administrator verwaltet.",
|
||||
"language": "Sprache",
|
||||
@@ -271,12 +237,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 +244,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Bild",
|
||||
"flash": "Flash",
|
||||
"grid_mode": "Thumbnailgröße"
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Tastaturkürzel",
|
||||
@@ -299,8 +258,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",
|
||||
@@ -309,9 +266,7 @@
|
||||
"toggle_rating": "Beitragsbewertung umschalten",
|
||||
"play_pause": "Abspielen/Pause",
|
||||
"next_prev": "Weiter/Zurück",
|
||||
"volume": "Lautstärke",
|
||||
"scroll_nav": "Weiter/Zurück",
|
||||
"frame_skip": "Einzelbildsteuerung (pausiert) / 5s Sprung",
|
||||
"show_help": "Hilfe anzeigen"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -348,11 +303,7 @@
|
||||
"scroll_for_more": "Scrollen für mehr..."
|
||||
},
|
||||
"error": {
|
||||
"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"
|
||||
"label": "Fehler"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Überall ablegen zum Hochladen"
|
||||
@@ -364,20 +315,7 @@
|
||||
"attach_file": "Datei anhängen",
|
||||
"uploading_file": "Wird hochgeladen...",
|
||||
"remove_file": "Datei entfernen",
|
||||
"file_too_large": "Datei zu groß",
|
||||
"poll_btn_title": "Umfrage erstellen",
|
||||
"poll_question_placeholder": "Umfragefrage...",
|
||||
"poll_option_placeholder": "Option...",
|
||||
"poll_add_option": "Option hinzufügen",
|
||||
"poll_remove": "Umfrage entfernen",
|
||||
"poll_vote": "Abstimmen",
|
||||
"poll_voted": "Abgestimmt",
|
||||
"poll_votes": "Stimmen",
|
||||
"poll_vote_single": "Stimme",
|
||||
"poll_delete": "Umfrage löschen",
|
||||
"poll_expired": "Umfrage geschlossen",
|
||||
"poll_anonymous": "Anonym",
|
||||
"poll_public": "Öffentliche Stimmen"
|
||||
"file_too_large": "Datei zu groß"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Datei auswählen",
|
||||
@@ -404,11 +342,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,21 +409,11 @@
|
||||
"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.",
|
||||
"loading_more": "Laden…",
|
||||
"end_of_activity": "─ Ende der Aktivität ─",
|
||||
"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",
|
||||
@@ -510,13 +434,13 @@
|
||||
},
|
||||
"ranking": {
|
||||
"title": "Rangliste",
|
||||
"top_contributors": "Größte Etikettierer",
|
||||
"top_contributors": "Top-Mitwirkende",
|
||||
"col_rank": "Rang",
|
||||
"col_avatar": "Avatar",
|
||||
"col_username": "Nutzername",
|
||||
"col_tagged": "Markiert",
|
||||
"tag_stats": "Statistiken",
|
||||
"stat_total": "Gesamt Inhalte",
|
||||
"stat_total": "Gesamt",
|
||||
"stat_tagged": "Markiert",
|
||||
"stat_untagged": "Unmarkiert",
|
||||
"stat_sfw": "SFW-Inhalte",
|
||||
@@ -526,8 +450,6 @@
|
||||
"stat_comments": "Gesamt Kommentare",
|
||||
"stat_favs": "Gesamt Favoriten",
|
||||
"stat_disk_usage": "Dateigröße Gesamt",
|
||||
"stat_users": "Gesamt Benutzer",
|
||||
"stat_anon_users": "Anonyme Benutzer",
|
||||
"most_favorited": "Meiste Favs",
|
||||
"favs": "Favs",
|
||||
"top_xd": "Top xD-Score"
|
||||
@@ -563,10 +485,6 @@
|
||||
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
|
||||
"no_uploads": "Keine Uploads gefunden",
|
||||
"no_favs": "Keine Favoriten",
|
||||
"guest_favs_saved": "Du hast {count} Gast-Favoriten auf diesem Gerät gespeichert.",
|
||||
"sync_guest_favs": "In Account importieren",
|
||||
"guest_favs_imported": "Favoriten erfolgreich in deinen Account importiert!",
|
||||
"private_favorites": "private Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
"ban_modal_reason": "Grund:",
|
||||
@@ -604,24 +522,6 @@
|
||||
"found": "Gefundene Metadaten:",
|
||||
"no_results": "Keine weiteren Metadaten in dieser Datei gefunden."
|
||||
},
|
||||
"info_modal": {
|
||||
"title": "Post- & Datei-Informationen",
|
||||
"button_title": "Post- & Datei-Informationen",
|
||||
"id": "Post-ID",
|
||||
"source": "Quelle",
|
||||
"uploader": "Hochgeladen von",
|
||||
"uploaded_at": "Hochgeladen am",
|
||||
"file_size": "Dateigröße",
|
||||
"mime_type": "MIME-Typ",
|
||||
"rating": "Bewertung",
|
||||
"oc": "Originaler Inhalt (OC)",
|
||||
"no": "Nein",
|
||||
"direct_url": "Direkt-Link",
|
||||
"view_file": "Datei anzeigen",
|
||||
"metadata": "Metadaten",
|
||||
"sha256": "SHA-256-Hash",
|
||||
"dimensions": "Abmessungen"
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Textebene hinzufügen",
|
||||
"tags_label": "Tags (kommagetrennt)",
|
||||
@@ -632,12 +532,7 @@
|
||||
"text_layer": "Textebene",
|
||||
"enter_text": "Text eingeben...",
|
||||
"size_label": "Größe",
|
||||
"create_meme": "Meme erstellen:",
|
||||
"custom_template_title": "Eigene Vorlage",
|
||||
"custom_template_tag": "Lokale Datei",
|
||||
"select_image": "Eigenes Bild auswählen",
|
||||
"choose_file_btn": "Bild aussuchen",
|
||||
"choose_image_first": "Bitte wählen Sie zuerst ein Bild aus!"
|
||||
"create_meme": "Meme erstellen:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "gerade eben",
|
||||
@@ -668,8 +563,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 +635,6 @@
|
||||
"settings": "Einstellungen",
|
||||
"filters": "Filter",
|
||||
"volume": "Lautstärke",
|
||||
"clear_filter": "Filter löschen",
|
||||
"reset_all": "Alles zurücksetzen",
|
||||
"rating": "Bewertung",
|
||||
"all": "Alle",
|
||||
@@ -805,57 +697,5 @@
|
||||
"left_hand_desc": "Du weißt bescheid.",
|
||||
"replying_to": "Antwort an {user}",
|
||||
"reply": "Antworten"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Einladungen",
|
||||
"section_desc": "Lade neue Nutzer ein. Du musst alle unten stehenden Kriterien erfüllen, um Einladungstokens zu generieren.",
|
||||
"eligible": "✓ Du bist berechtigt, Einladungen zu generieren.",
|
||||
"not_eligible": "✗ Du erfüllst noch nicht alle Kriterien.",
|
||||
"slots_used": "{used} / {total} Einladungsslots genutzt",
|
||||
"criteria_uploads": "Uploads",
|
||||
"criteria_age": "Kontoalter",
|
||||
"criteria_comments": "Kommentare",
|
||||
"criteria_tags": "Vergebene Tags",
|
||||
"criteria_days": " Tage",
|
||||
"generate_btn": "Einladung generieren",
|
||||
"generating": "Wird generiert…",
|
||||
"loading": "Wird geladen…",
|
||||
"no_invites": "Noch keine Einladungstokens generiert.",
|
||||
"status_unused": "Ungenutzt",
|
||||
"status_used_by": "Genutzt von {user}",
|
||||
"copy_btn": "Kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"delete_btn": "Löschen",
|
||||
"delete_confirm": "Diesen Einladungstoken löschen?",
|
||||
"slot_refreshes_on": "Slot erneuert sich am {date}",
|
||||
"slot_refreshed": "Slot erneuert",
|
||||
"admin_desc": "Du bist Admin, leg los.",
|
||||
"visibility": {
|
||||
"public": "Öffentlich",
|
||||
"unlisted": "Nicht gelistet",
|
||||
"private": "Privat",
|
||||
"change_visibility": "Sichtbarkeit ändern"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"title": "Album",
|
||||
"multiple_selected": "Mehrere Dateien ausgewählt",
|
||||
"mode_album": "Album (1 Beitrag)",
|
||||
"mode_batch": "Einzelne Beiträge",
|
||||
"cover": "Titelbild",
|
||||
"pictures": "Subf0cks",
|
||||
"counter": "%s von %s",
|
||||
"prev": "Vorheriger Subf0ck",
|
||||
"next": "Nächster Subf0ck",
|
||||
"hotkey_tip": "[ und ] oder Maus über Album zum Navigieren",
|
||||
"move_left": "Nach links",
|
||||
"move_right": "Nach rechts",
|
||||
"remove_picture": "Subf0ck entfernen",
|
||||
"add_more": "Weitere Subf0cks hinzufügen",
|
||||
"drop_hint": "Wähle oder ziehe mehrere Dateien (Subf0cks) hierher, um ein Album zu erstellen",
|
||||
"select_pictures": "Dateien für Album auswählen",
|
||||
"min_pictures": "Mindestens 2 Subf0cks für ein Album erforderlich",
|
||||
"subf0ck_tags": "Tags für diesen Subf0ck",
|
||||
"subf0ck_tags_placeholder": "Tags für diesen Subf0ck (optional)"
|
||||
}
|
||||
}
|
||||
+21
-179
@@ -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,29 +59,18 @@
|
||||
"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",
|
||||
"cancel_upload": "Cancel Upload",
|
||||
"shitpost_success": "Successfully shitposted {n} items!",
|
||||
"shitposting_status": "Uploading",
|
||||
"item_comment_placeholder": "Write a Comment...",
|
||||
"shitposting_status": "Shitposting",
|
||||
"item_comment_placeholder": "Comment (optional)...",
|
||||
"item_tags_placeholder": "Tags...",
|
||||
"btn_add_urls": "Add URL(s)",
|
||||
"tags_required_shitpost": "All items need tags",
|
||||
"url_queued_background": "URL processing started in background.",
|
||||
"add_more": "Add more",
|
||||
"url_tracker_title": "URL Uploads",
|
||||
"url_tracker_queued": "Queued",
|
||||
"url_tracker_downloading": "Downloading",
|
||||
"url_tracker_analyzing": "Analyzing",
|
||||
"url_tracker_saving": "Saving",
|
||||
"url_tracker_processing": "Processing",
|
||||
"url_tracker_extracting": "Extracting",
|
||||
"url_tracker_complete": "Complete!",
|
||||
"url_tracker_failed": "Upload failed",
|
||||
"url_tracker_view": "View →"
|
||||
"add_more": "Add more"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Registering...",
|
||||
@@ -131,7 +120,6 @@
|
||||
"switching": "Switching...",
|
||||
"generating": "Generating...",
|
||||
"title": "Settings",
|
||||
"profile": "Profile",
|
||||
"avatar": "Avatar",
|
||||
"current_avatar": "Current Avatar",
|
||||
"upload_custom_avatar": "Upload Custom Avatar",
|
||||
@@ -140,52 +128,30 @@
|
||||
"no_file_selected": "No file selected",
|
||||
"upload_btn": "Upload",
|
||||
"remove_custom": "Remove Custom",
|
||||
"current_banner": "Current Banner",
|
||||
"banner_hint": "Max 5MB. Allowed: gif, jpg, png, webp",
|
||||
"no_banner_set": "No banner set",
|
||||
"custom_description": "Custom Description",
|
||||
"description_placeholder": "Add a description to your profile",
|
||||
"save_description": "Save Description",
|
||||
"clear": "Clear",
|
||||
"preferences": "Preferences",
|
||||
"ui_section": "User Interface",
|
||||
"content_preferences_section": "Content Preferences",
|
||||
"appearance_section": "Appearance",
|
||||
"show_motd": "Show Message of the Day (MOTD)",
|
||||
"modern_layout": "Modern layout",
|
||||
"modern_layout_hint": "3 Column Layout",
|
||||
"feed_layout": "Feed Layout",
|
||||
"feed_layout_hint": "Choose how the main page displays posts",
|
||||
"feed_layout_grid": "Grid (Compact)",
|
||||
"feed_layout_modern": "Grid (3-column Modern)",
|
||||
"feed_layout_feed": "Feed (X / Instagram style)",
|
||||
"feed_layout_youtube": "YouTube Style",
|
||||
"alternative_infobox": "Alternative Author Infobox",
|
||||
"alternative_infobox_hint": "Show a rich author card with avatar and bio on item pages",
|
||||
"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).",
|
||||
"disable_autoplay": "Disable Autoplay",
|
||||
"disable_autoplay_hint": "Prevent videos and audio from playing automatically",
|
||||
"hide_item_ratings": "Hide Item Ratings",
|
||||
"hide_item_ratings_hint": "Don't show SFW/NSFW rating indicators on main page items",
|
||||
"disable_swiping": "Disable Swiping",
|
||||
"disable_swiping_hint": "Disable swipe-to-navigate on mobile devices",
|
||||
"favorites_private": "Private Favorites",
|
||||
"favorites_private_hint": "Only you and administrators can view your favorites list.",
|
||||
"hide_fav_badge": "Hide Favorite Badge Avatar",
|
||||
"hide_fav_badge_hint": "Display as a ghost icon on post detail pages without linking to your profile",
|
||||
"default_upload_visibility": "Default Upload Visibility",
|
||||
"default_upload_visibility_hint": "Set the default visibility level for your new uploads.",
|
||||
"image_expand_on_click": "Expand images inline on click",
|
||||
"image_expand_on_click_hint": "Instead of opening the scroll zoom modal, clicking an image will expand it to full size within the page.",
|
||||
"enable_bg_blur": "Enable Background blur",
|
||||
"enable_bg_blur_hint": "Show blurred background on items",
|
||||
"blur_nsfw": "Blur NSFW",
|
||||
"blur_nsfw_hint": "Blur NSFW-rated thumbnails.",
|
||||
"blur_nsfl": "Blur NSFL",
|
||||
"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_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",
|
||||
"render_emojis_hint": "Show :emoji: images inside >quoted lines",
|
||||
"embed_yt": "Embed YouTube links in comments",
|
||||
@@ -194,7 +160,7 @@
|
||||
"hide_koepfe_hint": "Disable the Köpfe",
|
||||
"comment_display_mode": "Comment Display Mode",
|
||||
"comment_display_tree": "Reply Tree (Default)",
|
||||
"comment_display_linear": "Linear",
|
||||
"comment_display_linear": "Linear / Flat (4chan style)",
|
||||
"comment_display_mode_hint": "Choose how you want comments to be displayed.",
|
||||
"forced_mode_notice": "This setting is managed by an administrator.",
|
||||
"language": "Language",
|
||||
@@ -271,12 +237,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 +244,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Image",
|
||||
"flash": "Flash",
|
||||
"grid_mode": "Thumbnail size"
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
@@ -299,8 +258,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",
|
||||
@@ -309,9 +266,7 @@
|
||||
"toggle_rating": "toggle item rating",
|
||||
"play_pause": "play/pause",
|
||||
"next_prev": "next/prev",
|
||||
"volume": "volume",
|
||||
"scroll_nav": "next/prev",
|
||||
"frame_skip": "frame step (paused) / 5s skip",
|
||||
"show_help": "show help"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -348,11 +303,7 @@
|
||||
"scroll_for_more": "Scroll for more..."
|
||||
},
|
||||
"error": {
|
||||
"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"
|
||||
"label": "Error"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Drop anywhere to upload"
|
||||
@@ -364,20 +315,7 @@
|
||||
"attach_file": "Attach file",
|
||||
"uploading_file": "Uploading...",
|
||||
"remove_file": "Remove file",
|
||||
"file_too_large": "File too large",
|
||||
"poll_btn_title": "Create poll",
|
||||
"poll_question_placeholder": "Poll question...",
|
||||
"poll_option_placeholder": "Option...",
|
||||
"poll_add_option": "Add option",
|
||||
"poll_remove": "Remove poll",
|
||||
"poll_vote": "Vote",
|
||||
"poll_voted": "You voted",
|
||||
"poll_votes": "votes",
|
||||
"poll_vote_single": "vote",
|
||||
"poll_delete": "Delete poll",
|
||||
"poll_expired": "Poll closed",
|
||||
"poll_anonymous": "Anonymous",
|
||||
"poll_public": "Public votes"
|
||||
"file_too_large": "File too large"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Select a file",
|
||||
@@ -407,8 +345,7 @@
|
||||
"view": "View",
|
||||
"save": "Save",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"dismiss": "Dismiss"
|
||||
"edit": "Edit"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Confirm Action",
|
||||
@@ -467,7 +404,7 @@
|
||||
},
|
||||
"report": {
|
||||
"title": "Submit Report",
|
||||
"placeholder": "",
|
||||
"placeholder": "Please provide details for this report (required)...",
|
||||
"submit": "Submit"
|
||||
},
|
||||
"account_warning": {
|
||||
@@ -476,22 +413,11 @@
|
||||
"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.",
|
||||
"loading_more": "Loading…",
|
||||
"end_of_activity": "─ end of activity ─",
|
||||
"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 +446,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",
|
||||
@@ -528,8 +454,6 @@
|
||||
"stat_comments": "Total Comments",
|
||||
"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 +464,7 @@
|
||||
"limit_reached": "Upload limit reached (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} uploads remaining",
|
||||
"auth_required_title": "Authentication Required",
|
||||
"auth_required_text": "You must be logged in to upload content.",
|
||||
"auth_required_text": "You must be logged in to upload content to w0bm.",
|
||||
"login_btn": "Login"
|
||||
},
|
||||
"messages": {
|
||||
@@ -565,10 +489,6 @@
|
||||
"subscribe_uploads_btn": "Subscribe user to uploads",
|
||||
"no_uploads": "no uploads found",
|
||||
"no_favs": "no favorites",
|
||||
"guest_favs_saved": "You have {count} guest favorites saved on this device.",
|
||||
"sync_guest_favs": "Import to Account",
|
||||
"guest_favs_imported": "Imported favorites to your account!",
|
||||
"private_favorites": "private favorites",
|
||||
"back_to_profile": "Back to Profile",
|
||||
"ban_modal_title": "Ban User",
|
||||
"ban_modal_reason": "Reason:",
|
||||
@@ -606,24 +526,6 @@
|
||||
"found": "Found in metadata:",
|
||||
"no_results": "No additional metadata fields found in this file."
|
||||
},
|
||||
"info_modal": {
|
||||
"title": "Post & File Information",
|
||||
"button_title": "Post & File Info",
|
||||
"id": "Post ID",
|
||||
"source": "Source",
|
||||
"uploader": "Uploader",
|
||||
"uploaded_at": "Uploaded At",
|
||||
"file_size": "File Size",
|
||||
"mime_type": "MIME Type",
|
||||
"rating": "Rating",
|
||||
"oc": "Original Content (OC)",
|
||||
"no": "No",
|
||||
"direct_url": "Direct URL",
|
||||
"view_file": "View File",
|
||||
"metadata": "Metadata",
|
||||
"sha256": "SHA-256 Hash",
|
||||
"dimensions": "Dimensions"
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Add Text Layer",
|
||||
"tags_label": "Tags (comma separated)",
|
||||
@@ -634,12 +536,7 @@
|
||||
"text_layer": "Text Layer",
|
||||
"enter_text": "Enter text...",
|
||||
"size_label": "Size",
|
||||
"create_meme": "Create Meme:",
|
||||
"custom_template_title": "Use Own Template",
|
||||
"custom_template_tag": "Local File",
|
||||
"select_image": "Select Custom Image",
|
||||
"choose_file_btn": "Choose Image",
|
||||
"choose_image_first": "Please select an image first!"
|
||||
"create_meme": "Create Meme:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "just now",
|
||||
@@ -670,8 +567,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 +637,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",
|
||||
@@ -805,57 +699,5 @@
|
||||
"left_hand_desc": "You know why.",
|
||||
"replying_to": "Replying to {user}",
|
||||
"reply": "Reply"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Invites",
|
||||
"section_desc": "Invite new users to join. You must meet all criteria below to generate invite tokens.",
|
||||
"eligible": "✓ You are eligible to generate invites.",
|
||||
"not_eligible": "✗ You do not yet meet all criteria.",
|
||||
"slots_used": "{used} / {total} invite slots used",
|
||||
"criteria_uploads": "Uploads",
|
||||
"criteria_age": "Account age",
|
||||
"criteria_comments": "Comments",
|
||||
"criteria_tags": "Tags assigned",
|
||||
"criteria_days": " days",
|
||||
"generate_btn": "Generate invite",
|
||||
"generating": "Generating…",
|
||||
"loading": "Loading…",
|
||||
"no_invites": "No invite tokens generated yet.",
|
||||
"status_unused": "Unused",
|
||||
"status_used_by": "Used by {user}",
|
||||
"copy_btn": "Copy",
|
||||
"copied": "Copied!",
|
||||
"delete_btn": "Delete",
|
||||
"delete_confirm": "Delete this invite token?",
|
||||
"slot_refreshes_on": "slot refreshes on {date}",
|
||||
"slot_refreshed": "slot refreshed",
|
||||
"admin_desc": "You are an admin, go ahead.",
|
||||
"visibility": {
|
||||
"public": "Public",
|
||||
"unlisted": "Unlisted",
|
||||
"private": "Private",
|
||||
"change_visibility": "Change Visibility"
|
||||
}
|
||||
},
|
||||
"album": {
|
||||
"title": "Album",
|
||||
"multiple_selected": "Multiple files selected",
|
||||
"mode_album": "Album (1 post)",
|
||||
"mode_batch": "Separate posts",
|
||||
"cover": "Cover",
|
||||
"pictures": "subf0cks",
|
||||
"counter": "%s of %s",
|
||||
"prev": "Previous subf0ck",
|
||||
"next": "Next subf0ck",
|
||||
"hotkey_tip": "Use [ and ] or hover to navigate subf0cks",
|
||||
"move_left": "Move left",
|
||||
"move_right": "Move right",
|
||||
"remove_picture": "Remove subf0ck",
|
||||
"add_more": "Add more subf0cks",
|
||||
"drop_hint": "Select or drop multiple files (subf0cks) to create an Album",
|
||||
"select_pictures": "Select files for album",
|
||||
"min_pictures": "Add at least 2 subf0cks for an album",
|
||||
"subf0ck_tags": "Tags for this subf0ck",
|
||||
"subf0ck_tags_placeholder": "Tags for this subf0ck (optional)"
|
||||
}
|
||||
}
|
||||
+15
-167
@@ -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",
|
||||
@@ -71,17 +70,7 @@
|
||||
"btn_add_urls": "URL(s) toevoegen",
|
||||
"tags_required_shitpost": "Alle items hebben tags nodig",
|
||||
"url_queued_background": "URL wordt op de achtergrond verwerkt.",
|
||||
"add_more": "Meer toevoegen",
|
||||
"url_tracker_title": "URL Uploads",
|
||||
"url_tracker_queued": "In wachtrij",
|
||||
"url_tracker_downloading": "Downloaden",
|
||||
"url_tracker_analyzing": "Analyseren",
|
||||
"url_tracker_saving": "Opslaan",
|
||||
"url_tracker_processing": "Verwerken",
|
||||
"url_tracker_extracting": "Extraheren",
|
||||
"url_tracker_complete": "Klaar!",
|
||||
"url_tracker_failed": "Upload mislukt",
|
||||
"url_tracker_view": "Bekijken →"
|
||||
"add_more": "Meer toevoegen"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Registreren...",
|
||||
@@ -131,7 +120,6 @@
|
||||
"switching": "Omschakelen...",
|
||||
"generating": "Genereren...",
|
||||
"title": "Instellingen",
|
||||
"profile": "Profiel",
|
||||
"avatar": "Avatar",
|
||||
"current_avatar": "Huidige Avatar",
|
||||
"upload_custom_avatar": "Aangepaste Avatar Uploaden",
|
||||
@@ -140,50 +128,30 @@
|
||||
"no_file_selected": "Geen bestand geselecteerd",
|
||||
"upload_btn": "Uploaden",
|
||||
"remove_custom": "Aangepaste Verwijderen",
|
||||
"current_banner": "Huidige Banner",
|
||||
"banner_hint": "Max 5MB. Toegestaan: gif, jpg, png, webp",
|
||||
"no_banner_set": "Geen banner ingesteld",
|
||||
"custom_description": "Aangepaste Beschrijving",
|
||||
"description_placeholder": "Voeg een beschrijving toe aan je profiel",
|
||||
"save_description": "Beschrijving Opslaan",
|
||||
"clear": "Wissen",
|
||||
"preferences": "Voorkeuren",
|
||||
"ui_section": "Gebruikersinterface",
|
||||
"content_preferences_section": "Inhoudsvoorkeuren",
|
||||
"appearance_section": "Uiterlijk",
|
||||
"show_motd": "Toon Bericht van de Dag (MOTD)",
|
||||
"modern_layout": "Moderne layout",
|
||||
"modern_layout_hint": "Indeling met 3 kolommen",
|
||||
"feed_layout": "Feed-indeling",
|
||||
"feed_layout_hint": "Kies hoe de hoofdpagina berichten weergeeft",
|
||||
"feed_layout_grid": "Raster (Compact)",
|
||||
"feed_layout_modern": "Raster (3-koloms Modern)",
|
||||
"feed_layout_feed": "Feed (X / Instagram-stijl)",
|
||||
"feed_layout_youtube": "YouTube-stijl",
|
||||
"alternative_infobox": "Alternatief auteur-informatievak",
|
||||
"alternative_infobox_hint": "Toont een uitgebreide auteurkaart met avatar en bio op itempagina's",
|
||||
"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).",
|
||||
"disable_autoplay": "Automatisch afspelen uitschakelen",
|
||||
"disable_autoplay_hint": "Voorkomen dat video's en audio automatisch worden afgespeeld",
|
||||
"hide_item_ratings": "Itembeoordelingen verbergen",
|
||||
"hide_item_ratings_hint": "Toon geen SFW/NSFW beoordelingsindicatoren op hoofdpagina items",
|
||||
"disable_swiping": "Swipen uitschakelen",
|
||||
"disable_swiping_hint": "Swipe-to-navigate uitschakelen op mobiele apparaten",
|
||||
"favorites_private": "Privé favorieten",
|
||||
"favorites_private_hint": "Alleen jij en beheerders kunnen je favorietenlijst bekijken.",
|
||||
"hide_fav_badge": "Favoriet-badge avatar verbergen",
|
||||
"hide_fav_badge_hint": "Toon als een spook-icoon op berichtpagina's zonder koppeling naar je profiel",
|
||||
"image_expand_on_click": "Afbeeldingen inline vergroten bij klikken",
|
||||
"image_expand_on_click_hint": "In plaats van de scroll-zoom-modal te openen, wordt een afbeelding bij klikken vergroot tot volledige grootte binnen de pagina.",
|
||||
"enable_bg_blur": "Achtergrondvervaging inschakelen",
|
||||
"enable_bg_blur_hint": "Vervaagde achtergrond tonen bij items",
|
||||
"blur_nsfw": "NSFW vervagen",
|
||||
"blur_nsfw_hint": "Vervaagt NSFW-thumbnails.",
|
||||
"blur_nsfl": "NSFL vervagen",
|
||||
"blur_nsfl_hint": "Vervaagt NSFL-thumbnails.",
|
||||
"blur_sfw": "SFW vervagen",
|
||||
"blur_sfw_hint": "Vervaagt SFW-thumbnails.",
|
||||
"blur_untagged": "Ongetagde vervagen",
|
||||
"blur_untagged_hint": "Vervaagt ongetagde thumbnails.",
|
||||
"blur_detail": "Details weigeren direct te tonen (vervagen)",
|
||||
"blur_detail_hint": "Vereist klikken om vervaagde media op de detailpagina te onthullen.",
|
||||
"render_emojis": "Emoji's weergeven in antwoorden",
|
||||
"render_emojis_hint": "Toon :emoji: afbeeldingen binnen >geciteerde regels",
|
||||
"embed_yt": "YouTube-links insluiten in opmerkingen",
|
||||
@@ -192,7 +160,7 @@
|
||||
"hide_koepfe_hint": "De Köpfe uitschakelen",
|
||||
"comment_display_mode": "Reactie-weergavemodus",
|
||||
"comment_display_tree": "Antwoordboom (Standaard)",
|
||||
"comment_display_linear": "Lineair",
|
||||
"comment_display_linear": "Lineair / Vlak (4chan-stijl)",
|
||||
"comment_display_mode_hint": "Kies hoe je reacties wilt laten weergeven.",
|
||||
"forced_mode_notice": "Deze instelling wordt beheerd door een beheerder.",
|
||||
"language": "Taal",
|
||||
@@ -269,12 +237,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 +244,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"image": "Afbeelding",
|
||||
"flash": "Flash",
|
||||
"grid_mode": "Thumbnailgrootte"
|
||||
"flash": "Flash"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Sneltoetsen",
|
||||
@@ -297,8 +258,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",
|
||||
@@ -307,9 +266,7 @@
|
||||
"toggle_rating": "itembeoordeling omschakelen",
|
||||
"play_pause": "afspelen/pauzeren",
|
||||
"next_prev": "volgende/vorige",
|
||||
"volume": "volume",
|
||||
"scroll_nav": "next/prev",
|
||||
"frame_skip": "frame-stap (gepauzeerd) / 5s overslaan",
|
||||
"show_help": "help tonen"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -346,11 +303,7 @@
|
||||
"scroll_for_more": "Scroll voor meer..."
|
||||
},
|
||||
"error": {
|
||||
"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"
|
||||
"label": "Fout"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Overal slepen om te uploaden"
|
||||
@@ -362,20 +315,7 @@
|
||||
"attach_file": "Bestand bijvoegen",
|
||||
"uploading_file": "Uploaden...",
|
||||
"remove_file": "Bestand verwijderen",
|
||||
"file_too_large": "Bestand te groot",
|
||||
"poll_btn_title": "Peiling aanmaken",
|
||||
"poll_question_placeholder": "Peilingvraag...",
|
||||
"poll_option_placeholder": "Optie...",
|
||||
"poll_add_option": "Optie toevoegen",
|
||||
"poll_remove": "Peiling verwijderen",
|
||||
"poll_vote": "Stemmen",
|
||||
"poll_voted": "Je hebt gestemd",
|
||||
"poll_votes": "stemmen",
|
||||
"poll_vote_single": "stem",
|
||||
"poll_delete": "Peiling verwijderen",
|
||||
"poll_expired": "Peiling gesloten",
|
||||
"poll_anonymous": "Anoniem",
|
||||
"poll_public": "Openbare stemmen"
|
||||
"file_too_large": "Bestand te groot"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Selecteer een bestand",
|
||||
@@ -402,11 +342,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,21 +409,11 @@
|
||||
"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.",
|
||||
"loading_more": "Laden…",
|
||||
"end_of_activity": "─ einde van activiteit ─",
|
||||
"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",
|
||||
@@ -524,8 +450,6 @@
|
||||
"stat_comments": "Totaal aantal reacties",
|
||||
"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 +460,7 @@
|
||||
"limit_reached": "Uploadlimiet bereikt (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} uploads over",
|
||||
"auth_required_title": "Authenticatie Vereist",
|
||||
"auth_required_text": "Je moet ingelogd zijn om inhoud naar f0ckm te uploaden.",
|
||||
"auth_required_text": "Je moet ingelogd zijn om inhoud naar w0bm te uploaden.",
|
||||
"login_btn": "Inloggen"
|
||||
},
|
||||
"messages": {
|
||||
@@ -561,10 +485,6 @@
|
||||
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
|
||||
"no_uploads": "geen uploads gevonden",
|
||||
"no_favs": "geen favorieten",
|
||||
"guest_favs_saved": "Je hebt {count} gastfavorieten opgeslagen op dit apparaat.",
|
||||
"sync_guest_favs": "Importeren naar account",
|
||||
"guest_favs_imported": "Favorieten succesvol geïmporteerd naar je account!",
|
||||
"private_favorites": "privé favorieten",
|
||||
"back_to_profile": "Terug naar Profiel",
|
||||
"ban_modal_title": "Gebruiker Bannen",
|
||||
"ban_modal_reason": "Reden:",
|
||||
@@ -602,24 +522,6 @@
|
||||
"found": "Gevonden in metadata:",
|
||||
"no_results": "Geen extra metadata-velden gevonden in dit bestand."
|
||||
},
|
||||
"info_modal": {
|
||||
"title": "Post- & Bestandsinformatie",
|
||||
"button_title": "Post- & Bestandsinformatie",
|
||||
"id": "Post-ID",
|
||||
"source": "Bron",
|
||||
"uploader": "Uploader",
|
||||
"uploaded_at": "Geüpload op",
|
||||
"file_size": "Bestandsgrootte",
|
||||
"mime_type": "MIME-type",
|
||||
"rating": "Beoordeling",
|
||||
"oc": "Originele Content (OC)",
|
||||
"no": "Nee",
|
||||
"direct_url": "Directe URL",
|
||||
"view_file": "Bestand bekijken",
|
||||
"metadata": "Metadata",
|
||||
"sha256": "SHA-256 Hash",
|
||||
"dimensions": "Afmetingen"
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Tekstlaag Toevoegen",
|
||||
"tags_label": "Tags (gescheiden door komma's)",
|
||||
@@ -630,12 +532,7 @@
|
||||
"text_layer": "Tekstlaag",
|
||||
"enter_text": "Voer tekst in...",
|
||||
"size_label": "Grootte",
|
||||
"create_meme": "Meme Maken:",
|
||||
"custom_template_title": "Eigen sjabloon gebruiken",
|
||||
"custom_template_tag": "Lokaal bestand",
|
||||
"select_image": "Selecteer eigen afbeelding",
|
||||
"choose_file_btn": "Kies afbeelding",
|
||||
"choose_image_first": "Selecteer eerst een afbeelding!"
|
||||
"create_meme": "Meme Maken:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "zojuist",
|
||||
@@ -666,8 +563,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 +633,6 @@
|
||||
"settings": "Instellingen",
|
||||
"filters": "Filters",
|
||||
"volume": "Volume",
|
||||
"clear_filter": "Filter wissen",
|
||||
"reset_all": "Alles resetten",
|
||||
"rating": "Beoordeling",
|
||||
"all": "Alles",
|
||||
@@ -801,51 +695,5 @@
|
||||
"left_hand_desc": "Je weet wel waarom.",
|
||||
"replying_to": "Antwoord aan {user}",
|
||||
"reply": "Antwoorden"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Uitnodigingen",
|
||||
"section_desc": "Nodig nieuwe gebruikers uit. Je moet aan alle onderstaande criteria voldoen om uitnodigingstokens te genereren.",
|
||||
"eligible": "✓ Je bent bevoegd om uitnodigingen te genereren.",
|
||||
"not_eligible": "✗ Je voldoet nog niet aan alle criteria.",
|
||||
"slots_used": "{used} / {total} uitnodigingsslots gebruikt",
|
||||
"criteria_uploads": "Uploads",
|
||||
"criteria_age": "Accountleeftijd",
|
||||
"criteria_comments": "Opmerkingen",
|
||||
"criteria_tags": "Toegewezen tags",
|
||||
"criteria_days": " dagen",
|
||||
"generate_btn": "Uitnodiging genereren",
|
||||
"generating": "Genereren…",
|
||||
"loading": "Laden…",
|
||||
"no_invites": "Nog geen uitnodigingstokens gegenereerd.",
|
||||
"status_unused": "Ongebruikt",
|
||||
"status_used_by": "Gebruikt door {user}",
|
||||
"copy_btn": "Kopiëren",
|
||||
"copied": "Gekopieërd!",
|
||||
"delete_btn": "Verwijderen",
|
||||
"delete_confirm": "Dit uitnodigingstoken verwijderen?",
|
||||
"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)"
|
||||
}
|
||||
}
|
||||
+16
-163
@@ -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",
|
||||
@@ -71,17 +70,7 @@
|
||||
"btn_add_urls": "Elfe hinzufügen",
|
||||
"tags_required_shitpost": "Alle Fetzen brauchen Etiketten",
|
||||
"url_queued_background": "Elfen-Hintergrundverarbeitung gestartet.",
|
||||
"add_more": "Meer hinzufügen",
|
||||
"url_tracker_title": "Elfen-Aufladierungen",
|
||||
"url_tracker_queued": "Warteschlange",
|
||||
"url_tracker_downloading": "Wird hinunterladiert",
|
||||
"url_tracker_analyzing": "Wird analysiert",
|
||||
"url_tracker_saving": "Wird gespeichert",
|
||||
"url_tracker_processing": "Wird verarbeitet",
|
||||
"url_tracker_extracting": "Wird extrahiert",
|
||||
"url_tracker_complete": "Fertig!",
|
||||
"url_tracker_failed": "Aufladierung fehlgeschlagen",
|
||||
"url_tracker_view": "Ansehen →"
|
||||
"add_more": "Meer hinzufügen"
|
||||
},
|
||||
"auth": {
|
||||
"registering": "Registrierung wird in die Wege geleitet...",
|
||||
@@ -100,7 +89,7 @@
|
||||
"password_min_hint": "Muss mindestens 20 Zeichen lang sein.",
|
||||
"confirm_password": "Kennwort bestätigen",
|
||||
"email_placeholder": "E-Post",
|
||||
"invite_token": "Einladungskots",
|
||||
"invite_token": "Einladungskennzeichen",
|
||||
"tos_private": "Ich bin mindestens 18 Jahre alt und stimme der Befolgung des Regelwerks zu",
|
||||
"tos_public": "Ich habe die Nutzungsbedingungen gelesen und akzeptiere diese",
|
||||
"tos_terms": "Nutzungsbedingungen",
|
||||
@@ -131,7 +120,6 @@
|
||||
"switching": "Umschaltung wird vorgenommen...",
|
||||
"generating": "Generierung wird angestoßen...",
|
||||
"title": "Einstellungen",
|
||||
"profile": "Profil",
|
||||
"avatar": "Profilbild",
|
||||
"current_avatar": "Aktuelles Profilbild",
|
||||
"upload_custom_avatar": "Benutzerdefiniertes Profilbild aufladieren",
|
||||
@@ -140,48 +128,30 @@
|
||||
"no_file_selected": "Keine Datei ausgewählt",
|
||||
"upload_btn": "Aufladieren",
|
||||
"remove_custom": "Benutzerdefiniertes entfernen",
|
||||
"current_banner": "Aktuelles Banner",
|
||||
"banner_hint": "Max 5MB. Erlaubt: gif, jpg, png, webp",
|
||||
"no_banner_set": "Kein Banner gesetzt",
|
||||
"custom_description": "Benutzerdefinierte Beschreibung",
|
||||
"description_placeholder": "Fügen Sie Ihrem Profil eine Beschreibung hinzu",
|
||||
"save_description": "Beschreibung speichern",
|
||||
"clear": "Leeren",
|
||||
"preferences": "Präferenzen",
|
||||
"ui_section": "Benutzeroberfläche",
|
||||
"content_preferences_section": "Inhaltseinstellungen",
|
||||
"appearance_section": "Erscheinungsbild",
|
||||
"show_motd": "Nachricht des Tages (NdT) anzeigen",
|
||||
"modern_layout": "Modernes Layout",
|
||||
"modern_layout_hint": "3-Spalten-Layout",
|
||||
"feed_layout": "Feed-Layout",
|
||||
"feed_layout_hint": "Wählze, wie die Hauptzeite Beiträge anzeigt",
|
||||
"feed_layout_grid": "Raster (Kompakt)",
|
||||
"feed_layout_modern": "Raster (3-spaltig Modern)",
|
||||
"feed_layout_feed": "Feed (X / Instagram-Stil)",
|
||||
"feed_layout_youtube": "YouTube-Stil",
|
||||
"alternative_infobox": "Alternativer Autor-Infoblock",
|
||||
"alternative_infobox_hint": "Zeigt einen erweiterten Autor-Block mit Avatar und Bio auf Beitragsseiten",
|
||||
"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).",
|
||||
"disable_autoplay": "Automatische Wiedergabe deaktivieren",
|
||||
"disable_autoplay_hint": "Vermeiden Sie das automatische Abspielen von Videos und Tondateien",
|
||||
"hide_item_ratings": "Item-Bewertungen ausblenden",
|
||||
"hide_item_ratings_hint": "Zeige keine SFW/NSFW-Bewertungsindikatoren bei Elementen auf der Hauptseite",
|
||||
"disable_swiping": "Wischen deaktivieren",
|
||||
"disable_swiping_hint": "Deaktivieren der Wisch-Navigation auf Mobilgeräten",
|
||||
"favorites_private": "Private Favoriten",
|
||||
"favorites_private_hint": "Nur du und Administratoren können deine Favoritenliste sehen.",
|
||||
"hide_fav_badge": "Herzi-Badge-Avatar versteckeln",
|
||||
"hide_fav_badge_hint": "Zeigt auf Beitragsseiten 1 geiles Geist-Icon an vong Anonymität her",
|
||||
"image_expand_on_click": "Bildli beim Klickle inline uffblähe",
|
||||
"image_expand_on_click_hint": "Anstatt dat Scroll-Zoom-Moped aufzumache, wird n Bild beim Klickle uff volle Größ im Bereich uffgepumpt.",
|
||||
"enable_bg_blur": "Hintergrundunschärfe aktivieren",
|
||||
"enable_bg_blur_hint": "Gefalteten Hintergrund auf Elementen anzeigen",
|
||||
"blur_nsfw": "NSFW verwischen",
|
||||
"blur_nsfw_hint": "Verwischt NSFW-Vorschaubilder.",
|
||||
"blur_nsfl": "NSFL verwischen",
|
||||
"blur_nsfl_hint": "Verwischt NSFL-Vorschaubilder.",
|
||||
"blur_sfw": "SFW verwischen",
|
||||
"blur_sfw_hint": "Verwischt SFW-Vorschaubilder.",
|
||||
"blur_untagged": "Unmarkierte verwischen",
|
||||
"blur_untagged_hint": "Verwischt unmarkierte Vorschaubilder.",
|
||||
"render_emojis": "Emojis in Zitatantworten darstellen",
|
||||
"render_emojis_hint": ":emoji:-Bilder innerhalb von >zitierten Zeilen anzeigen",
|
||||
"embed_yt": "Röhrenelfen in Kommentaren einbetten",
|
||||
@@ -190,7 +160,7 @@
|
||||
"hide_koepfe_hint": "Die Köpfe deaktivieren",
|
||||
"comment_display_mode": "Kommentar-Anzeigemodus",
|
||||
"comment_display_tree": "Antwort-Baum (Standard)",
|
||||
"comment_display_linear": "Linear",
|
||||
"comment_display_linear": "Linear / Flach (Vierkanal-Stil)",
|
||||
"comment_display_mode_hint": "Wähle aus, wie Kommentare angezeigt werden sollen.",
|
||||
"forced_mode_notice": "Diese Einstellung wird für Sie verwaltet.",
|
||||
"language": "Sprache",
|
||||
@@ -267,12 +237,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 +244,7 @@
|
||||
"video": "Video",
|
||||
"audio": "Tondatei",
|
||||
"image": "Bild",
|
||||
"flash": "Blitz",
|
||||
"grid_mode": "Größe der Daumennägel"
|
||||
"flash": "Blitz"
|
||||
},
|
||||
"shortcuts": {
|
||||
"title": "Tastaturkürzel",
|
||||
@@ -295,8 +258,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",
|
||||
@@ -305,9 +266,7 @@
|
||||
"toggle_rating": "Elementbewertung umschalten",
|
||||
"play_pause": "Abspielen/Pause",
|
||||
"next_prev": "nächster/vorheriger",
|
||||
"volume": "Lautstärke",
|
||||
"scroll_nav": "nächster/vorheriger",
|
||||
"frame_skip": "Einzelbildrücken (pausiert) / 5s Hüpfen",
|
||||
"show_help": "Hilfe anzeigen"
|
||||
},
|
||||
"notifications": {
|
||||
@@ -344,11 +303,7 @@
|
||||
"scroll_for_more": "Scrollen für mehr..."
|
||||
},
|
||||
"error": {
|
||||
"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"
|
||||
"label": "Fehler"
|
||||
},
|
||||
"drop": {
|
||||
"drop_anywhere": "Überall ablegen zum Aufladieren"
|
||||
@@ -360,20 +315,7 @@
|
||||
"attach_file": "Datei anflanschen",
|
||||
"uploading_file": "Wird aufladiert...",
|
||||
"remove_file": "Datei entfernen",
|
||||
"file_too_large": "Datei zu voluminös",
|
||||
"poll_btn_title": "Umfrage erstellen",
|
||||
"poll_question_placeholder": "Frage",
|
||||
"poll_option_placeholder": "Option",
|
||||
"poll_add_option": "Option hinzufügen",
|
||||
"poll_remove": "Umfrage entfernen",
|
||||
"poll_vote": "Abstimmen",
|
||||
"poll_voted": "Abgestimmt",
|
||||
"poll_votes": "Stimmen",
|
||||
"poll_vote_single": "Stimme",
|
||||
"poll_delete": "Umfrage löschen",
|
||||
"poll_expired": "Umfrage geschlossen",
|
||||
"poll_anonymous": "Anonym",
|
||||
"poll_public": "Öffentliche Stimmen"
|
||||
"file_too_large": "Datei zu voluminös"
|
||||
},
|
||||
"upload_btn": {
|
||||
"select_file": "Datei auswählen",
|
||||
@@ -402,8 +344,7 @@
|
||||
"view": "Ansehen",
|
||||
"save": "Speichern",
|
||||
"delete": "Löschen",
|
||||
"edit": "Editieren",
|
||||
"dismiss": "Weg damit"
|
||||
"edit": "Editieren"
|
||||
},
|
||||
"mod": {
|
||||
"confirm_action": "Aktion bestätigen",
|
||||
@@ -471,21 +412,11 @@
|
||||
"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.",
|
||||
"loading_more": "Ladung wird aufbereitet…",
|
||||
"end_of_activity": "─ Ende des Aktivitätsfadens ─",
|
||||
"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",
|
||||
@@ -522,8 +453,6 @@
|
||||
"stat_comments": "Gesamtanzahl Kommentare",
|
||||
"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 +463,7 @@
|
||||
"limit_reached": "Aufladierungsgrenze erreicht (0/{limit})",
|
||||
"limit_remaining": "{remaining}/{limit} Aufladierungen verbleibend",
|
||||
"auth_required_title": "Authentifizierung erforderlich",
|
||||
"auth_required_text": "Sie müssen angemeldet sein, um Inhalte hochzuladen.",
|
||||
"auth_required_text": "Sie müssen angemeldet sein, um Inhalte auf w0bm hochzuladen.",
|
||||
"login_btn": "Anmeldung"
|
||||
},
|
||||
"messages": {
|
||||
@@ -559,10 +488,6 @@
|
||||
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
|
||||
"no_uploads": "keine Aufladierungen gefunden",
|
||||
"no_favs": "keine Favoriten",
|
||||
"guest_favs_saved": "Du hast {count} Kaltgast-Favs auf diesem Gerät rumgammeln.",
|
||||
"sync_guest_favs": "In Account ballern",
|
||||
"guest_favs_imported": "Favs erfolgreich ins Konto geballert!",
|
||||
"private_favorites": "private Favoriten",
|
||||
"back_to_profile": "Zurück zum Profil",
|
||||
"ban_modal_title": "Benutzer sperren",
|
||||
"ban_modal_reason": "Grund:",
|
||||
@@ -600,24 +525,6 @@
|
||||
"found": "In den Metadaten gefunden:",
|
||||
"no_results": "Keine weiteren Metadatenfelder in dieser Datei gefunden."
|
||||
},
|
||||
"info_modal": {
|
||||
"title": "Pfosten- & Datei-Informationen",
|
||||
"button_title": "Pfosten- & Datei-Informationen",
|
||||
"id": "Pfosten-ID",
|
||||
"source": "Quelle",
|
||||
"uploader": "Hochgeladen von",
|
||||
"uploaded_at": "Hochgeladen am",
|
||||
"file_size": "Dateigröße",
|
||||
"mime_type": "MIME-Typ",
|
||||
"rating": "Bewertung",
|
||||
"oc": "Originaler Inhalt (OC)",
|
||||
"no": "Nein",
|
||||
"direct_url": "Direktelfe",
|
||||
"view_file": "Datei betrachten",
|
||||
"metadata": "Metadaten",
|
||||
"sha256": "SHA-256-Streuwert",
|
||||
"dimensions": "Abmessungen"
|
||||
},
|
||||
"meme": {
|
||||
"add_text_layer": "Textebene hinzufügen",
|
||||
"tags_label": "Etiketten (kommagetrennt)",
|
||||
@@ -628,12 +535,7 @@
|
||||
"text_layer": "Textebene",
|
||||
"enter_text": "Text eingeben...",
|
||||
"size_label": "Größe",
|
||||
"create_meme": "Memel erstellen:",
|
||||
"custom_template_title": "Eigene Vorlage nutzen",
|
||||
"custom_template_tag": "Lokale Datei",
|
||||
"select_image": "Eigenes Bild auswählen",
|
||||
"choose_file_btn": "Bild aussuchen",
|
||||
"choose_image_first": "Zuerst Vorlage auswählen"
|
||||
"create_meme": "Memel erstellen:"
|
||||
},
|
||||
"timeago": {
|
||||
"just_now": "gerade eben",
|
||||
@@ -664,8 +566,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 +638,6 @@
|
||||
"settings": "Einstellungen",
|
||||
"filters": "Filter",
|
||||
"volume": "Lautstärke",
|
||||
"clear_filter": "Filter entfernen",
|
||||
"reset_all": "Alles zurücksetzen",
|
||||
"rating": "Bewertung",
|
||||
"all": "Alle",
|
||||
@@ -801,51 +700,5 @@
|
||||
"left_hand_desc": "Sie wissen schon wieso.",
|
||||
"replying_to": "Antwort an {user}",
|
||||
"reply": "Antworten"
|
||||
},
|
||||
"invites": {
|
||||
"section_title": "Einladungswesen",
|
||||
"section_desc": "Laden Sie neue Nutzer ein, beizutreten. Sie müssen alle nachstehenden Kriterien erfüllen, um Einladungskots zu erzeugen.",
|
||||
"eligible": "✓ Sie sind berechtigt, Einladungen zu erzeugen.",
|
||||
"not_eligible": "✗ Sie erfüllen noch nicht alle Kriterien.",
|
||||
"slots_used": "{used} / {total} Einladungsplätze in Benutzung",
|
||||
"criteria_uploads": "Aufladierungen",
|
||||
"criteria_age": "Kontoalter",
|
||||
"criteria_comments": "Kommentare",
|
||||
"criteria_tags": "Vergebene Etiketten",
|
||||
"criteria_days": " Tage",
|
||||
"generate_btn": "Einladung erzeugen",
|
||||
"generating": "Erzeugung wird durchgeführt…",
|
||||
"loading": "Ladung wird aufbereitet…",
|
||||
"no_invites": "Noch keine Einladungskots erzeugt.",
|
||||
"status_unused": "Ungebraucht",
|
||||
"status_used_by": "Gebraucht von {user}",
|
||||
"copy_btn": "Kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"delete_btn": "Löschen",
|
||||
"delete_confirm": "Diesen Einladungskot löschen?",
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-377
@@ -6,28 +6,6 @@ import cfg from "./config.mjs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
function isFlatFrame(buffer) {
|
||||
if (!buffer || buffer.length !== 1056) return true;
|
||||
let min = 255;
|
||||
let max = 0;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
const val = buffer[i];
|
||||
if (val < min) min = val;
|
||||
if (val > max) max = val;
|
||||
sum += val;
|
||||
}
|
||||
const mean = sum / buffer.length;
|
||||
if (mean < 15 || mean > 240) return true;
|
||||
|
||||
let sqDiffSum = 0;
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
sqDiffSum += Math.pow(buffer[i] - mean, 2);
|
||||
}
|
||||
const variance = sqDiffSum / buffer.length;
|
||||
return variance < 10 || (max - min) < 15;
|
||||
}
|
||||
|
||||
export default new class queue {
|
||||
|
||||
constructor() {
|
||||
@@ -107,52 +85,31 @@ export default new class queue {
|
||||
async generatePHash(source) {
|
||||
try {
|
||||
// Temporal dHash implementation:
|
||||
// 1. Check if source is image/video and get duration.
|
||||
// 2. For videos: Extract 3 frames (10%, 50%, 90% of duration).
|
||||
// For static images: Extract 1 frame.
|
||||
// 3. Generate dHash for each valid non-flat frame.
|
||||
// 4. Return combined hash "hash1_hash2_hash3" or single "hash".
|
||||
// 1. Get duration.
|
||||
// 2. Extract 3 frames: 10%, 50%, 90%.
|
||||
// 3. Generate dHash for each.
|
||||
// 4. Return combined hash "hash1_hash2_hash3".
|
||||
|
||||
// Skip ffprobe for PDFs (which would fail with "Invalid data")
|
||||
if (source.toLowerCase().endsWith('.pdf')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let isVideo = true;
|
||||
let timestamps = [];
|
||||
|
||||
try {
|
||||
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
|
||||
const duration = parseFloat(durationStr);
|
||||
if (isNaN(duration) || duration <= 0) {
|
||||
isVideo = false;
|
||||
} else {
|
||||
timestamps = [duration * 0.1, duration * 0.5, duration * 0.9];
|
||||
}
|
||||
} catch (err) {
|
||||
isVideo = false;
|
||||
}
|
||||
|
||||
if (!isVideo) {
|
||||
timestamps = [0]; // Process static image as single frame
|
||||
}
|
||||
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', source])).stdout.trim();
|
||||
const duration = parseFloat(durationStr);
|
||||
if (isNaN(duration) || duration <= 0) return null;
|
||||
|
||||
const timestamps = [duration * 0.1, duration * 0.5, duration * 0.9];
|
||||
const hashes = [];
|
||||
|
||||
for (const ts of timestamps) {
|
||||
let buffer;
|
||||
try {
|
||||
const vf = isVideo ? 'thumbnail,scale=33:32,format=gray' : 'scale=33:32,format=gray';
|
||||
const args = [];
|
||||
if (isVideo) {
|
||||
args.push('-ss', ts.toString());
|
||||
}
|
||||
args.push('-v', 'error', '-i', source, '-vf', vf, '-frames:v', '1', '-f', 'rawvideo', 'pipe:1');
|
||||
|
||||
const { stdout } = await this.spawn('ffmpeg', args, { encoding: 'buffer', quiet: true });
|
||||
const { stdout } = await this.spawn('ffmpeg', ['-ss', ts.toString(), '-v', 'error', '-i', source, '-vf', 'thumbnail,scale=33:32,format=gray', '-frames:v', '1', '-f', 'rawvideo', 'pipe:1'], { encoding: 'buffer', quiet: true });
|
||||
buffer = stdout;
|
||||
} catch (err) {
|
||||
console.warn(`[PHASH] Failed to extract frame at ${ts}s for ${source}: ${err.message}`);
|
||||
// Buffer remains undefined, triggering fallback below
|
||||
}
|
||||
|
||||
if (!buffer || buffer.length !== 1056) {
|
||||
@@ -160,12 +117,6 @@ export default new class queue {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Filter out flat/black frames (e.g. solid color backgrounds, fade-to-black)
|
||||
if (isFlatFrame(buffer)) {
|
||||
console.log(`[PHASH] Ignored flat/black frame at ${ts}s for ${source}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let hash = '';
|
||||
let currentByte = 0;
|
||||
let bitCount = 0;
|
||||
@@ -200,122 +151,72 @@ export default new class queue {
|
||||
|
||||
async checkrepostphash(newHash) {
|
||||
if (!newHash) return false;
|
||||
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
|
||||
const newHashes = newHash.split('_');
|
||||
if (newHashes.length === 0) return false;
|
||||
|
||||
const h1 = newHashes[0] || '';
|
||||
const h2 = newHashes[1] || '';
|
||||
const h3 = newHashes[2] || '';
|
||||
|
||||
const results = await db`
|
||||
SELECT id FROM items
|
||||
WHERE is_deleted = false
|
||||
AND phash IS NOT NULL AND phash != '' AND phash != 'ERROR' AND phash != 'MISSING' AND phash NOT LIKE '00000000%'
|
||||
AND (
|
||||
(
|
||||
CASE WHEN split_part(phash, '_', 1) != '' AND ${h1} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 1))::bit(1024) # ('x' || ${h1})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
+
|
||||
CASE WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 2))::bit(1024) # ('x' || ${h2})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
+
|
||||
CASE WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 3))::bit(1024) # ('x' || ${h3})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
) >= (
|
||||
CASE
|
||||
WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN 2
|
||||
WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN 2
|
||||
ELSE 1
|
||||
END
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
// Fetch all phashes, filtering out "all zero" failed hashes
|
||||
const items = await db`
|
||||
SELECT id, phash FROM items
|
||||
WHERE phash IS NOT NULL
|
||||
AND phash != ''
|
||||
AND phash NOT LIKE '00000000%'
|
||||
`;
|
||||
|
||||
return results.length > 0 ? results[0].id : false;
|
||||
};
|
||||
// Configurable threshold: max Hamming distance per 256-bit dHash frame.
|
||||
// A value of 15 means < 6% bit difference — tight enough to only match true duplicates.
|
||||
const THRESHOLD = 15;
|
||||
|
||||
async findallrepostphash(newHash, excludeId = null) {
|
||||
if (!newHash) return [];
|
||||
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
|
||||
if (newHashes.length === 0) return [];
|
||||
const getHammingDistance = (h1, h2) => {
|
||||
if (!h1 || !h2 || h1.length !== h2.length) return 9999;
|
||||
let distance = 0;
|
||||
for (let i = 0; i < h1.length; i += 2) {
|
||||
const v1 = parseInt(h1.substr(i, 2), 16);
|
||||
const v2 = parseInt(h2.substr(i, 2), 16);
|
||||
let xor = v1 ^ v2;
|
||||
while (xor) {
|
||||
distance += xor & 1;
|
||||
xor >>= 1;
|
||||
}
|
||||
}
|
||||
return distance;
|
||||
};
|
||||
|
||||
const h1 = newHashes[0] || '';
|
||||
const h2 = newHashes[1] || '';
|
||||
const h3 = newHashes[2] || '';
|
||||
// We want at least 2 out of 3 frames to match
|
||||
const REQUIRED_MATCHES = 2;
|
||||
|
||||
const results = await db`
|
||||
SELECT id, username, stamp FROM items
|
||||
WHERE is_deleted = false
|
||||
AND phash IS NOT NULL AND phash != '' AND phash != 'ERROR' AND phash != 'MISSING' AND phash NOT LIKE '00000000%'
|
||||
${excludeId ? db`AND id != ${excludeId}` : db``}
|
||||
AND (
|
||||
CASE WHEN split_part(phash, '_', 1) != '' AND ${h1} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 1))::bit(1024) # ('x' || ${h1})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
+
|
||||
CASE WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 2))::bit(1024) # ('x' || ${h2})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
+
|
||||
CASE WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 3))::bit(1024) # ('x' || ${h3})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
>= 1
|
||||
)
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
for (const item of items) {
|
||||
// Handle legacy single hashes vs new multi-hashes
|
||||
const dbHashes = item.phash.split('_');
|
||||
|
||||
return results.map(r => ({ id: r.id, username: r.username, stamp: r.stamp }));
|
||||
};
|
||||
let matches = 0;
|
||||
// Compare corresponding frames: 0vs0, 1vs1, 2vs2
|
||||
const framesToCompare = Math.min(newHashes.length, dbHashes.length);
|
||||
|
||||
async checkcommentrepostphash(newHash) {
|
||||
if (!newHash) return false;
|
||||
const newHashes = newHash.split('_').filter(s => s && !s.startsWith('00000000'));
|
||||
if (newHashes.length === 0) return false;
|
||||
for (let i = 0; i < framesToCompare; i++) {
|
||||
const dist = getHammingDistance(newHashes[i], dbHashes[i]);
|
||||
if (dist <= THRESHOLD) {
|
||||
matches++;
|
||||
}
|
||||
}
|
||||
|
||||
const h1 = newHashes[0] || '';
|
||||
const h2 = newHashes[1] || '';
|
||||
const h3 = newHashes[2] || '';
|
||||
// If we have 3 frames, require 2 out of 3 matches.
|
||||
// If we are comparing against a legacy 1-frame hash, require that single frame to match.
|
||||
if (framesToCompare >= 3 && matches >= REQUIRED_MATCHES) {
|
||||
return item.id;
|
||||
} else if (framesToCompare === 1 && matches === 1) {
|
||||
return item.id;
|
||||
} else if (framesToCompare === 2 && matches >= 2) {
|
||||
return item.id;
|
||||
}
|
||||
}
|
||||
|
||||
const results = await db`
|
||||
SELECT id, dest FROM comment_files
|
||||
WHERE phash IS NOT NULL AND phash != '' AND phash NOT LIKE '00000000%'
|
||||
AND (
|
||||
(
|
||||
CASE WHEN split_part(phash, '_', 1) != '' AND ${h1} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 1))::bit(1024) # ('x' || ${h1})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
+
|
||||
CASE WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 2))::bit(1024) # ('x' || ${h2})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
+
|
||||
CASE WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN
|
||||
bit_count(('x' || split_part(phash, '_', 3))::bit(1024) # ('x' || ${h3})::bit(1024)) <= 15
|
||||
ELSE false END::int
|
||||
) >= (
|
||||
CASE
|
||||
WHEN split_part(phash, '_', 3) != '' AND ${h3} != '' THEN 2
|
||||
WHEN split_part(phash, '_', 2) != '' AND ${h2} != '' THEN 2
|
||||
ELSE 1
|
||||
END
|
||||
)
|
||||
)
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
return results.length > 0 ? results[0] : false;
|
||||
return false;
|
||||
};
|
||||
|
||||
async genuuid() {
|
||||
const raw = (await db`
|
||||
select replace(gen_random_uuid()::text, '-', '') || replace(gen_random_uuid()::text, '-', '') as uuid
|
||||
`)[0].uuid;
|
||||
return raw.substring(0, 48);
|
||||
return (await db`
|
||||
select gen_random_uuid() as uuid
|
||||
`)[0].uuid.substring(0, 8);
|
||||
};
|
||||
|
||||
async checkrepostlink(link) {
|
||||
@@ -354,12 +255,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,16 +273,6 @@ 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 (_) {}
|
||||
|
||||
try {
|
||||
|
||||
if (mime === 'video/youtube') {
|
||||
const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
|
||||
if (videoId) {
|
||||
@@ -411,11 +299,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(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,71 +306,29 @@ export default new class queue {
|
||||
const ffThumbSize = Math.max(thumbSize, 512);
|
||||
const seeks = ['20%', '40%', '60%', '80%'];
|
||||
for (const seek of seeks) {
|
||||
try {
|
||||
await this.spawn('ffmpegthumbnailer', ['-i', sourcePath, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||
} catch (err) {
|
||||
console.warn(`[QUEUE] ffmpegthumbnailer failed at ${seek} for ${itemid}, trying ffmpeg fallback: ${err.message}`);
|
||||
let seekSeconds = 0;
|
||||
try {
|
||||
const durationStr = (await this.spawn('ffprobe', ['-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', sourcePath])).stdout.trim();
|
||||
const duration = parseFloat(durationStr);
|
||||
if (!isNaN(duration) && duration > 0) {
|
||||
const pct = parseFloat(seek) / 100;
|
||||
seekSeconds = duration * pct;
|
||||
}
|
||||
} catch (probeErr) {
|
||||
seekSeconds = seek === '20%' ? 2 : seek === '40%' ? 5 : seek === '60%' ? 8 : 10;
|
||||
}
|
||||
// Fallback to ffmpeg, overriding the color transfer characteristic to standard bt709 (1) in case of unsupported trc properties (e.g. log316)
|
||||
await this.spawn('ffmpeg', [
|
||||
'-y',
|
||||
'-ss', String(seekSeconds),
|
||||
'-color_trc', '1',
|
||||
'-i', sourcePath,
|
||||
'-frames:v', '1',
|
||||
'-update', '1',
|
||||
'-vf', `scale=${ffThumbSize}:${ffThumbSize}:force_original_aspect_ratio=increase,crop=${ffThumbSize}:${ffThumbSize}`,
|
||||
tmpFile
|
||||
]);
|
||||
}
|
||||
await this.spawn('ffmpegthumbnailer', ['-i', sourcePath, '-s', String(ffThumbSize), '-t', seek, '-o', tmpFile]);
|
||||
try {
|
||||
const { stdout } = await this.spawn('magick', [tmpFile, '-colorspace', 'Gray', '-format', '%[fx:mean]', 'info:']);
|
||||
if (parseFloat(stdout.trim()) > 0.05) break;
|
||||
} catch (e) { break; }
|
||||
}
|
||||
}
|
||||
else if (mime.startsWith('image/') && mime != 'image/gif') {
|
||||
if (mime === 'image/avif') {
|
||||
try {
|
||||
await this.spawn('ffmpeg', ['-i', sourcePath, '-frames:v', '1', '-update', '1', tmpFile]);
|
||||
} catch (err) {
|
||||
// If ffmpeg fails, fallback to magick
|
||||
await this.spawn('magick', [sourcePath + '[0]', '-auto-orient', tmpFile]);
|
||||
}
|
||||
} else {
|
||||
await this.spawn('magick', [sourcePath + '[0]', '-auto-orient', tmpFile]);
|
||||
}
|
||||
}
|
||||
else if (mime.startsWith('image/') && mime != 'image/gif')
|
||||
await this.spawn('magick', [sourcePath + '[0]', tmpFile]);
|
||||
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 +349,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;
|
||||
@@ -598,134 +421,20 @@ export default new class queue {
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (mime.startsWith('application/') && cfg.mimes[mime] && !['swf', 'pdf'].includes(cfg.mimes[mime])) {
|
||||
// Any application/* MIME registered in config that isn't swf or pdf is treated as an archive.
|
||||
// Both the detection and the label come straight from cfg.mimes — no hardcoded list needed.
|
||||
let customThumb = cfg.websrv && cfg.websrv.archive_thumb;
|
||||
if (customThumb && customThumb.startsWith('/')) {
|
||||
customThumb = path.join(path.resolve(), 'public', customThumb);
|
||||
}
|
||||
let usedCustom = false;
|
||||
if (customThumb) {
|
||||
try {
|
||||
const stat = await fs.promises.stat(customThumb).catch(() => null);
|
||||
if (stat && stat.size > 0) {
|
||||
await this.spawn('magick', [customThumb, tmpFile]);
|
||||
usedCustom = true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!usedCustom) {
|
||||
const archiveLabel = (cfg.mimes[mime] || 'arc').toUpperCase();
|
||||
await this.spawn('magick', [
|
||||
'-size', thumbSpec, 'xc:#1a2e1a',
|
||||
'-gravity', 'center',
|
||||
'-fill', '#66bb6a',
|
||||
'-pointsize', '48',
|
||||
'-annotate', '0', archiveLabel,
|
||||
tmpFile
|
||||
]).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const isTransparentMime = mime === 'image/png' || mime === 'image/webp' || mime === 'image/avif' || mime === 'image/gif';
|
||||
if (isTransparentMime) {
|
||||
// Build a grey/white checkerboard via explicit xc: squares (no pattern replacement tricks):
|
||||
// row1 = [white | grey], row2 = row1 flipped → stack into a 2x2 tile → tile to thumbSpec
|
||||
const sq = 16;
|
||||
const tmpRow1 = path.join(os.tmpdir(), `${itemid}_r1.png`);
|
||||
const tmpRow2 = path.join(os.tmpdir(), `${itemid}_r2.png`);
|
||||
const tmpTile = path.join(os.tmpdir(), `${itemid}_tile.png`);
|
||||
const tmpBg = path.join(os.tmpdir(), `${itemid}_bg.png`);
|
||||
const tmpResized = path.join(os.tmpdir(), `${itemid}_rs.png`);
|
||||
try {
|
||||
await this.spawn('magick', ['-size', `${sq}x${sq}`, 'xc:white', '-size', `${sq}x${sq}`, 'xc:#cccccc', '+append', tmpRow1]);
|
||||
await this.spawn('magick', [tmpRow1, '-flop', tmpRow2]);
|
||||
await this.spawn('magick', [tmpRow1, tmpRow2, '-append', tmpTile]);
|
||||
await this.spawn('magick', ['-size', thumbSpec, `tile:${tmpTile}`, '-define', 'png:color-type=2', tmpBg]);
|
||||
// Resize/crop source image preserving alpha channel; force sRGB so palette images retain color
|
||||
await this.spawn('magick', [tmpFile, '-auto-orient', '-colorspace', 'sRGB', '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', tmpResized]);
|
||||
// Composite: Over operator — image (with alpha) on top of opaque checkerboard bg
|
||||
await this.spawn('magick', [tmpBg, tmpResized, '-composite', outPath]);
|
||||
} finally {
|
||||
for (const f of [tmpRow1, tmpRow2, tmpTile, tmpBg, tmpResized]) {
|
||||
await fs.promises.unlink(f).catch(() => {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await this.spawn('magick', [tmpFile, '-auto-orient', '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', outPath]);
|
||||
}
|
||||
await this.spawn('magick', [tmpFile, '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', path.join(tDir, itemid + '.webp')]);
|
||||
await fs.promises.unlink(tmpFile).catch(_ => { });
|
||||
await fs.promises.unlink(tmpJpg).catch(_ => { });
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[QUEUE] genThumbnail failed for item ${itemid} (${mime}):`, err.message || err);
|
||||
// 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 {
|
||||
await this.spawn('magick', [fallback404, '-resize', `${thumbSpec}^`, '-gravity', 'center', '-crop', `${thumbSpec}+0+0`, '+repage', outPath]);
|
||||
console.warn(`[QUEUE] Used 404.gif fallback thumbnail for item ${itemid}`);
|
||||
} catch (fallbackErr) {
|
||||
console.error(`[QUEUE] Even fallback thumbnail failed for item ${itemid}:`, fallbackErr.message || fallbackErr);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
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]);
|
||||
await this.spawn('magick', [src, '-blur', '0x20', dst]);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[QUEUE] Failed to generate blurred thumbnail for ${itemid}:`, err);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user