1 Commits

Author SHA1 Message Date
dbb8861aed adding different layouts for testing 2026-05-17 14:02:35 +02:00
162 changed files with 3875 additions and 26913 deletions

View File

@@ -1,10 +1,3 @@
POSTGRES_USER=f0ckm
POSTGRES_DB=f0ckm
POSTGRES_PASSWORD=f0ckm
# --- Nginx & Tor Profiles (Optional) ---
# Set to 'f0ckm-nginx' for Nginx, 'tor' for Tor hidden service, or 'f0ckm-nginx,tor' for both
# COMPOSE_PROFILES=tor
#
# VIRTUAL_HOST=yourdomain.com
# LETSENCRYPT_HOST=yourdomain.com
# LETSENCRYPT_EMAIL=your-email@example.com
POSTGRES_USER=
POSTGRES_DB=
POSTGRES_PASSWORD=

2
.gitignore vendored
View File

@@ -17,5 +17,3 @@ public/tag_cache
.env
config.json
bundle.css
public/s/fonts/impact.woff
f0ck-svelte/*

View File

@@ -1,30 +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.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
`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": {
@@ -54,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:
@@ -63,38 +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
## 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"
}
```

View File

@@ -1,4 +0,0 @@
client_max_body_size 10000M;
client_body_timeout 120s;
client_header_timeout 120s;

View File

@@ -1,9 +0,0 @@
# Tor configuration for f0ckm
# Enable SOCKS proxy for internal container & external connections
SocksPort 0.0.0.0:9050
# Tor Hidden Service (v3 Onion Service) configuration
# Tor will automatically generate private keys and hostnames in /var/lib/tor/f0ckm_hs/
HiddenServiceDir /var/lib/tor/f0ckm_hs/
HiddenServicePort 80 f0ckm:1337

View File

@@ -5,7 +5,6 @@
"domain": "example.com",
"regex": "example\\.com"
},
"onion": "http://your-onion-address.onion",
"socks": "socks5://127.0.0.1:9050",
"mail": "admin@example.com",
"maxfilesize": 104857600,
@@ -29,11 +28,6 @@
],
"enable_pdf": false,
"enable_nsfl": false,
"enable_private_uploads": true,
"enable_expiring_uploads": true,
"default_upload_visibility": 0,
"allow_user_upload_visibility": true,
"enable_item_slugs": true,
"nsfl_tag_id": 4,
"allowedMimes": [
"audio",
@@ -59,21 +53,14 @@
"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,
"halls_enabled": true,
"userhalls_enabled": true,
"enable_userhall_image_upload": true,
@@ -81,24 +68,12 @@
"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": [
@@ -108,14 +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": [
@@ -127,22 +94,12 @@
"enable_swiping": true,
"enable_profile_description": true,
"user_alternative_infobox": false,
"user_banner_enabled": true,
"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,
"paths": {
"images": "/b",
"thumbnails": "/t",
@@ -220,16 +177,7 @@
"video/x-matroska": "mkv",
"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-7z-compressed": "7z",
"application/x-tar": "tar",
"application/gzip": "gz",
"application/x-bzip2": "bz2",
"application/x-xz": "xz"
"application/pdf": "pdf"
},
"apis": {},
"smtp": {
@@ -241,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"
}
}

View File

@@ -11,14 +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/t/:/opt/f0ckm/public/t/:Z
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
@@ -31,15 +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/manifest.json:/opt/f0ckm/public/manifest.json:Z
environment:
- GIT_HASH=${f0ckm_TAG:-unknown}
- VIRTUAL_HOST=${VIRTUAL_HOST:-localhost}
- VIRTUAL_PORT=1337
- LETSENCRYPT_HOST=${LETSENCRYPT_HOST:-}
- LETSENCRYPT_EMAIL=${LETSENCRYPT_EMAIL:-}
ports:
- "1337:1337"
restart: unless-stopped
@@ -47,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
@@ -100,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:

View File

View File

View File

@@ -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);

View File

@@ -1,3 +0,0 @@
-- Migration: Add expires_at column to items table for expiring uploads
ALTER TABLE public.items ADD COLUMN IF NOT EXISTS expires_at bigint DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_items_expires_at ON public.items(expires_at) WHERE expires_at IS NOT NULL AND is_purged = false;

View File

@@ -1,5 +0,0 @@
-- Add favorites_private column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS favorites_private boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, only the owner and administrators can view the user''s favorites list';

View File

@@ -1,5 +0,0 @@
-- Add hide_fav_badge column to user_options table
ALTER TABLE user_options
ADD COLUMN IF NOT EXISTS hide_fav_badge boolean DEFAULT false;
COMMENT ON COLUMN public.user_options.favorites_private IS 'When true, the user''s favorite badge on item pages displays as a ghost icon without linking to their profile';

View File

@@ -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;

View File

@@ -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);

View File

@@ -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)';

View File

@@ -20,9 +20,6 @@ SET client_min_messages = warning;
SET row_security = off;
DROP PUBLICATION IF EXISTS alltables;
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_user_id_fkey;
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_api_key_key;
ALTER TABLE IF EXISTS ONLY public.user_api_keys DROP CONSTRAINT IF EXISTS user_api_keys_pkey;
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_user_id_fkey;
ALTER TABLE IF EXISTS ONLY public.user_warnings DROP CONSTRAINT IF EXISTS user_warnings_admin_id_fkey;
ALTER TABLE IF EXISTS ONLY public.user_video_views DROP CONSTRAINT IF EXISTS user_video_views_video_id_fkey;
@@ -79,7 +76,7 @@ CREATE OR REPLACE VIEW public.items_li AS
SELECT
NULL::integer AS id,
NULL::character varying(255) AS src,
NULL::character varying(60) AS dest,
NULL::character varying(40) AS dest,
NULL::character varying(100) AS mime,
NULL::integer AS size,
NULL::character varying(255) AS checksum,
@@ -94,7 +91,6 @@ DROP INDEX IF EXISTS public.idx_user_last_seen;
DROP INDEX IF EXISTS public.idx_user_halls_user_id;
DROP INDEX IF EXISTS public.idx_user_halls_assign_item;
DROP INDEX IF EXISTS public.idx_user_halls_assign_hall;
DROP INDEX IF EXISTS public.idx_user_api_keys_api_key;
DROP INDEX IF EXISTS public.idx_user_alias_userid;
DROP INDEX IF EXISTS public.idx_user_alias_type;
DROP INDEX IF EXISTS public.idx_user_alias_alias;
@@ -114,7 +110,6 @@ DROP INDEX IF EXISTS public.idx_items_username;
DROP INDEX IF EXISTS public.idx_items_pinned_id;
DROP INDEX IF EXISTS public.idx_items_is_purged;
DROP INDEX IF EXISTS public.idx_items_is_deleted;
DROP INDEX IF EXISTS public.idx_items_slug;
DROP INDEX IF EXISTS public.idx_items_active_id;
DROP INDEX IF EXISTS public.idx_global_chat_created_at;
DROP INDEX IF EXISTS public.idx_discord_queue_sent;
@@ -196,7 +191,6 @@ DROP TABLE IF EXISTS public.user_halls_assign;
DROP TABLE IF EXISTS public.user_halls;
DROP TABLE IF EXISTS public.user_dm_keyvault;
DROP TABLE IF EXISTS public.user_conversation_states;
DROP TABLE IF EXISTS public.user_api_keys;
DROP TABLE IF EXISTS public.user_alias;
DROP TABLE IF EXISTS public."user";
DROP SEQUENCE IF EXISTS public.user_id_seq;
@@ -594,7 +588,7 @@ CREATE TABLE public.comment_files (
id integer NOT NULL,
comment_id integer,
user_id integer NOT NULL,
dest character varying(60) NOT NULL,
dest character varying(40) NOT NULL,
mime character varying(100) NOT NULL,
size integer NOT NULL,
checksum character varying(255) NOT NULL,
@@ -606,8 +600,6 @@ CREATE TABLE public.comment_files (
ALTER TABLE public.comment_files OWNER TO f0ckm;
ALTER TABLE ONLY public.comment_files REPLICA IDENTITY DEFAULT;
--
-- Name: comment_files_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
--
@@ -828,25 +820,12 @@ CREATE TABLE public.invite_tokens (
used_by integer,
is_used boolean DEFAULT false,
created_by_discord character varying(255) DEFAULT NULL::character varying,
created_by_matrix character varying(255) DEFAULT NULL::character varying,
used_at timestamp with time zone DEFAULT NULL
created_by_matrix character varying(255) DEFAULT NULL::character varying
);
ALTER TABLE public.invite_tokens OWNER TO f0ckm;
--
-- Name: idx_invite_tokens_created_by; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX IF NOT EXISTS idx_invite_tokens_created_by ON public.invite_tokens (created_by);
--
-- Name: idx_invite_tokens_used_at; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX IF NOT EXISTS idx_invite_tokens_used_at ON public.invite_tokens (used_at) WHERE is_used = true;
--
-- Name: invite_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: f0ckm
--
@@ -890,7 +869,7 @@ ALTER SEQUENCE public.items_id_seq OWNER TO f0ckm;
CREATE TABLE public.items (
id integer DEFAULT nextval('public.items_id_seq'::regclass) NOT NULL,
src character varying(255) NOT NULL,
dest character varying(60) NOT NULL,
dest character varying(40) NOT NULL,
mime character varying(100) NOT NULL,
size integer NOT NULL,
checksum character varying(255) NOT NULL,
@@ -908,21 +887,12 @@ CREATE TABLE public.items (
is_pinned boolean DEFAULT false,
is_oc boolean DEFAULT false,
xd_score integer DEFAULT 0 NOT NULL,
original_filename text,
title text,
width integer,
height integer,
visibility smallint DEFAULT 0 NOT NULL,
slug character varying(16)
original_filename text
);
ALTER TABLE public.items OWNER TO f0ckm;
COMMENT ON COLUMN public.items.visibility IS '0=public, 1=unlisted, 2=private';
COMMENT ON COLUMN public.items.slug IS 'Unique unguessable 11-character URL slug for direct access';
--
-- Name: COLUMN items.src; Type: COMMENT; Schema: public; Owner: f0ckm
--
@@ -1356,21 +1326,6 @@ CREATE TABLE public."user" (
ALTER TABLE public."user" OWNER TO f0ckm;
--
-- Name: user_api_keys; Type: TABLE; Schema: public; Owner: f0ckm
--
CREATE TABLE public.user_api_keys (
user_id integer NOT NULL,
api_key text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT user_api_keys_pkey PRIMARY KEY (user_id),
CONSTRAINT user_api_keys_api_key_key UNIQUE (api_key)
);
ALTER TABLE public.user_api_keys OWNER TO f0ckm;
--
-- Name: user_alias; Type: TABLE; Schema: public; Owner: f0ckm
--
@@ -1500,30 +1455,17 @@ CREATE TABLE public.user_options (
hide_koepfe boolean DEFAULT false NOT NULL,
language text,
use_alternative_infobox boolean DEFAULT false,
use_alternative_steuerung boolean DEFAULT NULL,
receive_system_notifications boolean DEFAULT true,
receive_user_notifications boolean DEFAULT true,
do_not_disturb boolean DEFAULT false,
comment_display_mode integer DEFAULT 1,
force_comment_display_mode integer DEFAULT 0,
favorites_private boolean DEFAULT false,
hide_fav_badge boolean DEFAULT false,
default_upload_visibility smallint DEFAULT 0,
banner_file character varying(255) DEFAULT NULL::character varying,
banner_position character varying(50) DEFAULT 'center'::character varying,
banner_size character varying(50) DEFAULT 'cover'::character varying
feed_layout smallint DEFAULT 0 NOT NULL
);
ALTER TABLE public.user_options OWNER TO f0ckm;
COMMENT ON COLUMN public.user_options.default_upload_visibility IS 'Default upload visibility preference for user (0=public, 1=unlisted, 2=private)';
COMMENT ON COLUMN public.user_options.banner_file IS 'Custom uploaded banner image filename, stored in public/a/';
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';
--
-- Name: COLUMN user_options.avatar_file; Type: COMMENT; Schema: public; Owner: f0ckm
--
@@ -1645,13 +1587,6 @@ ALTER TABLE ONLY public.audit_log ALTER COLUMN id SET DEFAULT nextval('public.au
ALTER TABLE ONLY public.comments ALTER COLUMN id SET DEFAULT nextval('public.comments_id_seq'::regclass);
--
-- Name: comment_files id; Type: DEFAULT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files ALTER COLUMN id SET DEFAULT nextval('public.comment_files_id_seq'::regclass);
--
-- Name: custom_emojis id; Type: DEFAULT; Schema: public; Owner: f0ckm
--
@@ -1759,14 +1694,6 @@ ALTER TABLE ONLY public.comment_subscriptions
ADD CONSTRAINT comment_subscriptions_pkey PRIMARY KEY (user_id, item_id);
--
-- Name: comment_files comment_files_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files
ADD CONSTRAINT comment_files_pkey PRIMARY KEY (id);
--
-- Name: comments comments_pkey; Type: CONSTRAINT; Schema: public; Owner: f0ckm
--
@@ -2182,13 +2109,6 @@ CREATE INDEX idx_global_chat_created_at ON public.global_chat USING btree (creat
CREATE INDEX idx_items_active_id ON public.items USING btree (id) WHERE (active = true);
--
-- Name: idx_items_slug; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE UNIQUE INDEX idx_items_slug ON public.items USING btree (slug);
--
-- Name: idx_items_is_deleted; Type: INDEX; Schema: public; Owner: f0ckm
--
@@ -2322,13 +2242,6 @@ CREATE INDEX idx_user_alias_type ON public.user_alias USING btree (type);
CREATE INDEX idx_user_alias_userid ON public.user_alias USING btree (userid);
--
-- Name: idx_user_api_keys_api_key; Type: INDEX; Schema: public; Owner: f0ckm
--
CREATE INDEX idx_user_api_keys_api_key ON public.user_api_keys USING btree (api_key);
--
-- Name: idx_user_halls_assign_hall; Type: INDEX; Schema: public; Owner: f0ckm
--
@@ -2473,22 +2386,6 @@ ALTER TABLE ONLY public.comment_subscriptions
ADD CONSTRAINT comment_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: comment_files comment_files_comment_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files
ADD CONSTRAINT comment_files_comment_id_fkey FOREIGN KEY (comment_id) REFERENCES public.comments(id) ON DELETE CASCADE;
--
-- Name: comment_files comment_files_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.comment_files
ADD CONSTRAINT comment_files_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: comments comments_item_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
@@ -2825,14 +2722,6 @@ ALTER TABLE ONLY public.user_warnings
ADD CONSTRAINT user_warnings_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: user_api_keys user_api_keys_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: f0ckm
--
ALTER TABLE ONLY public.user_api_keys
ADD CONSTRAINT user_api_keys_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id) ON DELETE CASCADE;
--
-- Name: alltables; Type: PUBLICATION; Schema: -; Owner: f0ckm
--
@@ -2871,109 +2760,4 @@ CREATE INDEX IF NOT EXISTS idx_user_ips_user_id ON user_ips(user_id);
-- Add IP tracking to user_sessions for "current" IP view
ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT;
-- DM encrypted attachments (Migration 005)
CREATE TABLE IF NOT EXISTS public.dm_attachments (
id bigserial PRIMARY KEY,
sender_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
recipient_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
iv text NOT NULL,
file_path text NOT NULL,
original_name text NOT NULL DEFAULT '',
mime_hint text NOT NULL DEFAULT '',
size_bytes integer NOT NULL DEFAULT 0,
created_at timestamp with time zone DEFAULT now(),
expires_at timestamp with time zone DEFAULT (now() + interval '90 days')
);
CREATE INDEX IF NOT EXISTS idx_dm_att_sender ON public.dm_attachments(sender_id);
CREATE INDEX IF NOT EXISTS idx_dm_att_recipient ON public.dm_attachments(recipient_id);
CREATE INDEX IF NOT EXISTS idx_dm_att_expires ON public.dm_attachments(expires_at);
-- DM message edit/delete support (Migration 006)
ALTER TABLE private_messages
ADD COLUMN IF NOT EXISTS edited_at timestamp with time zone DEFAULT NULL;
-- Wordfilter Table (Migration 009)
CREATE TABLE IF NOT EXISTS public.wordfilter (
id SERIAL PRIMARY KEY,
word VARCHAR(255) UNIQUE NOT NULL,
replacement VARCHAR(255) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Comment Polls
CREATE TABLE IF NOT EXISTS public.comment_polls (
id SERIAL PRIMARY KEY,
comment_id INTEGER NOT NULL REFERENCES public.comments(id) ON DELETE CASCADE,
question TEXT NOT NULL,
is_anonymous BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT NULL,
UNIQUE(comment_id)
);
CREATE TABLE IF NOT EXISTS public.comment_poll_options (
id SERIAL PRIMARY KEY,
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
text TEXT NOT NULL,
sort_order SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS public.comment_poll_votes (
id SERIAL PRIMARY KEY,
poll_id INTEGER NOT NULL REFERENCES public.comment_polls(id) ON DELETE CASCADE,
option_id INTEGER NOT NULL REFERENCES public.comment_poll_options(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(poll_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_comment_polls_comment_id ON public.comment_polls(comment_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_options_poll ON public.comment_poll_options(poll_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_poll ON public.comment_poll_votes(poll_id);
CREATE INDEX IF NOT EXISTS idx_comment_poll_votes_user ON public.comment_poll_votes(user_id);
-- Sticker Packs (Migration 010)
-- sticker_packs table: holds imported Telegram (or custom) sticker pack metadata
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;
-- Link custom_emojis to a sticker pack (NULL = standalone emoji)
ALTER TABLE public.custom_emojis
ADD COLUMN IF NOT EXISTS pack_id integer REFERENCES public.sticker_packs(id) ON DELETE SET NULL;
-- Ensure thumb_url exists even on databases created before this column was added
ALTER TABLE public.sticker_packs
ADD COLUMN IF NOT EXISTS thumb_url text;
CREATE INDEX IF NOT EXISTS custom_emojis_pack_id_idx ON public.custom_emojis (pack_id);
-- User Emoji Favorites (Migration 011)
-- Persists per-user sticker/emoji favorites for cross-device sync
CREATE TABLE IF NOT EXISTS public.user_emoji_favorites (
user_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE,
emoji_name text NOT NULL,
emoji_url text NOT NULL,
created_at timestamp with time zone NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, emoji_name)
);
ALTER TABLE public.user_emoji_favorites OWNER TO f0ckm;
CREATE INDEX IF NOT EXISTS idx_user_emoji_favorites_user_id ON public.user_emoji_favorites (user_id);
-- User Banners
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_file character varying(255) DEFAULT NULL;
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_position character varying(50) DEFAULT 'center';
ALTER TABLE public.user_options ADD COLUMN IF NOT EXISTS banner_size character varying(50) DEFAULT 'cover';
\unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG

View File

@@ -1,2 +0,0 @@
-- Fix items with NULL visibility (should default to 0 = public)
UPDATE items SET visibility = 0 WHERE visibility IS NULL;

12
package-lock.json generated
View File

@@ -75,9 +75,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -492,9 +492,9 @@
"integrity": "sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg=="
},
"brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"requires": {
"balanced-match": "^1.0.0"
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -118,7 +118,7 @@
flex-direction: column;
/* Stacked */
align-items: center;
gap: 0.3rem;
gap: 1rem;
width: 100%;
}
@@ -219,13 +219,19 @@
.upload-form:not(.shitpost-mode-active) .preview-media-small {
width: 100%;
max-width: 400px;
min-height: auto;
height: auto;
aspect-ratio: 16 / 9;
object-fit: contain;
}
.upload-form.shitpost-mode-active .preview-media-small {
width: 120px;
height: 80px;
object-fit: contain;
min-height: auto;
}
.preview-media-small {
flex-shrink: 0;
@@ -241,7 +247,7 @@
align-items: center;
}
.upload-form.shitpost-mode-active .file-name-small {
.upload-form.shitpost-mode-active .file-meta-row-small {
padding-right: 30px; /* Space for X button */
}
@@ -249,9 +255,8 @@
flex: 1;
display: flex;
flex-direction: column;
overflow: visible;
overflow: hidden;
min-width: 0;
width: 100%;
}
.file-info-small {
@@ -362,8 +367,6 @@
}
.upload-form.shitpost-mode-active .global-rating-section,
.upload-form.shitpost-mode-active .global-visibility-section,
.upload-form.shitpost-mode-active .global-expiry-section,
.upload-form.shitpost-mode-active .global-comment-section,
.upload-form.shitpost-mode-active .global-tag-section {
display: none !important;
@@ -400,91 +403,56 @@
color: rgba(255, 255, 255, 0.4);
transition: all 0.2s;
text-transform: uppercase;
user-select: none;
}
.item-rating-option input:checked + .item-rating-label.sfw {
background: var(--badge-sfw);
background: #40c057;
color: #fff;
border-color: var(--badge-sfw);
border-color: #40c057;
}
.item-rating-option input:checked + .item-rating-label.nsfw {
background: var(--badge-nsfw);
background: #fd7e14;
color: #fff;
border-color: var(--badge-nsfw);
border-color: #fd7e14;
}
.item-rating-option input:checked + .item-rating-label.nsfl {
background: var(--badge-nsfl);
background: #fa5252;
color: #fff;
border-color: var(--badge-nsfl);
border-color: #fa5252;
}
/* Visibility Container - Only checked option is colored, unchecked options are gray */
.item-visibility-container .item-rating-option input:checked + .item-rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
color: #51cf66;
border-color: rgba(81, 207, 102, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
color: #ffbb33;
border-color: rgba(255, 187, 51, 0.5);
}
.item-visibility-container .item-rating-option input:checked + .item-rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
color: #ff4444;
border-color: rgba(255, 68, 68, 0.5);
}
.item-rating-label:hover {
background: rgba(255, 255, 255, 0.1);
}
/* Bigger Previews in Shitpost Mode */
.upload-form.shitpost-mode-active .file-preview-item {
.file-preview-item {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 15px;
gap: 16px;
gap: 0;
width: 100%;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.upload-form.shitpost-mode-active .preview-media-small {
.preview-media-small {
flex: 0 0 50%;
width: 50% !important;
max-width: 50% !important;
height: 240px !important;
max-height: 240px !important;
height: auto !important;
min-height: 120px;
max-height: 350px;
object-fit: contain;
border-radius: 8px;
background: rgba(0,0,0,0.3);
overflow: hidden; /* Clip the video — Chrome cannot expand past this */
}
/* Inner video inside the wrapper fills the container and cannot resize it */
.upload-form.shitpost-mode-active .preview-video-wrapper > video {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
display: block;
object-fit: contain;
}
/* Per-item Tags */
.item-tags-container {
margin-top: 10px;
width: 100%;
position: relative;
}
.item-tags-list {
@@ -581,34 +549,6 @@
border-color: var(--accent);
}
.item-title-container {
margin-top: 6px;
width: 100%;
}
.item-title-input {
width: 100%;
background: rgba(0,0,0,0.3);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
padding: 5px 10px;
color: #fff;
font-size: 0.8rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.item-title-input::placeholder {
color: rgba(255,255,255,0.3);
font-style: italic;
}
.item-title-input:focus {
border-color: var(--accent);
}
.rating-options {
display: flex;
gap: 1rem;
@@ -684,32 +624,6 @@
opacity: 1;
}
/* Global Visibility Section styling - unchecked options stay muted, checked options show colors */
.global-visibility-section .rating-option input:checked + .rating-label.sfw {
background: rgba(81, 207, 102, 0.2);
border-color: #51cf66;
color: #51cf66;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfw {
background: rgba(255, 187, 51, 0.2);
border-color: #ffbb33;
color: #ffbb33;
box-shadow: none;
opacity: 1;
}
.global-visibility-section .rating-option input:checked + .rating-label.nsfl {
background: rgba(255, 68, 68, 0.2);
border-color: #ff4444;
color: #ff4444;
box-shadow: none;
opacity: 1;
}
/* Tags */
.tag-input-container {
background: rgba(255, 255, 255, 0.05);
@@ -762,29 +676,6 @@
outline: none;
}
/* Global title input (normal mode) — matches tag-input-container style */
.upload-title-input {
width: 100%;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 0;
padding: 0.5rem;
color: inherit;
font-family: inherit;
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
box-sizing: border-box;
}
.upload-title-input:focus {
border-color: var(--accent, #7c5cbf);
}
.upload-title-input::placeholder {
color: rgba(255, 255, 255, 0.3);
}
.tag-count {
font-weight: normal;
font-size: 0.85rem;
@@ -844,9 +735,9 @@
.upload-form .tag-suggestions {
position: absolute !important;
min-width: 220px;
max-width: 320px;
max-height: 260px;
min-width: 220px !important;
max-width: 320px !important;
max-height: 260px !important;
overflow-y: auto !important;
background: var(--dropdown-bg, #1a1a1a) !important;
border: 1px solid var(--black, #000) !important;
@@ -859,7 +750,9 @@
scrollbar-width: thin !important;
}
.upload-form.shitpost-mode-active .tag-suggestions {
position: relative !important;
}
@keyframes tagDropIn {
from {
@@ -1350,7 +1243,7 @@
flex-shrink: 0;
}
@media (max-width: 768px) {
@media (max-width: 600px) {
.upload-form.shitpost-mode-active .file-preview-item {
flex-direction: column;
align-items: stretch;
@@ -1360,7 +1253,6 @@
.upload-form.shitpost-mode-active .preview-media-small,
.upload-form.shitpost-mode-active .item-media-col {
width: 100% !important;
max-width: 100% !important;
flex: 0 0 auto;
min-height: auto;
}
@@ -1368,341 +1260,4 @@
.upload-form.shitpost-mode-active .item-media-col iframe {
height: 200px;
}
/* Keep suggestions visible and position them above the tag input */
.upload-form.shitpost-mode-active .file-meta-row-small {
overflow: visible !important;
}
.upload-form.shitpost-mode-active .tag-suggestions {
position: absolute !important;
bottom: 100% !important;
top: auto !important;
left: 0 !important;
right: 0 !important;
width: 100% !important;
max-width: none !important;
margin-top: 0 !important;
margin-bottom: 8px !important;
z-index: 200000 !important;
}
}
/* Video thumbnail loading animation */
.video-thumbnail-loading {
animation: thumbPulse 1.5s ease-in-out infinite;
background: rgba(255, 255, 255, 0.05);
}
@keyframes thumbPulse {
0% { opacity: 0.4; }
50% { opacity: 0.8; }
100% { opacity: 0.4; }
}
/* ── Ruffle (Flash) Upload Preview ─────────────────────────────────────────── */
/* The container: CSS Grid with 3 rows — player | snapshot preview | button.
All rows are auto so the container grows naturally; no overflow clipping. */
.swf-upload-preview {
position: relative;
display: grid;
grid-template-rows: auto auto auto;
max-height: none !important;
background: #000;
border-radius: 8px;
}
/* Normal (non-shitpost) mode: full-width, player drives container height via aspect-ratio */
.upload-form:not(.shitpost-mode-active) .swf-upload-preview.preview-media-small {
width: 100% !important;
height: auto !important;
max-height: none !important;
min-height: auto !important;
}
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-player,
.upload-form:not(.shitpost-mode-active) .swf-upload-preview ruffle-object {
aspect-ratio: 16 / 9;
width: 100% !important;
height: auto !important;
}
/* Shitpost mode: player row is a fixed height so the snapshot row doesn't steal its space */
.upload-form.shitpost-mode-active .swf-upload-preview.preview-media-small {
width: 45% !important;
flex: 0 0 45% !important;
height: auto !important;
max-height: none !important;
min-height: auto !important;
aspect-ratio: unset;
}
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-player,
.upload-form.shitpost-mode-active .swf-upload-preview ruffle-object {
width: 100% !important;
height: 220px !important;
min-height: 220px;
}
/* Placeholder shown while Ruffle is loading */
.swf-upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
min-height: 200px;
background: rgba(0, 0, 0, 0.6);
animation: thumbPulse 1.8s ease-in-out infinite;
}
.swf-upload-placeholder-icon {
font-size: 2.5rem;
line-height: 1;
filter: drop-shadow(0 0 8px rgba(255, 200, 0, 0.7));
}
.swf-upload-placeholder-text {
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.5px;
}
/* ── Ruffle Snapshot Button ─────────────────────────────────────────────────── */
/* The snapshot button lives inside .swf-upload-preview, pinned to the bottom */
.swf-upload-preview {
position: relative;
display: flex;
flex-direction: column;
}
.btn-ruffle-snapshot {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
margin-top: 0;
padding: 8px 16px;
background: rgba(255, 200, 0, 0.08);
border: none;
border-top: 1px solid rgba(255, 200, 0, 0.2);
border-radius: 0 0 6px 6px;
color: rgba(255, 200, 0, 0.9);
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.2s ease;
letter-spacing: 0.3px;
flex-shrink: 0;
}
.btn-ruffle-snapshot:hover:not(:disabled) {
background: rgba(255, 200, 0, 0.15);
border-top-color: rgba(255, 200, 0, 0.4);
color: #ffd700;
}
.btn-ruffle-snapshot:active:not(:disabled) {
background: rgba(255, 200, 0, 0.22);
}
.btn-ruffle-snapshot:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Snapshot preview: sits in its own grid row, below the player */
.swf-upload-preview .ruffle-snapshot-preview {
display: block;
width: 100%;
height: auto;
object-fit: contain;
object-position: center;
background: rgba(0, 0, 0, 0.6);
border-top: 1px solid rgba(81, 207, 102, 0.3);
animation: snapPreviewIn 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes snapPreviewIn {
from { opacity: 0; transform: scale(0.96) translateY(4px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
/* =============================================
URL Upload Tracker — single persistent panel
============================================= */
#url-upload-tracker {
position: fixed;
bottom: 20px;
left: 20px;
z-index: 9998;
width: 320px;
background: var(--nav-bg, #1c1c1c);
border: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.15);
overflow: hidden;
font-size: 0.8rem;
opacity: 0;
transform: translateY(10px);
transition: opacity 1.5s ease, transform 1.5s ease;
pointer-events: none;
}
#url-upload-tracker.uut-visible {
opacity: 1;
transform: translateY(0);
pointer-events: all;
}
/* ── Header ──────────────────────────────────────── */
.uut-header {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 10px 8px 12px;
background: rgba(var(--accent-rgb, 120,120,120), 0.07);
border-bottom: 1px solid rgba(var(--accent-rgb, 120,120,120), 0.1);
user-select: none;
}
.uut-header > .fa-solid {
color: var(--accent);
font-size: 11px;
opacity: 0.75;
flex-shrink: 0;
}
.uut-title {
flex: 1;
font-size: 0.74rem;
font-weight: 600;
color: var(--white, #fff);
letter-spacing: 0.01em;
}
.uut-badge {
background: var(--accent);
color: #fff;
font-size: 0.62rem;
font-weight: 700;
min-width: 16px;
height: 16px;
border-radius: 8px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.uut-actions { display: flex; gap: 2px; }
.uut-btn {
background: none;
border: none;
cursor: pointer;
color: var(--white, #fff);
opacity: 0.3;
padding: 3px 6px;
border-radius: 4px;
font-size: 12px;
line-height: 1;
transition: opacity 0.15s, background 0.15s;
}
.uut-btn:hover { opacity: 0.8; background: rgba(var(--accent-rgb,120,120,120),0.12); }
/* ── Body (scrollable) ───────────────────────────── */
.uut-body {
max-height: 340px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(var(--accent-rgb,120,120,120), 0.25) transparent;
}
/* ── Active job rows ─────────────────────────────── */
.uut-job {
padding: 8px 12px 6px;
border-bottom: 1px solid rgba(var(--accent-rgb,120,120,120), 0.06);
transition: opacity 0.3s;
}
.uut-job--fading { opacity: 0; }
.uut-job-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.uut-spinner {
width: 11px;
height: 11px;
flex-shrink: 0;
margin-top: 3px;
border: 1.5px solid rgba(var(--accent-rgb,120,120,120), 0.18);
border-top-color: var(--accent);
border-radius: 50%;
animation: uutSpin 0.75s linear infinite;
}
@keyframes uutSpin { to { transform: rotate(360deg); } }
.uut-job-info { flex: 1; min-width: 0; }
.uut-url {
display: block;
font-size: 0.72rem;
color: var(--white, #fff);
opacity: 0.65;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.uut-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
}
.uut-stage {
font-size: 0.71rem;
color: var(--accent);
white-space: nowrap;
}
.uut-speed {
font-size: 0.67rem;
color: var(--white, #fff);
opacity: 0.38;
margin-left: auto;
white-space: nowrap;
}
/* Progress bar */
.uut-bar {
height: 2px;
background: rgba(var(--accent-rgb,120,120,120), 0.1);
border-radius: 2px;
overflow: hidden;
margin-top: 6px;
}
.uut-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 0.65s ease;
background-image: linear-gradient(90deg, var(--accent) 0%, rgba(255,255,255,0.4) 50%, var(--accent) 100%);
background-size: 200% 100%;
animation: uutShimmer 1.6s linear infinite;
}
@keyframes uutShimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }

View File

@@ -138,11 +138,13 @@
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;
@@ -432,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;
}
@@ -447,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;
}
@@ -513,38 +512,3 @@
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 in fullscreen */
.v0ck.v0ck_fullscreen:not(.v0ck_hover),
.v0ck.v0ck_fullscreen:not(.v0ck_hover) * {
cursor: none !important;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" fill="#242526"/>
<path d="M50 22 C37 22 28 32 28 45 L28 72 L35 66 L42 72 L50 66 L58 72 L65 66 L72 72 L72 45 C72 32 63 22 50 22 Z" fill="#666666" opacity="0.6"/>
<text x="50" y="58" font-family="system-ui, -apple-system, sans-serif" font-weight="bold" font-size="36" fill="#ffffff" text-anchor="middle">?</text>
</svg>

Before

Width:  |  Height:  |  Size: 443 B

24
public/s/img/iconset.svg Normal file
View File

@@ -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

View File

@@ -1,24 +1,18 @@
(async () => {
// Helper to get dynamic context
const getContext = () => {
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
const usernameEl = document.querySelector("a#a_username");
return {
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
postid: +idLink.innerText,
// data-username holds the raw DB username; data-author-id holds the user's numeric ID.
// Never fall back to innerText — it may be a display name or the literal string 'unknown'.
poster: (usernameEl?.dataset?.username || '').trim() || null,
authorId: (usernameEl?.dataset?.authorId || '').trim() || null,
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
};
};
@@ -66,7 +60,7 @@
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');
}
@@ -161,13 +155,7 @@
postid,
existingTags: tags,
anchorEl: anchor,
onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
renderTags
});
};
@@ -211,6 +199,7 @@
a.appendChild(img);
favcontainer.appendChild(a);
favcontainer.appendChild(document.createTextNode('\u00A0'));
});
favcontainer.hidden = false;
} else {
@@ -390,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) => {
@@ -484,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');
}
@@ -502,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) => {
@@ -544,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' }
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -533,7 +533,7 @@ class Danmaku {
const raw = msg.dataset.rawText;
if (!raw) return;
// Only re-render if the content is still plain text (no img children)
if (!msg.querySelector('.dpill-emoji')) {
if (!msg.querySelector('img.dpill-emoji')) {
msg.textContent = '';
msg.appendChild(this._renderContent(raw));
}
@@ -600,25 +600,11 @@ class Danmaku {
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]));
}

View File

@@ -46,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');
}
@@ -67,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;
@@ -87,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');

File diff suppressed because it is too large Load Diff

View File

@@ -634,12 +634,8 @@
// Visual state: strike-through when disabled
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');
ui.floatingBadge.style.display = (swfButtons.length === 0 && !!primaryVideo) ? 'block' : 'none';
// Handle floating badge visibility
ui.floatingBadge.style.display = swfButtons.length > 0 ? 'none' : 'block';
// Style both (if they exist)
[ui.floatingBadge, ...swfButtons].forEach(b => {

View File

@@ -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);
}

View File

@@ -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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -209,12 +209,6 @@
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 || {};
@@ -235,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' });
@@ -718,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);
}
}
});
@@ -1000,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();
@@ -1158,14 +1143,10 @@
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}${csrfToken ? `&csrf_token=${encodeURIComponent(csrfToken)}` : ''}`
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: `postid=${id}`
});
const data = await resp.json();
// Sync count to server truth (handles race conditions)
@@ -1385,15 +1366,18 @@
${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>
@@ -1401,7 +1385,7 @@
</button>
${item.is_external ? (
item.local_id
? `<a class="scroll-btn rehost-btn success" href="/${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>`
@@ -1562,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 {}
});
}
@@ -1587,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();
@@ -1694,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);
@@ -1832,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);
@@ -1856,19 +1849,17 @@
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,
rating: rating,
tags: '',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`,
...(item.original_filename ? { original_filename: item.original_filename } : {})
tags: '4chan',
comment: `Rehosted from 4chan thread: ${applied.externalUrl || 'unknown'}`
})
});
const data = await resp.json();
@@ -1894,7 +1885,7 @@
// Update button to link to the new site-internal post
setTimeout(() => {
btn.outerHTML = `
<a href="/${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>
@@ -1934,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();
@@ -2127,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('&gt;') || raw.includes('&lt;') || raw.includes('&amp;')) {
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`;
@@ -2800,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();
@@ -2823,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
@@ -2917,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) {
@@ -3227,12 +3136,26 @@
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); }
@@ -3518,7 +3441,7 @@
// Tab type arrays
const SCROLLER_USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
const SCROLLER_SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
let sActiveTab = 'user';
let sCachedNotifs = [];
@@ -3622,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';
@@ -3632,13 +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 {
const thumbSrc = n.type === 'admin_pending' ? `/mod/pending/t/${n.item_id}.webp` : `/t/${n.item_id}.webp`;
thumb = n.item_id ? `<div class="notif-thumb"><img src="${thumbSrc}" alt="" onerror="this.style.display='none'"></div>` : '';
}
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">
@@ -3680,11 +3595,7 @@
if (sMarkAll) {
sMarkAll.addEventListener('click', async () => {
try {
const csrfToken = window.f0ckSession?.csrf_token || window.scrollerCsrf || '';
await fetch('/api/notifications/read', {
method: 'POST',
headers: { ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}) }
});
await fetch('/api/notifications/read', { method: 'POST' });
updateScrollerNotifBadge(0);
sCachedNotifs = sCachedNotifs.map(n => ({ ...n, is_read: true }));
updateScrollerTabBadges(sCachedNotifs);
@@ -3699,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));

File diff suppressed because it is too large Load Diff

View File

@@ -5,19 +5,10 @@
let currentPage = 1;
let hasMore = true;
let ioSentinel = null; // persistent sentinel element for IntersectionObserver
let lastRenderedIds = ''; // track rendered comment IDs to skip needless re-renders
// Shared cache for activity across AJAX loads
if (!window._sidebarActivityCache) window._sidebarActivityCache = [];
const loadEmojis = async () => {
// Prefer CommentSystem's already-fetched cache to avoid a redundant request
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache)
|| null;
if (csCache && Object.keys(csCache).length > 0) {
Object.assign(customEmojis, csCache);
return;
}
// No shared cache yet — fetch directly
if (Object.keys(customEmojis).length > 0) return;
try {
const res = await fetch('/api/v2/emojis');
@@ -34,11 +25,7 @@
const renderEmoji = (match, name) => {
if (customEmojis[name]) {
const url = customEmojis[name];
if (url.endsWith('.webm')) {
return `<video class="sidebar-comment-img emoji" src="${url}" title=":${name}:" autoplay loop muted playsinline></video>`;
}
return `<img class="sidebar-comment-img emoji" src="${url}" alt="${name}" title=":${name}:" loading="lazy">`;
return `<img class="sidebar-comment-img emoji" src="${customEmojis[name]}" alt="${name}" title=":${name}:" loading="lazy">`;
}
return match;
};
@@ -47,22 +34,12 @@
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
};
const playSidebarEmojiVideos = (container) => {
if (!container) return;
container.querySelectorAll('video.emoji').forEach(v => {
v.play().catch(() => {
v.addEventListener('canplay', () => v.play().catch(() => {}), { once: true });
});
});
return div.innerHTML;
};
const ytOembedCache = new Map(); // videoId -> meta object
const ytOembedPending = new Map(); // videoId -> Promise
const fetchSidebarYoutubeTitles = async (container) => {
const links = container.querySelectorAll('.sidebar-video-link[data-yt-id]');
if (links.length === 0) return;
@@ -80,40 +57,8 @@
meta = await ytOembedPending.get(videoId);
} else {
const promise = (async () => {
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
// 1. Try client-side oEmbed first (avoids Tor/proxy consent walls)
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(ytUrl)}&format=json`;
const clientResp = await fetch(oembedUrl);
if (clientResp.ok) {
const clientData = await clientResp.json();
if (clientData.title) {
const metaObj = {
title: clientData.title,
site_name: 'youtube.com',
author: clientData.author_name || 'Unknown'
};
// Cache client-side
ytOembedCache.set(videoId, metaObj);
// Push to server cache so other clients/server benefit
try {
const csrf = window.f0ckSession?.csrf_token;
fetch('/api/v2/meta/cache', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'X-CSRF-Token': csrf } : {})
},
body: JSON.stringify({ url: ytUrl, meta: metaObj })
}).catch(() => {});
} catch (_) {}
return metaObj;
}
}
} catch (e) { }
// 2. Fall back to backend meta fetch
try {
const ytUrl = `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`;
const r = await fetch(`/api/v2/meta/fetch?url=${encodeURIComponent(ytUrl)}`);
if (r.ok) {
const data = await r.json();
@@ -138,12 +83,6 @@
titleSpan.textContent = 'YouTube Video';
}
titleSpan.dataset.loaded = 'true';
// Re-check overflow since the new text might cause wrapping
const inner = link.closest('.comment-content-inner');
if (inner && typeof checkOverflow === 'function') {
checkOverflow(inner);
}
}
};
@@ -153,6 +92,12 @@
const renderCommentContent = (content, commentId = null, itemId = null) => {
if (!content) return '';
// Truncate extremely long comments before any processing to keep the sidebar
// fast and the DOM lean, regardless of markdown / regex complexity.
if (content.length > SIDEBAR_CONTENT_TRUNCATE) {
content = content.substring(0, SIDEBAR_CONTENT_TRUNCATE) + '\u2026';
}
if (typeof marked === 'undefined') {
return escapeHtml(content)
.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n));
@@ -183,10 +128,10 @@
const hostsRegexPart = allowedHosts.join('|');
// "Safe non-whitespace" stops at protocol boundaries so concatenated URLs aren't merged.
const safeS = `(?:(?!https?:\\/\\/)\\S)`;
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?:(?<!\\S)|(?<=\\]))(?=\\/[a-zA-Z0-9_\\-]))`;
const domainOrRelative = `(?:(?:https?:\\/\\/|\\/\\/)?(?:${hostsRegexPart})|(?<!\\S)(?=\\/[a-zA-Z0-9_\\-]))`;
const imageRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/${safeS}+\\.(?:jpg|jpeg|png|gif|webp)(?:\\?${safeS}+)?))(?![\\)\\]])`, 'gi');
const rawVideoRegex = new RegExp(`(?<![\\(\\[])(${domainOrRelative}(?:\\/[^\\s\\[\\]\\(\\)]*\\.(?:mp4|webm|ogv|mov)(?:\\?[^\\s\\[\\]\\(\\)]+)?(?:#gif)?))`, 'gi');
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]\(\/user\/([^)]+)\)|\[@([^\]]+)\]/g;
const mentionRegex = /(?<!\[)@([a-zA-Z0-9_\-\.]+)(?!\])|\[@([^\]]+)\]/g;
const renderer = new marked.Renderer();
renderer.blockquote = function (quote) {
@@ -262,10 +207,9 @@
// Manual greentext handling — apply emoji if the user preference allows it
const quoteContent = line.substring(line.indexOf('>') + 1);
const quoteEmojis = window.f0ckSession?.quote_emojis === true;
const escapedQuote = quoteContent.replace(/>/g, '&gt;');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: escapedQuote;
? quoteContent.replace(/:([a-z0-9_]+):/g, (m, n) => renderEmoji(m, n))
: quoteContent;
return `<span class="greentext">&gt;${rendered}</span>`;
}
@@ -278,13 +222,9 @@
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`;
processedLine = processedLine.replace(mentionRegex, (match, g1, g2) => {
const user = g1 || g2;
return `<a href="/user/${encodeURIComponent(user)}" class="mention">@${user}</a>`;
});
// Handle Comment Context Links (>>ID)
@@ -307,33 +247,13 @@
return `[video](${fullUrl})`;
});
// Use marked for each line individually.
// Protect URLs and already-formed Markdown link/image tokens from the
// italic-prevention pass so that underscores in query params
// (e.g. ?v=_FcvmypiHg4) are never turned into ?v=\_FcvmypiHg4.
const mdProtected = [];
// Match [text](url) / ![alt](url) tokens AND bare http(s) URLs
let mdSafe = processedLine.replace(
/(!?\[[^\]]*\]\([^)]*\))|https?:\/\/\S+/g,
(match) => {
const idx = mdProtected.length;
mdProtected.push(match);
return `\x02MDURL${idx}\x03`;
}
);
// Escape * and _ only in the non-URL portions
mdSafe = mdSafe
.replace(/\\/g, '\\\\')
.replace(/\*/g, '\\*')
.replace(/_/g, '\\_');
// Restore protected URLs/tokens
mdSafe = mdSafe.replace(/\x02MDURL(\d+)\x03/g, (_, i) => mdProtected[+i]);
// Use marked for each line individually
let mdSafe = processedLine.replace(/\*/g, '\\*').replace(/_/g, '\\_');
const bs = String.fromCharCode(92);
mdSafe = mdSafe.split(bs + bs + '_').join(bs + bs + bs + '_');
let rendered = marked.parseInline ? marked.parseInline(mdSafe, { renderer: renderer }) : marked.parse(mdSafe, { 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) {
@@ -370,14 +290,6 @@
}
);
// Abyss label replacement
md = md.replace(
/<a\s[^>]*href="(?:https?:\/\/[^\/]+)?\/abyss(?:#|\/)(\d+)"[^>]*>([\s\S]*?)<\/a>/gi,
(match, abyssId) => {
return `<a href="/abyss/${abyssId}" class="sidebar-abyss-link" data-abyss-id="${abyssId}"><i class="fa-solid fa-dice-d6"></i> /abyss/${abyssId}</a>`;
}
);
// Build regex for allowed media hosters (video/audio)
const escapedSiteHost = window.location.host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mediaHosts = [escapedSiteHost];
@@ -455,50 +367,9 @@
const SIDEBAR_MAX_CHARS = 200;
const SIDEBAR_MAX_EMOJIS = 12;
const renderCommentAttachments = (files, content = '') => {
if (!files || files.length === 0) return '';
const items = files.map(f => {
const url = `/c/${f.dest}`;
if (content.includes(url)) return ''; // Skip if already rendered in content
if (f.mime.startsWith('image/')) {
return `<a href="${url}" target="_blank" class="cf-attachment cf-image"><img src="${url}" class="sidebar-comment-img" alt="${escapeHtml(f.original_filename || 'image')}" loading="lazy"></a>`;
} else if (f.mime.startsWith('video/')) {
return `<div class="cf-attachment cf-video"><video src="${url}" class="sidebar-comment-img" controls preload="metadata"></video></div>`;
} else if (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>` : '';
};
const renderSidebarPoll = (poll, commentId, itemId) => {
if (!poll) return '';
const href = (itemId && commentId) ? `/${itemId}#c${commentId}` : (commentId ? `#sc${commentId}` : '#');
return `<a class="sidebar-poll-preview" href="${href}"><i class="fa-solid fa-chart-bar"></i> ${escapeHtml(poll.question)}</a>`;
};
const renderActivityItem = (c) => {
const itemKey = c.item_slug || c.slug || c.item_id;
const rawContent = c.content || c.body || '';
let displayContent = renderCommentContent(rawContent, c.id, itemKey);
displayContent = window.f0cklib?.processMentions ? window.f0cklib.processMentions(displayContent) : displayContent;
// Robust fallback for is_long if server didn't provide it, plus inline media checks
let isLong = c.is_long;
if (isLong === undefined) {
isLong = rawContent.length > 120
|| rawContent.split('\n').length > 2
|| (c.files && c.files.length > 0);
}
// Always force isLong if there are inline media tags that expand vertically or files are attached
if (!isLong && ((c.files && c.files.length > 0) || /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(rawContent) || displayContent.includes('<video') || displayContent.includes('<img'))) {
isLong = true;
}
const attachmentsHtml = renderCommentAttachments(c.files, rawContent);
const pollHtml = renderSidebarPoll(c.poll, c.id, itemKey);
const displayContent = renderCommentContent(rawContent, c.id, c.item_id);
// Build avatar URL — same priority as the rest of the app
let avatarSrc = '/a/default.png';
@@ -512,151 +383,71 @@
const timeStr = c.created_at
? (window.f0ckTimeAgo ? window.f0ckTimeAgo(c.created_at) : (c.timeago || c.created_at))
: (c.timeago || 'just now');
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}" data-iso="${escapeHtml(c.created_at)}"` : '';
const fullDate = c.created_at
? (window.f0ckFormatDateFull ? window.f0ckFormatDateFull(c.created_at) : new Date(c.created_at).toISOString())
: '';
const tsAttr = c.created_at ? ` data-ts="${escapeHtml(c.created_at)}"` : '';
let itemPreview = '';
if (c.item_id) {
let mediaHtml = '';
const rClass = c.item_rating_class || 'untagged';
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 isBlurred = false;
if (rClass === 'nsfw' && blurNsfw) isBlurred = true;
else if (rClass === 'nsfl' && blurNsfl) isBlurred = true;
else if (rClass === 'sfw' && blurSfw) isBlurred = true;
else if (rClass === 'untagged' && blurUntagged) isBlurred = true;
let thumbUrl = `/t/${c.item_id}.webp`;
if (isBlurred) {
thumbUrl = `/t/${c.item_id}_blur.webp`;
}
if (window.applyThumbCacheBust) thumbUrl = window.applyThumbCacheBust(thumbUrl);
mediaHtml = `<img src="${thumbUrl}" style="width: 32px; height: 32px; object-fit: cover; border-radius: 2px;" loading="lazy" onerror="this.style.display='none'" />`;
itemPreview = `
<div class="item-preview">
<a href="/${itemKey}" class="sidebar-thumb-link" data-mode="${rClass}">${mediaHtml}</a>
<a href="/${itemKey}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
<a href="/${c.item_id}">${mediaHtml}</a>
<a href="/${c.item_id}#c${c.id}" style="font-size: 0.8em; color: var(--accent); text-decoration: none;">${(window.f0ckI18n && window.f0ckI18n.sidebar_view) || 'View'} &raquo;</a>
</div>`;
}
return `
<div class="comment" id="sc${c.id}">
<div class="comment-body">
<div class="comment-header">
<div class="comment-header-left">
<a href="/user/${c.username.toLowerCase()}" class="sidebar-avatar-link">
<img src="${avatarSrc}" class="sidebar-avatar" loading="eager" onload="this.classList.add('loaded')" onerror="this.classList.add('loaded');this.src='/a/default.png'" />
<img src="${avatarSrc}" class="sidebar-avatar" alt="${c.username}" loading="lazy" />
</a>
<a href="/user/${c.username.toLowerCase()}" class="comment-author" ${c.username_color ? `style="color: ${c.username_color}"` : ''}>${escapeHtml(c.display_name || c.username)}</a>
</div>
<span class="comment-time timeago" tooltip="${fullDate}" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
<span class="comment-time timeago" style="font-size: 0.75em;"${tsAttr}>${timeStr}</span>
</div>
<div class="comment-content${isLong ? ' has-overflow' : ''}"><div class="comment-content-inner">${displayContent}${attachmentsHtml}${pollHtml}</div><button class="read-more-btn"${isLong ? ' style="display:block"' : ''}>${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
<div class="comment-content"><div class="comment-content-inner">${displayContent}</div><button class="read-more-btn">${window.f0ckI18n?.sidebar_read_more || 'read more'}</button></div>
${itemPreview}
</div>
</div>`;
};
const checkOverflow = (targetElement) => {
const targets = targetElement
? (targetElement.classList.contains('comment-content-inner') ? [targetElement] : targetElement.querySelectorAll('.comment-content-inner'))
: document.querySelectorAll('.sidebar-activity .comment-content-inner');
const results = [];
// Read phase: batch layout reads first
targets.forEach(inner => {
const checkOverflow = () => {
document.querySelectorAll('.sidebar-activity .comment-content-inner').forEach(inner => {
const container = inner.parentElement;
const btn = container ? container.querySelector('.read-more-btn') : null;
const btn = container.querySelector('.read-more-btn');
if (!btn) return;
const isExpanded = container.classList.contains('expanded');
const scrollHeight = inner.scrollHeight;
const clientHeight = inner.clientHeight;
const hasUnloadedImages = Array.from(inner.querySelectorAll('img, video')).some(
media => {
if (media.tagName === 'IMG') {
return (!media.complete || media.naturalHeight === 0) && media.dataset.error !== 'true';
}
if (media.tagName === 'VIDEO') {
return media.readyState < 1 && media.dataset.error !== 'true'; // HAVE_METADATA
}
return false;
}
);
results.push({
container,
btn,
isExpanded,
scrollHeight,
clientHeight,
hasUnloadedImages
});
});
// Write phase: perform DOM updates after all reads are completed.
// Visibility of the button is controlled entirely by the CSS rule on
// .comment-content.has-overflow > .read-more-btn — no btn.style.display here.
results.forEach(({ container, btn, isExpanded, scrollHeight, clientHeight, hasUnloadedImages }) => {
// Clear any leftover inline display style (e.g. from the HTML template's
// style="display:block") so the CSS class-based rule is the single source of truth.
btn.style.display = '';
if (isExpanded) {
// If expanded, always show "see less"
if (container.classList.contains('expanded')) {
btn.style.display = 'block';
btn.textContent = window.f0ckI18n?.sidebar_see_less || 'see less';
return;
}
if (scrollHeight > clientHeight + 2) { // 2px buffer for rounding
// Content overflows — ensure clamped state (may already be set from HTML default)
if (inner.scrollHeight > inner.clientHeight + 2) { // 2px buffer for rounding
btn.style.display = 'block';
btn.textContent = window.f0ckI18n?.sidebar_read_more || 'read more';
container.classList.add('has-overflow');
} else if (hasUnloadedImages) {
// Images haven't loaded yet — their height is 0 so we can't tell if content
// will overflow. Keep has-overflow set; attachMediaLoadListeners will
// re-run checkOverflow once images finish loading.
} else {
// Content fits and all images are loaded — safe to remove clamped state
btn.style.display = 'none';
container.classList.remove('has-overflow');
}
});
};
const attachMediaLoadListeners = (element) => {
// Only target images and videos inside comment-content-inner.
// Avatars and item preview thumbnails have fixed sizes and do not affect text overflow.
element.querySelectorAll('.comment-content-inner img, .comment-content-inner video').forEach(media => {
element.querySelectorAll('img, video').forEach(media => {
if (media.dataset.loadListenerBound) return;
media.dataset.loadListenerBound = 'true';
const inner = media.closest('.comment-content-inner');
if (inner) {
const handler = () => checkOverflow(inner);
const errorHandler = () => {
media.dataset.error = 'true';
checkOverflow(inner);
};
media.addEventListener('load', handler, { once: true });
media.addEventListener('error', errorHandler, { once: true });
media.addEventListener('loadedmetadata', handler, { once: true });
// Also use ResizeObserver to catch layout changes (e.g. cached image layout deferred)
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => checkOverflow(inner));
ro.observe(media);
}
}
media.addEventListener('load', checkOverflow, { once: true });
media.addEventListener('loadedmetadata', checkOverflow, { once: true });
});
};
@@ -668,7 +459,7 @@
const contentDiv = readBtn.closest('.comment-content');
if (contentDiv) {
contentDiv.classList.toggle('expanded');
checkOverflow(contentDiv); // Re-sync button text and visibility for this element
checkOverflow(); // Re-sync button text and visibility
}
return;
}
@@ -679,28 +470,43 @@
const showSkeletons = () => {
const container = document.getElementById('sidebar-activity-container');
if (!container) return;
container.innerHTML = `
<div class="sidebar-loading-state">
<i class="fa-solid fa-circle-notch fa-spin"></i>
<span>${window.f0ckI18n?.sidebar_loading_activity || 'Loading activity...'}</span>
</div>
`;
const variants = [
`<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-medium"></div>
<div class="skeleton-line skeleton-text-short"></div>`,
`<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-short"></div>`,
`<div class="skeleton-line skeleton-text-medium"></div>
<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-short"></div>`,
`<div class="skeleton-line skeleton-text-long"></div>
<div class="skeleton-line skeleton-text-medium"></div>`,
`<div class="skeleton-line skeleton-text-short"></div>
<div class="skeleton-line skeleton-text-long"></div>`,
];
let html = '';
for (let i = 0; i < SIDEBAR_SKELETON_COUNT; i++) {
html += `
<div class="sidebar-skeleton-item">
<div class="skeleton-header">
<div class="skeleton-avatar"></div>
<div class="skeleton-meta">
<div class="skeleton-line skeleton-name"></div>
<div class="skeleton-line skeleton-time"></div>
</div>
</div>
${variants[i % 5]}
</div>`;
}
container.innerHTML = html;
// Auto-play converted GIF videos
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
};
const renderFromCache = (force = false) => {
const renderFromCache = () => {
const container = document.getElementById('sidebar-activity-container');
if (!container || window._sidebarActivityCache.length === 0) return false;
const currentIds = window._sidebarActivityCache.map(c => String(c.id)).join(',');
// If the same comments are already rendered, leave the DOM completely untouched.
// This keeps video stickers playing and avoids any overflow state churn.
// force=true bypasses this guard for cases where content changed without ID changes
// (e.g. emojis just loaded and need to replace raw :codes: in existing comments).
if (!force && currentIds === lastRenderedIds && container.querySelector('.comment')) {
return true;
}
lastRenderedIds = currentIds;
let html = '';
window._sidebarActivityCache.forEach(c => {
html += renderActivityItem(c);
@@ -712,35 +518,15 @@
container.appendChild(ioSentinel);
}
attachMediaLoadListeners(container);
// has-overflow is now set per-comment by the server (via is_long) so the
// correct clamped/unclamped state is baked into the HTML on first paint.
// checkOverflow still runs deferred to handle two edge cases:
// 1. Comments with images whose heights are 0 at inject time — the server
// correctly marks them is_long:true, but images that load extra-tall
// still need a re-check after load (handled by attachMediaLoadListeners).
// 2. Any mis-estimates: content that the heuristic got wrong is corrected
// after layout so measurements are accurate.
// Run checkOverflow synchronously immediately after setting innerHTML.
// This forces a synchronous layout (reflow) but guarantees the browser
// will not paint the intermediate state, completely eliminating any "flash"
// of the button popping in or out.
checkOverflow();
// checkOverflow still runs deferred to handle image loads (attachMediaLoadListeners)
// and a fallback 1s timeout for any incredibly slow layout edge cases.
requestAnimationFrame(() => {
setTimeout(checkOverflow, 1000);
});
fetchSidebarYoutubeTitles(container);
// Auto-play converted GIF videos and webm emoji stickers
// Auto-play converted GIF videos
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
playSidebarEmojiVideos(container);
return true;
};
const SIDEBAR_PAGE_LIMIT = 15;
const SIDEBAR_INITIAL_LIMIT = 15;
const SIDEBAR_MAX_COMMENTS = 20;
const loadActivity = async (silent = false) => {
const container = document.getElementById('sidebar-activity-container');
@@ -766,21 +552,22 @@
const data = await res.json();
if (data.success && data.comments && data.comments.length > 0) {
window._sidebarActivityCache = data.comments.slice(0, SIDEBAR_MAX_COMMENTS).map(c => ({
window._sidebarActivityCache = data.comments.map(c => ({
...c,
body: c.content || c.body
}));
hasMore = (data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT)
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
renderFromCache(); // no-op if IDs unchanged (renderFromCache self-guards)
hasMore = data.hasMore === true || data.comments.length === SIDEBAR_INITIAL_LIMIT;
renderFromCache();
// Also check after a delay to account for image/emoji loading shifts
setTimeout(checkOverflow, 500);
} else if (!hasCache) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_no_activity || 'No recent activity.') + '</div>';
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">No recent activity.</div>';
hasMore = false;
}
} catch (e) {
console.error("Sidebar Activity: Failed to load activity", e);
if (!hasCache) {
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">' + (window.f0ckI18n?.sidebar_failed_to_load || 'Failed to load.') + '</div>';
container.innerHTML = '<div style="text-align:center;padding:20px;color:#888;">Failed to load.</div>';
}
hasMore = false;
} finally {
@@ -802,7 +589,7 @@
const sentinel = document.createElement('div');
sentinel.id = 'sidebar-load-more-sentinel';
sentinel.style.cssText = 'text-align:center;padding:8px 0;font-size:0.78em;color:#666;';
sentinel.textContent = window.f0ckI18n?.sidebar_loading_more || 'Loading…';
sentinel.textContent = 'Loading…';
container.appendChild(sentinel);
try {
@@ -818,65 +605,40 @@
if (data.success && data.comments && data.comments.length > 0) {
currentPage++;
hasMore = data.hasMore === true;
// Append only comments not already in the cache, up to the global cap
const remaining = SIDEBAR_MAX_COMMENTS - window._sidebarActivityCache.length;
// Append only comments not already in the cache
const existingIds = new Set(window._sidebarActivityCache.map(c => String(c.id)));
const newComments = data.comments
.filter(c => !existingIds.has(String(c.id)))
.slice(0, remaining)
.map(c => ({ ...c, body: c.content || c.body }));
const newComments = data.comments.filter(c => !existingIds.has(String(c.id))).map(c => ({
...c,
body: c.content || c.body
}));
window._sidebarActivityCache.push(...newComments);
// Stop loading more if the hard cap is reached
hasMore = data.hasMore === true
&& window._sidebarActivityCache.length < SIDEBAR_MAX_COMMENTS;
// Append new items to DOM
let html = '';
newComments.forEach(c => { html += renderActivityItem(c); });
if (html) {
const temp = document.createElement('div');
temp.innerHTML = html;
const addedNodes = [];
while (temp.firstElementChild) {
const node = temp.firstElementChild;
container.appendChild(node);
addedNodes.push(node);
container.appendChild(temp.firstElementChild);
}
// Keep the IO sentinel at the very end so it triggers on the next scroll
if (ioSentinel) container.appendChild(ioSentinel);
addedNodes.forEach(node => {
attachMediaLoadListeners(node);
fetchSidebarYoutubeTitles(node);
playSidebarEmojiVideos(node);
});
// Defer overflow checks to after layout so measurements are accurate.
addedNodes.forEach(node => checkOverflow(node));
requestAnimationFrame(() => {
// Deferred re-check for image comments — mirrors renderFromCache behaviour.
// Images in newly appended nodes may not be laid out yet at append time.
setTimeout(() => addedNodes.forEach(node => checkOverflow(node)), 1000);
});
// Auto-play converted GIF videos and webm emoji stickers
attachMediaLoadListeners(container);
checkOverflow();
fetchSidebarYoutubeTitles(container);
// Auto-play converted GIF videos
container.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
}
if (!hasMore) {
// Show end-of-feed indicator
const end = document.createElement('div');
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
container.appendChild(end);
}
} else {
hasMore = false;
// Show end-of-feed indicator
const end = document.createElement('div');
end.style.cssText = 'text-align:center;padding:8px 0;font-size:0.75em;color:#444;';
end.textContent = window.f0ckI18n?.sidebar_end_of_activity || '─ end of activity ─';
end.textContent = '─ end of activity ─';
container.appendChild(end);
}
} catch (e) {
@@ -897,7 +659,7 @@
return;
}
// 2. Update cache (prepend, capped at SIDEBAR_MAX_COMMENTS)
// 2. Update cache (prepend, no hard cap — infinite scroll handles depth)
const newItem = {
...data,
body: data.body || data.content,
@@ -905,22 +667,9 @@
};
window._sidebarActivityCache.unshift(newItem);
// Trim cache to the hard cap and remove the evicted DOM node (if any)
if (window._sidebarActivityCache.length > SIDEBAR_MAX_COMMENTS) {
const evicted = window._sidebarActivityCache.pop();
if (container && evicted) {
const evictedEl = document.getElementById('sc' + evicted.id);
if (evictedEl) evictedEl.remove();
}
}
// Update DOM if visible
if (container) {
// is_long may be provided by the server (SSE payload) or absent for legacy events.
// Default to true so the button is always visible; checkOverflow will hide it
// if the rendered content is actually short.
const itemWithLong = { ...newItem, is_long: newItem.is_long !== false };
const html = renderActivityItem(itemWithLong);
const html = renderActivityItem(newItem);
const temp = document.createElement('div');
temp.innerHTML = html;
const node = temp.firstElementChild;
@@ -928,26 +677,15 @@
node.classList.add('new-item-fade');
container.prepend(node);
attachMediaLoadListeners(node);
checkOverflow(node);
checkOverflow();
fetchSidebarYoutubeTitles(container);
playSidebarEmojiVideos(node);
}
}
};
const init = async () => {
// Run emoji loading and activity fetching in parallel — avatars appear
// immediately without waiting for the emoji API to respond first.
// After both settle, if emojis finished after the initial render, re-render
// from cache so custom emoji images show on first page load.
const emojiPromise = loadEmojis();
const activityPromise = loadActivity();
await activityPromise;
await emojiPromise;
// If emojis were not yet available when activity first rendered, re-render now.
if (Object.keys(customEmojis).length > 0 && window._sidebarActivityCache.length > 0) {
renderFromCache(true); // force: emojis are now loaded, content changed
}
await loadEmojis();
loadActivity();
};
// Listen for live activity from f0ckm.js
@@ -980,7 +718,7 @@
void el.offsetWidth;
el.classList.add('new-item-fade');
attachMediaLoadListeners(inner);
requestAnimationFrame(() => checkOverflow(inner));
checkOverflow();
fetchSidebarYoutubeTitles(el);
// Auto-play converted GIF videos
inner.querySelectorAll('video.autoplay-gif').forEach(v => { v.autoplay = true; v.muted = true; v.play().catch(() => { v.addEventListener('canplay', () => v.play().catch(() => { }), { once: true }); }); });
@@ -994,20 +732,6 @@
handleLiveEdit(e.detail);
});
// When emojis are refreshed (e.g. after sticker pack import), sync the sidebar
// cache and re-render so newly imported stickers appear in existing entries.
window.addEventListener('f0ck:emojis_ready', () => {
const csCache = (typeof CommentSystem !== 'undefined' && CommentSystem.emojiCache) || null;
if (!csCache || Object.keys(csCache).length === 0) return;
// Merge new emojis into local cache (additive — never removes)
const hadBefore = Object.keys(customEmojis).length;
Object.assign(customEmojis, csCache);
const hasNew = Object.keys(customEmojis).length > hadBefore;
if (hasNew && window._sidebarActivityCache.length > 0) {
renderFromCache(true); // force: new emoji images available, re-render
}
});
let lastBoundMode = typeof window.activeMode !== 'undefined' ? window.activeMode : null;
// Handle AJAX item loads
@@ -1020,7 +744,6 @@
if (modeChanged) {
window._sidebarActivityCache = [];
lastRenderedIds = '';
currentPage = 1;
hasMore = true;
loadActivity(false); // Force reload with loading state
@@ -1042,7 +765,6 @@
window.f0ckDebug("Sidebar Activity: Mode change detected", e.detail.mode);
lastBoundMode = e.detail.mode;
window._sidebarActivityCache = [];
lastRenderedIds = '';
currentPage = 1;
hasMore = true;
loadActivity(false);

View File

@@ -71,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 });
@@ -269,51 +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();
}
};
// 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
@@ -328,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) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,14 @@
(async () => {
// Helper to get dynamic context from the DOM
const getContext = () => {
const commentsEl = document.querySelector("#comments-container");
const favoEl = document.querySelector("#a_favo");
const infoEl = document.querySelector("#a_info");
const idLinkEl = document.querySelector("a.id-link");
const rawId = commentsEl?.dataset?.itemId || favoEl?.dataset?.itemId || infoEl?.dataset?.itemId || idLinkEl?.dataset?.itemId || idLinkEl?.innerText;
if (!rawId) return null;
const idLink = document.querySelector("a.id-link");
if (!idLink) return null;
const tagsContainer = document.querySelector("#tags");
const inner = tagsContainer ? (tagsContainer.querySelector(".tags-inner") || tagsContainer) : null;
const inner = tagsContainer.querySelector(".tags-inner") || tagsContainer;
return {
postid: /^\d+$/.test(String(rawId).trim()) ? parseInt(rawId, 10) : rawId.trim(),
postid: +idLink.innerText,
poster: document.querySelector("a#a_username")?.innerText,
tags: inner ? [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2)) : []
tags: [...inner.querySelectorAll(".badge")].map(t => t.innerText.slice(0, -2))
};
};
@@ -62,7 +56,7 @@
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');
}
@@ -126,13 +120,7 @@
postid,
existingTags: tags,
anchorEl: anchor,
onSubmit: async (tag) => {
const res = await post("/api/v2/tags/" + postid, { tagname: tag });
if (res.success && window.invalidateItemCache) {
window.invalidateItemCache(postid);
}
return res;
},
onSubmit: async (tag) => post("/api/v2/tags/" + postid, { tagname: tag }),
renderTags
});
};
@@ -201,6 +189,7 @@
a.appendChild(img);
favcontainer.appendChild(a);
favcontainer.appendChild(document.createTextNode('\u00A0'));
});
favcontainer.hidden = false;
} else {

View File

@@ -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(/&gt;/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, '&gt;');
const rendered = quoteEmojis
? escapedQuote.replace(/:([a-z0-9_]+):/g, (m, n) => this.renderEmoji(m, n))
: escapedQuote;
return `<span class="greentext">&gt;${rendered}</span>`;
return `<span class="greentext">&gt;${quoteContent}</span>`;
}
// Per-line limit
if (line.length > 10000) return line;
if (!line.trim()) return '&nbsp;';
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 `![image](${fullUrl})`;
});
// 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,12 +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);
return `<div class="comment" id="c${c.id}"><div class="comment-avatar"><a href="/${itemKey}"><img src="/t/${c.item_id}.webp" alt=""></a></div><div class="comment-body"><div class="comment-header"><div class="comment-header-left"><span class="comment-author" tooltip="ID: ${c.user_id}" ${this.userColor ? `style="color: ${this.userColor}"` : ''}>${this.username}</span></div><span class="comment-time timeago" title="${fullDate}">${timeAgo}</span></div><div class="comment-content" data-raw="${this.escapeHtml(c.content)}">${content}</div>${this.renderCommentAttachments(c.files, c.content)}${this.renderCommentPoll(c.poll, c.id)}<div class="comment-footer"><div class="comment-footer-right"><div class="comment-actions">${window.f0ckSession && window.f0ckSession.logged_in ? `<button class="report-comment-btn" data-id="${c.id}" title="Report Comment" style="background:none;border:none;color:inherit;cursor:pointer;opacity:0.75;padding:0;"><svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 512 512" fill="currentColor"><path d="M506.3 417l-213.3-364c-16.3-28-57.5-28-73.8 0l-213.2 364C-10.6 445.1 9.7 480 42.7 480h426.6C502.5 480 522.6 445.1 506.3 417zM256 384c-14.1 0-25.6-11.5-25.6-25.6 0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6C281.6 372.5 270.1 384 256 384zM281.6 264.4c0 14.1-11.5 25.6-25.6 25.6-14.1 0-25.6-11.5-25.6-25.6v-96c0-14.1 11.5-25.6 25.6-25.6 14.1 0 25.6 11.5 25.6 25.6V264.4z"/></svg></button>` : ''}</div></div></div></div><a href="/${itemKey}#c${c.id}" class="comment-permalink" title="Permalink">#${c.id}</a></div>`;
// 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}">
<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() {
@@ -456,24 +333,18 @@ if (!window.UserCommentSystem) {
if (!unsafe) return '';
const div = document.createElement('div');
div.textContent = unsafe;
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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();
}
});

View File

@@ -5,33 +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" tabindex="-1">SWF</button>
<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>
@@ -44,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>
@@ -121,7 +111,10 @@ class v0ck {
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
@@ -164,35 +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 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);
}
@@ -286,31 +262,14 @@ 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;
if (isMobile && !player.classList.contains('v0ck_hover')) {
player.classList.add('v0ck_hover');
return;
}
controlsJustShown = false;
togglePlay(e);
}
});
@@ -342,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);
@@ -375,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);
@@ -408,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;
@@ -421,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;
@@ -432,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);
@@ -449,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 });
@@ -536,197 +394,11 @@ 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) {
const playPromise = togglePlay();
if (playPromise !== undefined) {
@@ -869,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(() => {
player.classList.remove('v0ck_hover');
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;
}
player.classList.add('v0ck_hover');
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;
player.classList.remove('v0ck_hover');
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;

View File

@@ -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);
});

View File

@@ -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);
});

View File

@@ -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 ---`);

View File

@@ -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);
});

View File

@@ -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";
@@ -30,40 +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');
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`);
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 {
@@ -79,23 +49,23 @@ 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)})`;
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} 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/%' ORDER BY id`;
console.log(`Regenerating ${items.length} audio items...\n`);
@@ -105,14 +75,6 @@ try {
} 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' 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}
ORDER BY id
`;
console.log(`Regenerating ONLY blurred thumbnails for all ${items.length} non-Flash items...\n`);
} else {
const ids = args.map(Number).filter(n => !isNaN(n) && n > 0);
if (ids.length === 0) {
@@ -135,4 +97,3 @@ try {
console.error('Fatal error:', err);
process.exit(1);
}

View File

@@ -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) => {
@@ -162,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,
@@ -233,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) {

View File

@@ -1,289 +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);
}
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'}
where user_id = ${+req.session.id}
`;
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'
});
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);
}
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}
`;
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);
}
};

View File

@@ -12,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,
@@ -24,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.
@@ -95,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/')
);
};
@@ -184,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 = [];
@@ -385,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) {
@@ -483,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
@@ -574,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;
@@ -640,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;
}

View File

@@ -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);
}
}

View File

@@ -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);
}
};

View File

@@ -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) {

View File

@@ -30,7 +30,7 @@ const resolvePath = (defaultRel) => {
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)) return path.resolve(storagePath);
return local;
@@ -51,9 +51,7 @@ config.paths = {
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('e'),
pending: resolvePath('pending'),
deleted: resolvePath('deleted'),
logs: resolvePath('logs'),

View File

@@ -38,16 +38,6 @@ export default new class {
.replace(/'/g, "&#039;");
}
generateSlug(length = 11) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-';
let slug = '';
const bytes = crypto.randomBytes(length);
for (let i = 0; i < length; i++) {
slug += chars[bytes[i] % chars.length];
}
return slug;
}
formatSize(size, i = ~~(Math.log(size) / Math.log(1024))) {
return (size / Math.pow(1024, i)).toFixed(2) * 1 + " " + ["B", "kB", "MB", "GB", "TB"][i];
};
@@ -63,29 +53,6 @@ export default new class {
const timeStr = t(unitKey, { n: interval });
return t('timeago.ago', { t: timeStr });
};
expiresIn(expiresAt) {
if (!expiresAt) return null;
const expiresNum = parseInt(expiresAt, 10);
if (isNaN(expiresNum)) return null;
const now = ~~(Date.now() / 1000);
const diff = expiresNum - now;
if (diff <= 0) return "expiring now";
if (diff < 60) return `in ${diff}s`;
if (diff < 3600) {
const mins = Math.floor(diff / 60);
return `in ${mins} minute${mins === 1 ? '' : 's'}`;
}
if (diff < 86400) {
const hours = Math.floor(diff / 3600);
const mins = Math.floor((diff % 3600) / 60);
return mins > 0 ? `in ${hours}h ${mins}m` : `in ${hours} hour${hours === 1 ? '' : 's'}`;
}
const days = Math.floor(diff / 86400);
const hours = Math.floor((diff % 86400) / 3600);
return hours > 0 ? `in ${days}d ${hours}h` : `in ${days} day${days === 1 ? '' : 's'}`;
};
md5(str) {
return crypto.createHash('md5').update(str).digest("hex");
};
@@ -114,37 +81,6 @@ export default new class {
}
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')) {
parts.push('not exists (select 1 from tags_assign where item_id = items.id)');
}
if (parts.length === 0) return null;
return '(' + parts.join(' OR ') + ')';
};
createID() {
return crypto.randomBytes(16).toString("hex") + Date.now().toString(24);
};
@@ -165,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
};
@@ -254,30 +183,10 @@ 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);
};
async getTags(itemid) {
const tags = await db`
@@ -405,67 +314,6 @@ export default new class {
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";
@@ -493,15 +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');
}
};

View File

@@ -204,46 +204,3 @@ export async function moveToDeleted(dest, deletedId) {
await fs.unlink(srcPath).catch(() => {});
}
}
/**
* Periodically scan for and purge expired uploads (expires_at <= now).
* Unlinks media files (thumbnails, coverarts, main image) via safeDeleteMediaFile,
* and sets is_deleted = true, is_purged = true, active = false in DB.
*/
export async function purgeExpiredUploads() {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return;
}
try {
const now = ~~(Date.now() / 1000);
const expiredItems = await db`
SELECT id, dest, mime
FROM items
WHERE expires_at IS NOT NULL
AND expires_at <= ${now}
AND is_purged = false
`;
if (expiredItems.length > 0) {
console.log(`[EXPIRING UPLOADS] Found ${expiredItems.length} expired item(s) to purge.`);
for (const item of expiredItems) {
try {
if (item.dest) {
await safeDeleteMediaFile(item.dest, item.id);
}
await fs.unlink(path.join(cfg.paths.t, `${item.id}.webp`)).catch(() => {});
await fs.unlink(path.join(cfg.paths.t, `${item.id}_blur.webp`)).catch(() => {});
if (item.mime && item.mime.startsWith('audio')) {
await fs.unlink(path.join(cfg.paths.ca, `${item.id}.webp`)).catch(() => {});
}
await db`UPDATE items SET is_deleted = true, is_purged = true, active = false WHERE id = ${item.id}`;
console.log(`[EXPIRING UPLOADS] Successfully purged expired item #${item.id}`);
} catch (e) {
console.error(`[EXPIRING UPLOADS] Error purging item #${item.id}:`, e);
}
}
}
} catch (err) {
console.error('[EXPIRING UPLOADS] Failed running purgeExpiredUploads check:', err);
}
}

View File

@@ -70,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...",
@@ -130,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",
@@ -139,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.",
"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",
@@ -193,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",
@@ -299,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": {
@@ -338,10 +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."
"label": "Fehler"
},
"drop": {
"drop_anywhere": "Überall ablegen zum Hochladen"
@@ -353,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",
@@ -461,10 +410,6 @@
},
"sidebar": {
"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",
@@ -489,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",
@@ -505,7 +450,6 @@
"stat_comments": "Gesamt Kommentare",
"stat_favs": "Gesamt Favoriten",
"stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer",
"most_favorited": "Meiste Favs",
"favs": "Favs",
"top_xd": "Top xD-Score"
@@ -541,7 +485,6 @@
"subscribe_uploads_btn": "Benutzer für Uploads abonnieren",
"no_uploads": "Keine Uploads gefunden",
"no_favs": "Keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:",
@@ -579,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)",
@@ -607,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",
@@ -777,36 +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"
}
}
}

View File

@@ -64,23 +64,13 @@
"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...",
@@ -130,7 +120,6 @@
"switching": "Switching...",
"generating": "Generating...",
"title": "Settings",
"profile": "Profile",
"avatar": "Avatar",
"current_avatar": "Current Avatar",
"upload_custom_avatar": "Upload Custom Avatar",
@@ -139,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.",
"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 Untagged",
"blur_untagged_hint": "Blur thumbnails with no tags or rating.",
"blur_detail": "Click to reveal item on detail page",
"blur_detail_hint": "Require clicking to reveal blurred media on the post detail page.",
"render_emojis": "Render emojis in quote replies",
"render_emojis_hint": "Show :emoji: images inside >quoted lines",
"embed_yt": "Embed YouTube links in comments",
@@ -193,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",
@@ -299,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": {
@@ -338,10 +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."
"label": "Error"
},
"drop": {
"drop_anywhere": "Drop anywhere to upload"
@@ -353,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",
@@ -465,10 +414,6 @@
},
"sidebar": {
"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",
@@ -509,7 +454,6 @@
"stat_comments": "Total Comments",
"stat_favs": "Total Favorites",
"stat_disk_usage": "Total File Size",
"stat_users": "Total Users",
"most_favorited": "Most Favorited",
"favs": "favs",
"top_xd": "Top xD Scores"
@@ -545,7 +489,6 @@
"subscribe_uploads_btn": "Subscribe user to uploads",
"no_uploads": "no uploads found",
"no_favs": "no favorites",
"private_favorites": "private favorites",
"back_to_profile": "Back to Profile",
"ban_modal_title": "Ban User",
"ban_modal_reason": "Reason:",
@@ -583,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)",
@@ -611,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",
@@ -779,36 +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"
}
}
}

View File

@@ -70,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...",
@@ -130,7 +120,6 @@
"switching": "Omschakelen...",
"generating": "Genereren...",
"title": "Instellingen",
"profile": "Profiel",
"avatar": "Avatar",
"current_avatar": "Huidige Avatar",
"upload_custom_avatar": "Aangepaste Avatar Uploaden",
@@ -139,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.",
"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",
@@ -191,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",
@@ -297,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": {
@@ -336,10 +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."
"label": "Fout"
},
"drop": {
"drop_anywhere": "Overal slepen om te uploaden"
@@ -351,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",
@@ -459,10 +410,6 @@
},
"sidebar": {
"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",
@@ -503,7 +450,6 @@
"stat_comments": "Totaal aantal reacties",
"stat_favs": "Totaal aantal favorieten",
"stat_disk_usage": "Totale Bestandsgrootte",
"stat_users": "Totaal Gebruikers",
"most_favorited": "Meest Gefavoriet",
"favs": "favorieten",
"top_xd": "Top xD-scores"
@@ -539,7 +485,6 @@
"subscribe_uploads_btn": "Gebruiker abonneren op uploads",
"no_uploads": "geen uploads gevonden",
"no_favs": "geen favorieten",
"private_favorites": "privé favorieten",
"back_to_profile": "Terug naar Profiel",
"ban_modal_title": "Gebruiker Bannen",
"ban_modal_reason": "Reden:",
@@ -577,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)",
@@ -605,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",
@@ -773,30 +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."
}
}

View File

@@ -70,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...",
@@ -99,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",
@@ -130,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",
@@ -139,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.",
"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",
@@ -189,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",
@@ -295,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": {
@@ -334,10 +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."
"label": "Fehler"
},
"drop": {
"drop_anywhere": "Überall ablegen zum Aufladieren"
@@ -349,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",
@@ -460,10 +413,6 @@
},
"sidebar": {
"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",
@@ -504,7 +453,6 @@
"stat_comments": "Gesamtanzahl Kommentare",
"stat_favs": "Gesamtanzahl Favoriten",
"stat_disk_usage": "Dateigröße Gesamt",
"stat_users": "Gesamt Benutzer",
"most_favorited": "Am häufigsten favorisiert",
"favs": "Favoriten",
"top_xd": "Beste xD-Punktestände"
@@ -540,7 +488,6 @@
"subscribe_uploads_btn": "Benutzer für Aufladierungen abonnieren",
"no_uploads": "keine Aufladierungen gefunden",
"no_favs": "keine Favoriten",
"private_favorites": "private Favoriten",
"back_to_profile": "Zurück zum Profil",
"ban_modal_title": "Benutzer sperren",
"ban_modal_reason": "Grund:",
@@ -578,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)",
@@ -606,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",
@@ -776,30 +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."
}
}

View File

@@ -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) {
@@ -372,10 +273,6 @@ export default new class queue {
}
} catch (e) {}
const outPath = path.join(tDir, itemid + '.webp');
try {
if (mime === 'video/youtube') {
const videoId = filename.startsWith('yt:') ? filename.split(':')[1] : null;
if (videoId) {
@@ -409,51 +306,15 @@ 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
@@ -560,83 +421,11 @@ 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(() => {});
// 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;
}
};
@@ -645,7 +434,7 @@ export default new class queue {
const src = path.join(tDir, `${itemid}.webp`);
const dst = path.join(tDir, `${itemid}_blur.webp`);
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);

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ import cfg from "../config.mjs";
import security from "../security.mjs";
import crypto from "crypto";
import path from "path";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getShitpostMode, ensureAllItemsHaveSlugs, getEnableItemSlugs } from "../settings.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getEnablePdf, setEnablePdf, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getShitpostMode, getDefaultFeedLayout, setDefaultFeedLayout } from "../settings.mjs";
export default (router, tpl) => {
router.get(/^\/login(\/)?$/, async (req, res) => {
@@ -45,7 +45,7 @@ export default (router, tpl) => {
return res.reply({ code: 429, body: msg });
}
if (!username || !password) {
if (!username || !password || password.length < 20) {
return fail("Invalid username or password.");
}
@@ -287,6 +287,7 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(),
shitpost_mode: getShitpostMode(),
enable_cleanup_config: cfg.websrv.enable_cleanup !== false,
default_feed_layout: getDefaultFeedLayout(),
tmp: null
}, req)
});
@@ -319,7 +320,7 @@ export default (router, tpl) => {
});
});
router.get(/^\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
router.get(/\/admin\/user\/(?<userId>\d+)\/ips(\/)?$/, lib.auth, async (req, res) => {
const userId = +req.params.userId;
const user = await db`select "user", login from "user" where id = ${userId} limit 1`;
if (user.length === 0) return res.reply({ code: 404, body: 'User not found' });
@@ -507,8 +508,7 @@ export default (router, tpl) => {
update "user"
set banned = false,
ban_reason = null,
ban_expires = null,
last_seen = ${~~(Date.now() / 1e3)}
ban_expires = null
where id = ${+user_id}
`;
@@ -619,6 +619,8 @@ export default (router, tpl) => {
const registration_open = req.post.registration_open === 'on' ? 'true' : 'false';
const min_tags = isNaN(parseInt(req.post.min_tags)) ? 3 : Math.max(0, parseInt(req.post.min_tags));
const trusted_uploads = Math.max(0, parseInt(req.post.trusted_uploads) ?? 3);
const raw_feed_layout = parseInt(req.post.default_feed_layout, 10);
const default_feed_layout = (!isNaN(raw_feed_layout) && raw_feed_layout >= 0 && raw_feed_layout <= 3) ? raw_feed_layout : getDefaultFeedLayout();
await db`INSERT INTO site_settings (key, value) VALUES ('manual_approval', ${manual_approval}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
@@ -627,13 +629,14 @@ export default (router, tpl) => {
setRegistrationOpen(registration_open === 'true');
}
await db`INSERT INTO site_settings (key, value) VALUES ('min_tags', ${min_tags.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('trusted_uploads', ${trusted_uploads.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('default_feed_layout', ${default_feed_layout.toString()}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
setManualApproval(manual_approval === 'true');
setMinTags(min_tags);
setTrustedUploads(trusted_uploads);
setDefaultFeedLayout(default_feed_layout);
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
res.setHeader('Content-Type', 'application/json');
@@ -643,7 +646,8 @@ export default (router, tpl) => {
manual_approval: getManualApproval(),
registration_open: getRegistrationOpen(),
min_tags: getMinTags(),
trusted_uploads: getTrustedUploads()
trusted_uploads: getTrustedUploads(),
default_feed_layout: getDefaultFeedLayout()
})
});
}
@@ -651,77 +655,6 @@ export default (router, tpl) => {
return res.writeHead(302, { "Location": "/admin" }).end();
});
// Config Manager API GET
router.get(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
const origin = req.headers?.origin || '*';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
const isDev = cfg.main?.development === true;
if (!isDev && (!req.session || !req.session.admin)) {
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
}
try {
const configPath = path.resolve(process.cwd(), "config.json");
const raw = await fs.readFile(configPath, "utf-8");
const json = JSON.parse(raw);
if (res.json) return res.json({ success: true, config: json });
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, config: json }));
} catch (err) {
if (res.json) return res.json({ success: false, msg: err.message });
return res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
// Config Manager API POST
router.post(/^\/api\/v2\/admin\/config\/?$/, async (req, res) => {
const origin = req.headers?.origin || '*';
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
const isDev = cfg.main?.development === true;
if (!isDev && (!req.session || !req.session.admin)) {
return res.writeHead(401, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: 'Unauthorized' }));
}
try {
let updatedConfig = req.post?.config || req.json?.config || req.post;
if (typeof updatedConfig === 'string') {
updatedConfig = JSON.parse(updatedConfig);
}
if (!updatedConfig || typeof updatedConfig !== 'object') {
throw new Error('Invalid configuration payload');
}
const configPath = path.resolve(process.cwd(), "config.json");
// Write formatted JSON to config.json on disk
await fs.writeFile(configPath, JSON.stringify(updatedConfig, null, 2) + "\n", "utf-8");
// Mutate in-memory cfg object so changes apply immediately
Object.assign(cfg, updatedConfig);
if (getEnableItemSlugs()) {
ensureAllItemsHaveSlugs();
}
// Audit log entry (if session available)
if (req.session?.id) {
await audit.log(req.session.id, 'update_config_file', 'system', 0, { updatedKeys: Object.keys(updatedConfig) });
}
const response = { success: true, message: 'Configuration saved to config.json' };
if (res.json) return res.json(response);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
} catch (err) {
console.error('[ADMIN] Config Save Error:', err);
const response = { success: false, msg: err.message };
if (res.json) return res.json(response, 400);
return res.writeHead(400, { 'Content-Type': 'application/json' }).end(JSON.stringify(response));
}
});
router.get(/^\/admin\/cleanup\/?$/, lib.auth, async (req, res) => {
if (!getEnableCleanup()) {
return res.redirect("/admin");
@@ -732,7 +665,6 @@ export default (router, tpl) => {
enable_cleanup: getEnableCleanup(),
cleanup_start_date: getCleanupStartDate(),
cleanup_end_date: getCleanupEndDate(),
cleanup_include_engaged: getCleanupIncludeEngaged(),
totals: await lib.countf0cks(),
tmp: null
};
@@ -746,18 +678,15 @@ export default (router, tpl) => {
try {
const cleanup_start_date = req.post.cleanup_start_date || '';
const cleanup_end_date = req.post.cleanup_end_date || '';
const cleanup_include_engaged = req.post.cleanup_include_engaged === 'true' || req.post.cleanup_include_engaged === 'on' || req.post.cleanup_include_engaged === '1';
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_start_date', ${cleanup_start_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_end_date', ${cleanup_end_date}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
await db`INSERT INTO site_settings (key, value) VALUES ('cleanup_include_engaged', ${cleanup_include_engaged ? 'true' : 'false'}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`;
setCleanupStartDate(cleanup_start_date);
setCleanupEndDate(cleanup_end_date);
setCleanupIncludeEngaged(cleanup_include_engaged);
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate(), cleanup_include_engaged: getCleanupIncludeEngaged() });
const body = JSON.stringify({ success: true, enable_cleanup: getEnableCleanup(), cleanup_start_date: getCleanupStartDate(), cleanup_end_date: getCleanupEndDate() });
return res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }).end(body);
}
@@ -776,23 +705,17 @@ export default (router, tpl) => {
router.post(/^\/admin\/cleanup\/run\/?$/, lib.auth, async (req, res) => {
try {
// Ensure settings are synced from DB before execution
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date', 'cleanup_include_engaged')`;
const settings = await db`SELECT key, value FROM site_settings WHERE key IN ('enable_cleanup', 'cleanup_start_date', 'cleanup_end_date')`;
const settingsMap = Object.fromEntries(settings.map(s => [s.key, s.value]));
const isEnabled = settingsMap['enable_cleanup'] !== undefined ? settingsMap['enable_cleanup'] === 'true' : getEnableCleanup();
const isEnabled = settingsMap['enable_cleanup'] === 'true';
const startDate = settingsMap['cleanup_start_date'] || '';
const endDate = settingsMap['cleanup_end_date'] || '';
const reqEngaged = req.post?.include_engaged;
const includeEngaged = reqEngaged !== undefined
? (reqEngaged === 'true' || reqEngaged === true || reqEngaged === 'on' || reqEngaged === '1')
: (settingsMap['cleanup_include_engaged'] === 'true' || getCleanupIncludeEngaged());
// Update memory state
setEnableCleanup(isEnabled);
setCleanupStartDate(startDate);
setCleanupEndDate(endDate);
setCleanupIncludeEngaged(includeEngaged);
if (!isEnabled) {
throw new Error('Cleanup is disabled in settings.');
@@ -802,7 +725,7 @@ export default (router, tpl) => {
throw new Error('Please select both a Start Date and an End Date.');
}
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate} (includeEngaged: ${includeEngaged})...`);
console.log(`[ADMIN] Starting manual cleanup for period ${startDate} to ${endDate}...`);
const start_stamp = ~~(new Date(startDate).getTime() / 1000);
const end_stamp = ~~(new Date(endDate).getTime() / 1000) + 86399; // Include full end day
@@ -818,7 +741,7 @@ export default (router, tpl) => {
AND i.is_pinned = false
AND i.stamp >= ${start_stamp}
AND i.stamp <= ${end_stamp}
${includeEngaged ? db`` : db`AND (
AND (
-- Case 1: Active posts with no engagement (ignoring automatic subscriptions)
(i.active = true AND i.is_deleted = false AND NOT EXISTS (SELECT 1 FROM comments WHERE item_id = i.id) AND NOT EXISTS (SELECT 1 FROM favorites WHERE item_id = i.id))
OR
@@ -827,7 +750,7 @@ export default (router, tpl) => {
OR
-- Case 3: Pending posts (not yet approved) that are old enough
(i.active = false AND i.is_deleted = false)
)`}
)
`;
const statsInfo = `(Total items: ${totalCleanable[0].c}, In range: ${withinRange[0].c})`;
@@ -885,11 +808,8 @@ export default (router, tpl) => {
// User Management Routes
router.get(/^\/admin\/users\/?$/, lib.auth, async (req, res) => {
const rawQ = req.url.qs?.q || '';
// Exact match mode: strip surrounding double quotes and match exactly
const exactMatch = rawQ.startsWith('"') && rawQ.endsWith('"') && rawQ.length > 2;
const q = exactMatch ? rawQ.slice(1, -1) : rawQ;
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
const q = req.url.qs?.q || '';
const page = Math.max(1, parseInt(req.url.qs?.page) || 1);
const limit = 50;
const offset = (page - 1) * limit;
@@ -901,10 +821,7 @@ export default (router, tpl) => {
(SELECT token FROM invite_tokens WHERE used_by = u.id ORDER BY created_at DESC LIMIT 1) as reg_method
FROM "user" u
LEFT JOIN user_options uo ON uo.user_id = u.id
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
${q ? db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` : db``}
),
ghost_users AS (
SELECT
@@ -913,10 +830,7 @@ export default (router, tpl) => {
NULL::text as avatar_file, NULL::varchar as display_name, 0 as force_comment_display_mode, 0 as comment_display_mode, 'Legacy' as reg_method
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``}
GROUP BY i.username
),
all_users AS (
@@ -958,19 +872,13 @@ export default (router, tpl) => {
const totalCountActual = await db`
SELECT COUNT(*) as c FROM "user" u
${q ? (exactMatch
? db`WHERE lower(u.login) = lower(${q}) OR lower(u.user) = lower(${q}) OR lower(u.email) = lower(${q})`
: db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}`
) : db``}
${q ? db`WHERE u.login ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.user ILIKE ${'%' + lib.escapeLike(q) + '%'} OR u.email ILIKE ${'%' + lib.escapeLike(q) + '%'}` : db``}
`;
const totalCountGhost = await db`
SELECT COUNT(DISTINCT i.username) as c
FROM items i
WHERE NOT EXISTS (SELECT 1 FROM "user" u WHERE u.login = i.username OR u.user = i.username)
${q ? (exactMatch
? db`AND lower(i.username) = lower(${q})`
: db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})`
) : db``}
${q ? db`AND (i.username ILIKE ${'%' + lib.escapeLike(q) + '%'})` : db``}
`;
const total = parseInt(totalCountActual[0].c) + parseInt(totalCountGhost[0].c);
@@ -1049,57 +957,11 @@ export default (router, tpl) => {
}
});
router.post(/^\/api\/v2\/admin\/users\/set-role\/?$/, lib.auth, async (req, res) => {
try {
const { user_id, role } = req.post;
if (!user_id) throw new Error('Missing user_id');
if (!['user', 'mod', 'admin'].includes(role)) throw new Error('Invalid role. Must be user, mod, or admin.');
// Fetch target
const target = await db`SELECT id, login FROM "user" WHERE id = ${+user_id} LIMIT 1`;
if (!target.length) throw new Error('User not found.');
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected.');
// Prevent self-demotion
if (+user_id === req.session.id && role !== 'admin') {
throw new Error('You cannot change your own role away from admin.');
}
const isAdmin = role === 'admin';
const isMod = role === 'mod';
await db`
UPDATE "user"
SET admin = ${isAdmin}, is_moderator = ${isMod}
WHERE id = ${+user_id}
`;
// Invalidate sessions so permissions refresh on next login
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
await audit.log(req.session.id, 'admin_set_role', 'user', +user_id, {
target_login: target[0].login,
role
});
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: true,
role,
msg: `Role for "${target[0].login}" set to ${role}.`
}));
} catch (err) {
console.error('[ADMIN] Set role failed:', err);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
router.post(/^\/api\/v2\/admin\/users\/lock-layout\/?$/, lib.auth, async (req, res) => {
try {
const { user_id, mode, lock } = req.post;
if (!user_id) throw new Error('Missing user_id');
const isLocked = lock === true || lock === 'true' || lock === 1;
const targetMode = parseInt(mode, 10);
@@ -1400,136 +1262,6 @@ export default (router, tpl) => {
});
router.post(/^\/api\/v2\/admin\/users\/rename\/?$/, lib.auth, async (req, res) => {
try {
const { user_id, new_username } = req.post;
if (!user_id) throw new Error('Missing user_id');
if (!new_username || !new_username.trim()) throw new Error('Missing new_username');
const newName = new_username.trim();
// Validate format (same rules as registration)
if (!/^[a-zA-Z0-9._-]+$/.test(newName)) {
throw new Error('Invalid username. Only A-Z, 0-9, _, -, and . are allowed.');
}
if (newName.length < 2 || newName.length > 32) {
throw new Error('Username must be between 2 and 32 characters.');
}
// Get current user info
const target = await db`SELECT id, login, "user" FROM "user" WHERE id = ${+user_id} LIMIT 1`;
if (!target.length) throw new Error('User not found');
if (target[0].login === 'deleted_user') throw new Error('The deleted_user account is protected and cannot be renamed.');
const oldLogin = target[0].login;
const oldUser = target[0].user;
const newLogin = newName.toLowerCase();
if (newLogin === oldLogin && newName === oldUser) throw new Error('New username is the same as the current one.');
// Check for conflicts
const conflict = await db`SELECT id FROM "user" WHERE (lower(login) = ${newLogin} OR lower("user") = lower(${newName})) AND id != ${+user_id} LIMIT 1`;
if (conflict.length) throw new Error(`Username "${newName}" is already taken.`);
await db.begin(async sql => {
// 1. Update the user record
await sql`UPDATE "user" SET login = ${newLogin}, "user" = ${newName} WHERE id = ${+user_id}`;
// 2. Update items.username (matches both old login and old display name)
await sql`UPDATE items SET username = ${newLogin} WHERE username ILIKE ${oldLogin} OR username ILIKE ${oldUser}`;
// 3. Clear old login_attempts so the new name starts clean
await sql`DELETE FROM login_attempts WHERE username = ${oldLogin}`;
});
// Invalidate all sessions so the user must re-log with the new name
await db`DELETE FROM user_sessions WHERE user_id = ${+user_id}`;
// Log it in audit
await audit.log(req.session.id, 'admin_rename_user', 'user', +user_id, {
old_login: oldLogin,
new_login: newLogin,
old_user: oldUser,
new_user: newName
});
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: true,
new_login: newLogin,
new_user: newName,
msg: `User renamed from "${oldLogin}" to "${newLogin}". All uploads updated. Sessions invalidated.`
}));
} catch (err) {
console.error('[ADMIN] Rename failed:', err);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
router.post(/^\/api\/v2\/admin\/users\/create\/?$/, lib.auth, async (req, res) => {
try {
let { username, email, password, role } = req.post;
if (!username || !username.trim()) throw new Error('Username is required.');
username = username.trim();
if (!/^[a-zA-Z0-9._-]+$/.test(username)) {
throw new Error('Username contains invalid characters. Only A-Z, 0-9, _, -, and . are allowed.');
}
if (username.length < 2 || username.length > 32) {
throw new Error('Username must be between 2 and 32 characters.');
}
if (!password || password.length < 20) {
throw new Error('Password must be at least 20 characters long.');
}
// Check for existing user
const existing = await db`
SELECT id FROM "user"
WHERE lower(login) = lower(${username}) OR lower("user") = lower(${username})
${email ? db`OR (email IS NOT NULL AND lower(email) = lower(${email}))` : db``}
LIMIT 1
`;
if (existing.length) throw new Error('Username or email is already taken.');
const isAdmin = role === 'admin';
const isMod = role === 'mod';
const hash = await lib.hash(password);
const ts = ~~(Date.now() / 1e3);
const { getDefaultLayout } = await import('../settings.mjs');
const newUser = await db`
INSERT INTO "user" (login, password, "user", created_at, admin, is_moderator, email, activated, activation_token)
VALUES (
${username.toLowerCase()}, ${hash}, ${username},
to_timestamp(${ts}), ${isAdmin}, ${isMod},
${email || null}, true, NULL
)
RETURNING id, login
`;
const userId = newUser[0].id;
await db`
INSERT INTO user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
VALUES (${userId}, 3, 'amoled', 0, NULL, 'default.png', ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
`;
await audit.log(req.session.id, 'admin_create_user', 'user', userId, {
new_login: username.toLowerCase(),
role: role || 'user'
});
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({
success: true,
user_id: userId,
username: username.toLowerCase(),
msg: `User "${username.toLowerCase()}" created successfully.`
}));
} catch (err) {
console.error('[ADMIN] Create user failed:', err);
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: err.message }));
}
});
// About page text editor
router.get(/^\/admin\/about\/?$/, lib.auth, async (req, res) => {
const settings = await db`SELECT value FROM site_settings WHERE key = 'about_text' LIMIT 1`;
@@ -1695,9 +1427,6 @@ export default (router, tpl) => {
// Chat Manager
router.get(/^\/admin\/chat\/?$/, lib.auth, async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.redirect("/admin");
}
res.reply({
body: tpl.render('admin/chat', {
session: req.session,
@@ -1707,45 +1436,5 @@ export default (router, tpl) => {
});
});
// Koepfe Manager
router.get(/^\/admin\/koepfe\/?$/, lib.auth, async (req, res) => {
res.reply({
body: tpl.render('admin/koepfe', { session: req.session, tmp: null }, req)
});
});
router.get(/^\/api\/v2\/admin\/koepfe\/?$/, lib.auth, async (req, res) => {
try {
const fs = (await import('fs')).promises;
const path = await import('path');
const kDir = cfg.paths.koepfe;
const files = await fs.readdir(kDir).catch(() => []);
const koepfeFiles = files.filter(f => /\.(png|jpe?g|gif|webp|avif)$/i.test(f)).map(f => ({
filename: f,
url: '/s/koepfe/' + f
}));
if (res.json) return res.json({ koepfe: koepfeFiles });
return res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify({ koepfe: koepfeFiles }));
} catch (e) {
if (res.json) return res.json({ success: false, msg: e.message });
return res.writeHead(500, {'Content-Type': 'application/json'}).end(JSON.stringify({ success: false, msg: e.message }));
}
});
router.post(/^\/api\/v2\/admin\/koepfe\/delete\/?$/, lib.auth, async (req, res) => {
try {
const { filename } = req.post || req.body || {};
if (!filename || filename.includes('..') || filename.includes('/')) throw new Error('Invalid filename');
const fs = (await import('fs')).promises;
const path = await import('path');
await fs.unlink(path.join(cfg.paths.koepfe, filename));
if (res.json) return res.json({ success: true });
return res.writeHead(200, {'Content-Type': 'application/json'}).end(JSON.stringify({ success: true }));
} catch (e) {
if (res.json) return res.json({ success: false, msg: e.message });
return res.writeHead(500, {'Content-Type': 'application/json'}).end(JSON.stringify({ success: false, msg: e.message }));
}
});
return router;
}

View File

@@ -1,10 +1,9 @@
import f0cklib from "../routeinc/f0cklib.mjs";
import url from "url";
import cfg from "../config.mjs";
import { createI18n } from "../i18n.mjs";
export default (router, tpl) => {
router.get(/^\/ajax\/item\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+)/, async (req, res) => {
router.get(/\/ajax\/item\/(?<itemid>\d+)/, async (req, res) => {
const tAjaxStart = Date.now();
let query = {};
if (typeof req.url === 'string') {
@@ -32,15 +31,12 @@ export default (router, tpl) => {
if (cfg.main.development) console.log(`[${new Date().toISOString()}] [AJAX] Starting item load for ${req.params.itemid}`);
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/([a-zA-Z0-9_-]{11}|\d+)/)?.[1];
const itemid = req.params.itemid || req.url.pathname.match(/\/ajax\/item\/(\d+)/)?.[1];
const data = await f0cklib.getf0ck({
itemid: itemid,
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: req.session,
session: !!req.session,
url: contextUrl,
user: query.user,
tag: query.tag,
@@ -58,17 +54,9 @@ export default (router, tpl) => {
const tAjaxFetch = Date.now();
if (!data.success) {
const reqMode = (query.mode !== undefined ? +query.mode : req.mode) ?? 0;
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
const errorModeLabel = (req.session && reqMode !== 3) ? (modeLabels[reqMode] || null) : null;
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const errorHtml = tpl.render('error-partial', {
message: tErr('error.post_not_visible'),
tmp: null,
session: req.session ? { ...req.session } : false,
error_mode_label: errorModeLabel,
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
error_filter_hint_link: tErr('error.filter_hint_link')
message: data.message || '404 - Not f0cked',
tmp: null
}, req);
return res.reply({
headers: { 'Content-Type': 'application/json' },
@@ -138,7 +126,7 @@ export default (router, tpl) => {
const item = data.item;
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
@@ -147,11 +135,9 @@ export default (router, tpl) => {
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
data.item_username_lower = (item.username || '').toLowerCase();
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
}
// Render both the item content and the pagination
@@ -170,8 +156,7 @@ export default (router, tpl) => {
html: itemHtml,
pagination: paginationHtml,
title: data.title,
id: itemid,
slug: data.item?.slug || null
id: itemid
})
});
});
@@ -195,7 +180,7 @@ export default (router, tpl) => {
// Infinite scroll endpoint for index thumbnails
router.get(/^\/ajax\/items/, async (req, res) => {
router.get(/\/ajax\/items/, async (req, res) => {
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
@@ -206,46 +191,26 @@ export default (router, tpl) => {
const page = parseInt(query.page) || 1;
const isRandom = query.random === '1' || req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const data = await f0cklib.getf0cks({
page: page,
tag: query.tag || null,
hall: query.hall || null,
user: query.user || null,
userHall: query.userHall || null,
userHallOwner: query.userHallOwner || null,
mime: query.mime || (req.cookies.mime || null),
mode: query.mode !== undefined ? +query.mode : req.mode,
ratings: ratingsArr,
session: req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
session: !!req.session,
exclude: req.session ? (req.session.excluded_tags || []) : [],
user_id: req.session?.id,
fav: query.fav === 'true',
random: isRandom,
strict: query.strict === '1' || query.strict === 'true' || req.session?.strict_mode,
explicitStrict: query.strict === '1' || query.strict === 'true',
newer: query.newer || null,
minXdScore: req.session?.min_xd_score || 0,
tagger: query.tagger || null
minXdScore: req.session?.min_xd_score || 0
});
if (!data.success) {
if (data.is_private) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
const privateHtml = `<div class="private-favs-msg" style="padding: 30px; text-align: center; color: var(--text-muted); font-size: 1.1em;">${tErr('profile.private_favorites')}</div>`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: false,
is_private: true,
html: privateHtml,
hasMore: false
})
});
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -287,7 +252,7 @@ export default (router, tpl) => {
return res.reply({
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, must-revalidate, proxy-revalidate',
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
},

View File

@@ -2,22 +2,15 @@ import { promises as fs } from "fs";
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import queue from '../../queue.mjs';
import search from '../../routeinc/search.mjs';
import path from "path";
import f0cklib from '../../routeinc/f0cklib.mjs';
import audit from '../../audit.mjs';
import { parseMultipart, collectBody } from '../../multipart.mjs';
import { purgeExpiredUploads } from '../../lib_delete.mjs';
import { calculateExpiresAt } from './upload.mjs';
const allowedMimes = ["audio", "image", "video", "%"];
const getGlobalfilter = () => {
if (!cfg.nsfp?.length) return null;
const filteredTags = cfg.websrv.public_nsfw ? cfg.nsfp.filter(id => id !== 2) : cfg.nsfp;
return filteredTags.length ? filteredTags.map(n => `tag_id = ${n}`).join(' or ') : null;
};
const globalfilter = cfg.nsfp?.length ? cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ') : null;
const metaCache = new Map();
const MAX_META_CACHE = 2000;
@@ -306,49 +299,9 @@ export default router => {
}
});
// Allow authenticated clients to push metadata they fetched client-side (e.g. YouTube oEmbed
// fetched directly from the browser, bypassing Tor/proxy consent walls).
group.post(/\/meta\/cache$/, lib.loggedin, async (req, res) => {
if (!cfg.websrv.web_meta_extraction) {
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
}
try {
const body = await collectBody(req);
const payload = JSON.parse(body.toString());
const { url, meta } = payload || {};
if (!url || !meta || !meta.title) {
return res.json({ success: false, msg: 'url and meta.title required' }, 400);
}
// Only accept YouTube URLs to prevent arbitrary cache poisoning
if (!/(youtube\.com|youtu\.be)/i.test(url)) {
return res.json({ success: false, msg: 'Only YouTube URLs accepted' }, 400);
}
// Sanitise — only store known-safe fields
const safeMeta = {
title: String(meta.title).substring(0, 500),
site_name: 'youtube.com',
author: meta.author ? String(meta.author).substring(0, 200) : 'Unknown'
};
metaCache.set(url, safeMeta);
if (metaCache.size > MAX_META_CACHE) {
const first = metaCache.keys().next().value;
metaCache.delete(first);
}
try {
await db`INSERT INTO meta_cache (url, data) VALUES (${url}, ${safeMeta})
ON CONFLICT (url) DO UPDATE SET data = EXCLUDED.data, created_at = CURRENT_TIMESTAMP`;
} catch (err) {
console.error('[META-CACHE] DB save failed:', err);
}
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: 'Invalid request body' }, 400);
}
});
// F-002 Security: Require authentication to prevent SSRF via arbitrary URL fetching.
// Guests may read from the cache (in-memory or DB); only authenticated users trigger real outbound fetches.
group.get(/\/meta\/fetch$/, async (req, res) => {
// Guests use cached entries from DB (populated by authenticated user requests).
group.get(/\/meta\/fetch$/, lib.loggedin, async (req, res) => {
if (!cfg.websrv.web_meta_extraction) {
return res.json({ success: false, msg: 'Metadata extraction is disabled' }, 403);
}
@@ -372,11 +325,6 @@ export default router => {
console.error('[META-CACHE] DB lookup failed:', err);
}
// Cache miss — require auth to perform real outbound fetch (SSRF prevention)
if (!req.session) {
return res.json({ success: false, msg: 'Not cached' }, 401);
}
const setCache = async (u, m) => {
if (!m || !m.title) return;
metaCache.set(u, m);
@@ -404,71 +352,34 @@ export default router => {
? cfg.main.socks.replace(/^socks5:\/\//, 'socks5h://')
: null;
// 1. YouTube: try oEmbed first (fast), then fall through to yt-dlp on failure
// 1. YouTube: go straight to oEmbed — faster and more reliable than yt-dlp for these
if (isYouTube) {
try {
const oembedUrl = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
// Do NOT use --fail: non-2xx responses (e.g. age-restricted/private videos) still
// return valid JSON we can attempt to parse. Use ignoreExitCode so we always get output.
const { stdout: oembedOut } = await queue.spawn('curl', [
'-s', '--max-time', '15',
'--user-agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
'-s', '--max-time', '10', '--fail',
'--user-agent', 'Mozilla/5.0 (compatible; oEmbed)',
...(socksH ? ['--proxy', socksH] : []),
oembedUrl
], { ignoreExitCode: true });
]);
if (oembedOut && oembedOut.trim()) {
const trimmed = oembedOut.trim();
// YouTube often returns HTML (consent/cookie walls, CAPTCHAs, geo-blocks)
// instead of JSON when accessed through a proxy — skip those gracefully.
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
console.warn(`[META-FETCH] YouTube oEmbed returned non-JSON (likely HTML wall), falling back to yt-dlp`);
} else {
const data = JSON.parse(trimmed);
if (data.title) {
const meta = {
title: data.title,
site_name: 'youtube.com',
author: data.author_name || 'Unknown'
};
await setCache(url, meta);
return res.json({ success: true, meta });
}
const data = JSON.parse(oembedOut);
if (data.title) {
const meta = {
title: data.title,
site_name: 'youtube.com',
author: data.author_name || 'Unknown'
};
await setCache(url, meta);
return res.json({
success: true,
meta
});
}
}
} catch (oembedErr) {
console.warn(`[META-FETCH] YouTube oEmbed failed, will try yt-dlp:`, oembedErr.message || oembedErr);
console.error(`[META-FETCH] YouTube oEmbed failed:`, oembedErr.message || oembedErr);
}
// oEmbed failed — fall back to yt-dlp for YouTube (handles age-restricted, private, etc.)
for (let attempt = 1; attempt <= 2; attempt++) {
try {
const { stdout: ytOut } = await queue.spawn('yt-dlp', [
...proxyArgs,
'--quiet',
'--no-warnings',
'--js-runtimes', 'node',
'--print', '%(title)s',
'--print', '%(uploader)s',
'--skip-download',
url
]);
const ytLines = ytOut.trim().split('\n');
const ytTitle = ytLines[0] ? ytLines[0].trim() : '';
if (ytTitle) {
const meta = {
title: ytTitle,
site_name: 'youtube.com',
author: ytLines[1] ? ytLines[1].trim() : 'Unknown'
};
await setCache(url, meta);
return res.json({ success: true, meta });
}
} catch (ytErr) {
if (attempt < 2) { await sleep(1000); continue; }
console.error(`[META-FETCH] YouTube yt-dlp fallback failed:`, ytErr.message || ytErr);
}
}
return res.json({ success: false, msg: 'Failed to extract YouTube metadata' }, 500);
}
@@ -585,9 +496,7 @@ export default router => {
const userHallOwner = req.url.qs.userHallOwner || null;
const isFav = req.url.qs.fav === 'true';
const isStrict = req.url.qs.strict === '1';
const mode = req.mode ?? 0; // Use req.mode (set by middleware) for consistency with all other routes
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const mode = req.session?.mode ?? 0;
const data = await f0cklib.getRandom({
user,
@@ -598,77 +507,53 @@ export default router => {
mime,
fav: isFav,
mode,
ratings: ratingsArr && ratingsArr.length > 0 ? ratingsArr : null,
strict: isStrict,
session: !!req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
is_admin: req.session?.admin
exclude: req.session?.excluded_tags || []
});
if (!data || !data.itemid) {
if (!data.itemid) {
return res.json({
success: false,
items: []
});
}
const rows = await db`
SELECT *
FROM "items"
WHERE id = ${data.itemid} AND active = true
LIMIT 1
`;
const item = rows[0];
// API expects { success: true, items: { id: ... } } (based on f0ck.js usage)
// The old query returned full item row. f0cklib.getRandom returns { itemid: ... } or { itemid: ... } (actually it returns { itemid: ... } on success)
if (!item) {
return res.json({
success: false,
items: []
});
}
const isYouTube = item.mime === 'video/youtube';
const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
let ytDest = item.dest;
if (isYouTube && (!ytDest || !ytDest.startsWith('yt:'))) {
const m = item.src && item.src.match(ytSrcRegex);
if (m) ytDest = `yt:${m[1]}`;
}
const relativeDest = isYouTube ? ytDest : `${cfg.websrv.paths.images}/${item.dest}`;
const directUrl = isYouTube ? ytDest : `${cfg.main.url.full}${cfg.websrv.paths.images}/${item.dest}`;
const { username, src, xd_score, ...safeItem } = item;
// We need to fetch the item details if the frontend expects them?
// Looking at f0ck.js:
// if (data.success && data.items && data.items.id) { loadItemAjax(`/${data.items.id}`, true); }
// So it only really needs the ID.
return res.json({
success: true,
items: {
...safeItem,
id: item.id,
slug: (getEnableItemSlugs() && item.slug) ? item.slug : null,
dest: relativeDest,
url: directUrl,
direct_url: directUrl
}
items: { id: data.itemid }
});
});
group.get(/\/orakel\/user$/, async (req, res) => {
try {
const now = ~~(Date.now() / 1000);
const sevenDaysAgo = now - 604800; // 7 days in seconds
const thirtyDaysAgo = now - 2592000; // 30 days in seconds
// Flat random pick from all users seen in the last 7 days.
// No tiered bias — gives a proper pool of recently-active users
// rather than always favouring whoever is online right now.
// Tiered selection from user.last_seen (updated fire-and-forget on every authenticated request):
// Tier 0 — active in last 15 minutes
// Tier 1 — active in last 24 hours
// Tier 2 — active in last 30 days (includes lurkers — anyone who visited the site)
// Banned users are always excluded.
let activeUsers = await db`
SELECT "user"."user", "user".id, uo.display_name
FROM "user"
LEFT JOIN user_options uo ON uo.user_id = "user".id
WHERE "user".last_seen > ${sevenDaysAgo}
WHERE "user".last_seen > ${thirtyDaysAgo}
AND "user".banned = false
ORDER BY RANDOM()
ORDER BY (CASE
WHEN "user".last_seen > ${now - 900} THEN 0
WHEN "user".last_seen > ${now - 86400} THEN 1
ELSE 2
END), RANDOM()
LIMIT 1
`;
@@ -710,11 +595,9 @@ export default router => {
};
const excludedTags = req.session?.excluded_tags || [];
const isOwnerOrAdmin = req.session && (req.session.admin || req.session.is_moderator);
const visibilityFilter = isOwnerOrAdmin ? db`` : db`and coalesce("items".visibility, 0) = 0`;
const newest = (await db`select max(id) as id from "items"`)[0]?.id || 0;
const oldest = (await db`select min(id) as id from "items"`)[0]?.id || 0;
const newest = (await db`select max(id) as id from "items"`)[0].id;
const oldest = (await db`select min(id) as id from "items"`)[0].id;
const modequery = lib.getMode(opt.mode);
const rows = (await db`
@@ -724,7 +607,6 @@ export default router => {
where
${db.unsafe(modequery)} and
active = true
${visibilityFilter}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = "items".id and tag_id = any(${excludedTags}::int[]))` : db``}
${opt.older
? db`and id <= ${opt.older}`
@@ -740,8 +622,8 @@ export default router => {
`).sort((a, b) => b.id - a.id);
return res.json({
atEnd: rows.length > 0 && rows[0].id === newest,
atStart: rows.length > 0 && rows[rows.length - 1].id === oldest,
atEnd: rows[0].id === newest,
atStart: rows[rows.length - 1].id === oldest,
success: true,
items: rows
}, 200);
@@ -756,6 +638,20 @@ export default router => {
where id = ${+id} and active = true
limit 1
`;
const next = await db`
select id
from "items"
where id > ${+id} and active = true
order by id
limit 1
`;
const prev = await db`
select id
from "items"
where id < ${+id} and active = true
order by id desc
limit 1
`;
if (item.length === 0) {
return res.json({
@@ -764,54 +660,12 @@ export default router => {
});
}
const actitem = item[0];
const session = req.session;
const isOwnerOrAdmin = session && (
session.admin ||
session.is_moderator ||
(session.user && session.user.toLowerCase() === (actitem.username || '').toLowerCase())
);
// Exclude unlisted (1) and private (2) items from API responses for non-owners/non-admins
if ((actitem.visibility || 0) > 0 && !isOwnerOrAdmin) {
return res.json({
success: false,
msg: 'no items found'
});
}
let guestTagFilter = db``;
if (!session) {
if (cfg.websrv.public_nsfw) {
guestTagFilter = cfg.websrv.public_untagged
? db`and (id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))`
: db`and id in (select item_id from tags_assign where tag_id in (1, 2))`;
} else {
guestTagFilter = cfg.websrv.public_untagged
? db`and (id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))`
: db`and id in (select item_id from tags_assign where tag_id = 1)`;
}
}
const next = await db`
select id
from "items"
where id > ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
order by id
limit 1
`;
const prev = await db`
select id
from "items"
where id < ${+id} and active = true and coalesce(visibility, 0) = 0 ${guestTagFilter}
order by id desc
limit 1
`;
const rows = {
...actitem,
next: next[0]?.id ?? null,
prev: prev[0]?.id ?? null
...item[0],
...{
next: next[0]?.id ?? null,
prev: prev[0]?.id ?? null
}
};
return res.json({
@@ -894,28 +748,6 @@ export default router => {
}
});
group.get(/\/items\/suggest$/, async (req, res) => {
const searchString = req.url.qs.q;
if (!searchString || searchString.length < 1) {
return res.json({ success: false, suggestions: [] });
}
try {
const items = await db`
SELECT id, title
FROM items
WHERE title IS NOT NULL
AND active = true
AND title ILIKE ${'%' + searchString + '%'}
ORDER BY id DESC
LIMIT 8
`;
return res.json({ success: true, suggestions: items });
} catch (err) {
return res.json({ success: false, error: 'Item title suggestion error', suggestions: [] });
}
});
// tags lol
group.put(/\/tags\/rename\/(?<tagname>.*)/, lib.modAuth, async (req, res) => {
@@ -966,33 +798,6 @@ export default router => {
});
// PATCH /api/v2/items/:id/title — set or clear the title for an item
// Allowed by: item owner, moderators, admins
group.patch(/\/items\/(?<id>\d+)\/title$/, lib.loggedin, async (req, res) => {
const id = +req.params.id;
if (!id) return res.json({ success: false, msg: 'Invalid item id' }, 400);
// Fetch item to check ownership
const rows = await db`SELECT id, username FROM items WHERE id = ${id} AND active = true LIMIT 1`;
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
const item = rows[0];
const isOwner = req.session.user === item.username;
const isMod = !!(req.session.is_moderator || req.session.admin);
if (!isOwner && !isMod) return res.json({ success: false, msg: 'Forbidden' }, 403);
// Accept title from JSON or URL-encoded body
let rawTitle = req.post?.title ?? req.body?.title ?? null;
if (rawTitle !== null) rawTitle = String(rawTitle).trim();
// Empty string → null (clears the title)
const title = (rawTitle === '' || rawTitle === null) ? null : rawTitle.substring(0, 500);
await db`UPDATE items SET title = ${title} WHERE id = ${id}`;
return res.json({ success: true, title });
});
group.post(/\/admin\/deletepost$/, lib.modAuth, async (req, res) => {
if (req.post.postid === undefined || req.post.postid === null) {
return res.json({
@@ -1079,24 +884,7 @@ export default router => {
});
group.post(/\/togglefav$/, lib.loggedin, async (req, res) => {
const rawPostid = req.post?.postid ?? req.body?.postid ?? req.url?.qs?.postid;
if (rawPostid === undefined || rawPostid === null) {
return res.json({ success: false, msg: 'Missing postid' }, 400);
}
// Support both numeric item ID and string slug
const isNumeric = /^\d+$/.test(String(rawPostid));
const itemRow = await db`
SELECT id FROM items
WHERE ${isNumeric ? db`id = ${+rawPostid}` : db`slug = ${String(rawPostid)}`} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!itemRow.length) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const postid = itemRow[0].id;
const postid = +req.post.postid;
// Check if already faved by this user — compare as numbers to avoid type mismatch
const existing = await db`
@@ -1124,7 +912,7 @@ export default router => {
}
const favs = await db`
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color, "user_options".hide_fav_badge
select "user".user, "user_options".avatar, "user_options".avatar_file, "user_options".display_name, "user_options".username_color
from "favorites"
left join "user" on "user".id = "favorites".user_id
left join "user_options" on "user_options".user_id = "favorites".user_id
@@ -1226,121 +1014,6 @@ export default router => {
});
});
group.post(/\/item\/visibility$/, lib.loggedin, async (req, res) => {
if (cfg.enable_private_uploads === false) {
return res.json({ success: false, msg: 'Private uploads feature disabled' }, 403);
}
const postid = req.post?.postid || req.post?.id || req.body?.postid || req.body?.id;
const visibility = parseInt(req.post?.visibility ?? req.body?.visibility, 10);
if (!postid || isNaN(visibility) || ![0, 1, 2].includes(visibility)) {
return res.json({ success: false, msg: 'Invalid parameters' }, 400);
}
const isNumeric = /^\d+$/.test(String(postid));
const item = await db`
SELECT id, slug, username, visibility
FROM items
WHERE ${isNumeric ? db`id = ${+postid}` : db`slug = ${String(postid)}`} AND active = true AND is_deleted = false
LIMIT 1
`;
if (item.length === 0) {
return res.json({ success: false, msg: 'Item not found' }, 404);
}
const isOwner = item[0].username === req.session.user;
const isAdmin = req.session.admin || req.session.is_moderator;
if (!isOwner && !isAdmin) {
return res.json({ success: false, msg: 'Unauthorized' }, 403);
}
await db`UPDATE items SET visibility = ${visibility} WHERE id = ${item[0].id}`;
f0cklib.clearCountCache();
return res.json({
success: true,
itemid: item[0].id,
slug: item[0].slug,
visibility: visibility
});
});
group.post(/\/items\/(?<id>[0-9]+)\/rethumb$/, lib.loggedin, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
const rows = await db`
SELECT id, dest, mime, username
FROM items
WHERE id = ${itemid} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
const item = rows[0];
const isOwner = item.username === req.session.user;
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
const ok = await queue.genThumbnail(item.dest, item.mime, item.id, '', false);
await queue.genBlurredThumbnail(item.id, false);
if (ok) {
db.notify('rethumb', JSON.stringify({ item_id: item.id })).catch(() => {});
audit.log(req.session.id, 'rethumb_item', 'item', item.id, {}).catch(() => {});
return res.json({ success: true, itemid: item.id });
} else {
return res.json({ success: false, msg: 'Thumbnail regeneration failed' }, 500);
}
});
group.post(/\/items\/(?<id>[0-9]+)\/expiry$/, lib.loggedin, async (req, res) => {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return res.json({ success: false, msg: 'Expiring uploads feature is disabled' }, 403);
}
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
const rows = await db`
SELECT id, username, expires_at
FROM items
WHERE id = ${itemid} AND active = true AND is_deleted = false
LIMIT 1
`;
if (!rows.length) return res.json({ success: false, msg: 'Item not found' }, 404);
const item = rows[0];
const isOwner = item.username === req.session.user;
const isAdmin = !!(req.session.admin || req.session.is_moderator);
if (!isOwner && !isAdmin) return res.json({ success: false, msg: 'Unauthorized' }, 403);
const reqExpiry = req.body?.expiry ?? req.body?.expires_at ?? req.post?.expiry ?? req.post?.expires_at;
const nowStamp = Math.floor(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(reqExpiry, nowStamp);
await db`UPDATE items SET expires_at = ${targetExpiresAt} WHERE id = ${item.id}`;
audit.log(req.session.id, 'set_item_expiry', 'item', item.id, { expires_at: targetExpiresAt }).catch(() => {});
if (targetExpiresAt && targetExpiresAt <= nowStamp) {
await purgeExpiredUploads().catch(err => {
console.error('[API ITEM EXPIRY] Purge failed:', err);
});
}
const expires_in = targetExpiresAt ? lib.expiresIn(targetExpiresAt) : null;
return res.json({
success: true,
itemid: item.id,
expires_at: targetExpiresAt,
expires_in: expires_in,
purged: !!(targetExpiresAt && targetExpiresAt <= nowStamp)
});
});
group.post(/\/item\/(?<id>[0-9]+)\/rating$/, lib.loggedin, async (req, res) => {
const itemid = +req.params.id;
if (!itemid) return res.json({ success: false, msg: 'No itemid provided' }, 400);
@@ -1372,22 +1045,12 @@ export default router => {
const currentRatingId = existingRating.length > 0 ? existingRating[0].tag_id : null;
let newRatingId;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
newRatingId = 1;
} else if (reqRating === 'nsfw') {
newRatingId = 2;
} else if (reqRating === 'nsfl') {
newRatingId = nsfl_id;
if (currentRatingId === 1) {
newRatingId = 2; // SFW -> NSFW
} else if (currentRatingId === 2) {
newRatingId = cfg.enable_nsfl ? nsfl_id : 1; // NSFW -> NSFL (if enabled) or SFW
} else {
// fallback to cycling
if (currentRatingId === 1) {
newRatingId = 2; // SFW -> NSFW
} else if (currentRatingId === 2) {
newRatingId = cfg.enable_nsfl ? nsfl_id : 1; // NSFW -> NSFL (if enabled) or SFW
} else {
newRatingId = 1; // NSFL or none -> SFW
}
newRatingId = 1; // NSFL or none -> SFW
}
await db.begin(async sql => {
@@ -1400,10 +1063,12 @@ export default router => {
VALUES (${itemid}, ${newRatingId}, ${req.session.id})
`;
// Ensure blurred thumbnail exists
await queue.genBlurredThumbnail(itemid).catch(err => {
console.error(`[RATING_TOGGLE] Blurred thumbnail generation failed for ${itemid}:`, err);
});
// If switching to NSFW/NSFL, ensure blurred thumbnail exists
if (newRatingId === 2 || newRatingId === nsfl_id) {
await queue.genBlurredThumbnail(itemid).catch(err => {
console.error(`[RATING_TOGGLE] Blurred thumbnail generation failed for ${itemid}:`, err);
});
}
});
const newRating = newRatingId === 1 ? 'sfw' : (newRatingId === 2 ? 'nsfw' : 'nsfl');

View File

@@ -3,7 +3,6 @@ import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
// Note: Avatar upload/delete is handled by middleware in index.mjs via avatar_handler.mjs
// These routes remain for other settings API endpoints
@@ -296,87 +295,40 @@ export default router => {
}
});
// Update New Layout visibility preference
// Update feed layout preference (0=grid, 1=modern, 2=feed/instagram, 3=youtube)
group.put(/\/layout/, lib.loggedin, async (req, res) => {
const use_new_layout = req.post.use_new_layout === true || req.post.use_new_layout === 'true';
const raw = req.post.feed_layout !== undefined ? req.post.feed_layout : req.post.use_new_layout;
let feed_layout;
// Backward compat: if old boolean use_new_layout was sent, map to int
if (req.post.feed_layout === undefined && req.post.use_new_layout !== undefined) {
feed_layout = (req.post.use_new_layout === true || req.post.use_new_layout === 'true') ? 1 : 0;
} else {
feed_layout = parseInt(raw, 10);
}
if (isNaN(feed_layout) || feed_layout < 0 || feed_layout > 3) {
return res.json({ success: false, msg: 'Invalid layout value: must be 03' }, 400);
}
try {
await db`
update user_options
set use_new_layout = ${use_new_layout}
set feed_layout = ${feed_layout},
use_new_layout = ${feed_layout === 1}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.use_new_layout = use_new_layout;
return res.json({ success: true, use_new_layout }, 200);
if (req.session) {
req.session.feed_layout = feed_layout;
req.session.use_new_layout = feed_layout === 1;
}
return res.json({ success: true, feed_layout }, 200);
} catch (e) {
console.error('Update Layout pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Favorites Privacy preference
group.put(/\/favorites_private/, lib.loggedin, async (req, res) => {
const favorites_private = req.post.favorites_private === true || req.post.favorites_private === 'true';
try {
await db`
update user_options
set favorites_private = ${favorites_private}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.favorites_private = favorites_private;
return res.json({ success: true, favorites_private }, 200);
} catch (e) {
console.error('Update Favorites Privacy pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Hide Fav Badge preference
group.put(/\/hide_fav_badge/, lib.loggedin, async (req, res) => {
const hide_fav_badge = req.post.hide_fav_badge === true || req.post.hide_fav_badge === 'true';
try {
await db`
update user_options
set hide_fav_badge = ${hide_fav_badge}
where user_id = ${+req.session.id}
`;
// Sync session immediately
if (req.session) req.session.hide_fav_badge = hide_fav_badge;
return res.json({ success: true, hide_fav_badge }, 200);
} catch (e) {
console.error('Update Hide Fav Badge pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Default Upload Visibility preference
group.put(/\/default_upload_visibility/, lib.loggedin, async (req, res) => {
if (cfg.allow_user_upload_visibility === false || cfg.websrv?.allow_user_upload_visibility === false) {
return res.json({ success: false, msg: 'Custom upload visibility is disabled by the administrator' }, 403);
}
const vis = parseInt(req.post.default_upload_visibility, 10);
if (isNaN(vis) || ![0, 1, 2].includes(vis)) {
return res.json({ success: false, msg: 'Invalid visibility option' }, 400);
}
try {
await db`
update user_options
set default_upload_visibility = ${vis}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.default_upload_visibility = vis;
return res.json({ success: true, default_upload_visibility: vis }, 200);
} catch (e) {
console.error('Update Default Upload Visibility pref error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update Username Color preference
group.put(/\/username_color/, lib.loggedin, async (req, res) => {
const { color } = req.post;
@@ -393,14 +345,6 @@ export default router => {
`;
// Sync session immediately
if (req.session) req.session.username_color = color;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
username_color: color
});
await db`select pg_notify('profile_update', ${payloadStr})`;
return res.json({ success: true, color }, 200);
} catch (e) {
console.error('Update Username Color error:', e);
@@ -490,14 +434,6 @@ export default router => {
`;
// Sync session immediately
if (req.session) req.session.display_name = cleanDisplayName;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
display_name: cleanDisplayName
});
await db`select pg_notify('profile_update', ${payloadStr})`;
return res.json({ success: true, display_name: cleanDisplayName, msg: 'Display name updated successfully' }, 200);
} catch (e) {
console.error('Update Display Name error:', e);
@@ -516,7 +452,7 @@ export default router => {
return res.json({ success: false, msg: 'Invalid description format' }, 400);
}
const cleanDescription = description ? description.trim().substring(0, 2000) : null;
const cleanDescription = description ? description.trim().substring(0, 255) : null;
try {
await db`
@@ -526,14 +462,6 @@ export default router => {
`;
// Sync session immediately
if (req.session) req.session.description = cleanDescription;
const payloadStr = JSON.stringify({
user_id: req.session.id,
user: req.session.user,
description: cleanDescription
});
await db`select pg_notify('profile_update', ${payloadStr})`;
return res.json({ success: true, description: cleanDescription }, 200);
} catch (e) {
console.error('Update Description error:', e);
@@ -548,7 +476,7 @@ export default router => {
// F-023 Security: Validate font against actual files on disk
// The font value is rendered into CSS url() in header.html, so it must be a real filename
if (font) {
const fontsDir = cfg.paths.fonts;
const fontsDir = path.join(path.resolve(), 'public/s/fonts');
try {
const available = (await fs.readdir(fontsDir)).filter(f => /\.(ttf|otf|woff2?)$/i.test(f));
if (!available.includes(font)) {
@@ -731,24 +659,6 @@ export default router => {
}
});
// Update alternative steuerung preference (per-user toggle for icon-only nav)
group.put(/\/alternative_steuerung/, lib.loggedin, async (req, res) => {
const use_alternative_steuerung = req.post.use_alternative_steuerung === true || req.post.use_alternative_steuerung === 'true';
try {
await db`
update user_options
set use_alternative_steuerung = ${use_alternative_steuerung}
where user_id = ${+req.session.id}
`;
if (req.session) req.session.use_alternative_steuerung = use_alternative_steuerung;
return res.json({ success: true, use_alternative_steuerung }, 200);
} catch (e) {
console.error('Update alternative_steuerung error:', e);
return res.json({ success: false, msg: 'Error updating preference' }, 500);
}
});
// Update per-user language preference
group.put(/\/language/, lib.loggedin, async (req, res) => {
if (cfg.websrv.allow_language_change === false) {
@@ -831,342 +741,6 @@ export default router => {
}
});
// --- Upload API Key Management ---
// GET /api/v2/settings/api-key
// Returns whether the user has an API key, when it was created, and the last 8 chars (masked preview).
group.get(/\/api-key$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
try {
const row = (await db`
SELECT api_key, created_at
FROM user_api_keys
WHERE user_id = ${+req.session.id}
LIMIT 1
`)[0];
if (!row) {
return res.json({ success: true, has_key: false }, 200);
}
return res.json({
success: true,
has_key: true,
preview: `****${row.api_key.slice(-8)}`,
created_at: row.created_at
}, 200);
} catch (e) {
console.error('[API KEY] GET error:', e);
return res.json({ success: false, msg: 'Error fetching API key' }, 500);
}
});
// POST /api/v2/settings/api-key/regenerate
// Generates a new key (or replaces an existing one). Returns the full key — only shown once.
group.post(/\/api-key\/regenerate$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
try {
const newKey = crypto.randomBytes(32).toString('hex');
await db`
INSERT INTO user_api_keys (user_id, api_key, created_at)
VALUES (${+req.session.id}, ${newKey}, now())
ON CONFLICT (user_id) DO UPDATE
SET api_key = EXCLUDED.api_key,
created_at = now()
`;
return res.json({
success: true,
api_key: newKey,
msg: 'API key generated. Copy it now — it will not be shown again in full.'
}, 200);
} catch (e) {
console.error('[API KEY] Regenerate error:', e);
return res.json({ success: false, msg: 'Error generating API key' }, 500);
}
});
// DELETE /api/v2/settings/api-key
// Revokes (deletes) the user's API key.
group.delete(/\/api-key$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.json({ success: false, msg: 'API keys are disabled' }, 403);
}
try {
const result = await db`
DELETE FROM user_api_keys
WHERE user_id = ${+req.session.id}
RETURNING user_id
`;
if (result.length === 0) {
return res.json({ success: false, msg: 'No API key to revoke' }, 404);
}
return res.json({ success: true, msg: 'API key revoked' }, 200);
} catch (e) {
console.error('[API KEY] Delete error:', e);
return res.json({ success: false, msg: 'Error revoking API key' }, 500);
}
});
// GET /api/v2/settings/api-key/sharex-config
// Downloads a pre-filled ShareX custom uploader (.sxcu) for the requesting user.
group.get(/\/api-key\/sharex-config$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_api_keys === false) {
return res.status(403).reply({ body: 'API keys are disabled' });
}
try {
const row = (await db`
SELECT api_key FROM user_api_keys
WHERE user_id = ${+req.session.id}
LIMIT 1
`)[0];
if (!row) {
return res.status(404).reply({ body: 'No API key — generate one first in Settings.' });
}
// Determine a safe default rating for the ShareX config.
// In shitpost mode the server accepts uploads without a rating, but
// we still send 'sfw' so the item gets a rating tag by default.
const defaultRating = 'sfw';
const sxcu = {
Version: '15.0.0',
Name: cfg.main.url.domain,
DestinationType: 'ImageUploader, FileUploader',
RequestMethod: 'POST',
RequestURL: `${cfg.main.url.full}/api/v2/upload`,
Headers: {
'X-Api-Key': row.api_key
},
Body: 'MultipartFormData',
FileFormName: 'file',
// The server requires a rating field. Without it every upload is rejected.
// Users can change this value in ShareX's custom uploader settings.
Parameters: {
rating: defaultRating
},
// {json:file_url} maps to the direct file URL in the success response JSON
URL: '{json:file_url}',
ThumbnailURL: '{json:file_url}',
ErrorMessage: '{response}'
};
const filename = `${cfg.main.url.domain}.sxcu`;
const body = JSON.stringify(sxcu, null, 2);
res.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${filename}"`,
'Content-Length': Buffer.byteLength(body)
}).end(body);
} catch (e) {
console.error('[API KEY] ShareX config error:', e);
return res.status(500).reply({ body: 'Error generating config' });
}
});
// --- User Invite System ---
// Eligibility: ≥150 uploads, ≥30 days old, ≥66 comments, ≥200 tags
// Slots: configurable (default 2), refresh 30 days after a token is used.
const getInviteCriteria = () => {
const ic = cfg.websrv.invite_criteria || {};
return {
uploads: Number.isFinite(+ic.uploads) ? +ic.uploads : 150,
age_days: Number.isFinite(+ic.age_days) ? +ic.age_days : 30,
comments: Number.isFinite(+ic.comments) ? +ic.comments : 66,
tags: Number.isFinite(+ic.tags) ? +ic.tags : 200,
};
};
const getInviteSlots = () => {
const n = parseInt(cfg.websrv.user_invite_slots);
return Number.isFinite(n) && n > 0 ? n : 2;
};
// GET /api/v2/settings/invites
// Returns eligibility, criteria breakdown, tokens created by this user, and slot usage.
group.get(/\/invites$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const userId = +req.session.id;
const username = req.session.user;
const totalSlots = getInviteSlots();
const refreshDays = 30;
const isAdmin = !!req.session.admin;
// Always fetch this user's token history
const tokens = await db`
SELECT
it.id,
it.token,
it.is_used,
it.used_at,
it.created_at,
u.user AS used_by_name
FROM invite_tokens it
LEFT JOIN "user" u ON u.id = it.used_by
WHERE it.created_by = ${userId}
ORDER BY it.created_at DESC
`;
let eligible, criteria, slotsConsumed, slotsAvailable;
if (isAdmin) {
// Admins bypass all criteria and slot limits
eligible = true;
criteria = null;
slotsConsumed = 0;
slotsAvailable = Infinity;
} else {
// Gather eligibility stats in one query
const [stats] = await db`
SELECT
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
FROM "user" u
WHERE u.id = ${userId}
`;
const INVITE_CRITERIA = getInviteCriteria();
criteria = {
uploads: { current: stats.upload_count, required: INVITE_CRITERIA.uploads, met: stats.upload_count >= INVITE_CRITERIA.uploads },
age_days: { current: Math.floor(stats.age_days), required: INVITE_CRITERIA.age_days, met: stats.age_days >= INVITE_CRITERIA.age_days },
comments: { current: stats.comment_count, required: INVITE_CRITERIA.comments, met: stats.comment_count >= INVITE_CRITERIA.comments },
tags: { current: stats.tag_count, required: INVITE_CRITERIA.tags, met: stats.tag_count >= INVITE_CRITERIA.tags },
};
eligible = Object.values(criteria).every(c => c.met);
// Slots consumed = tokens used within the last 30 days
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
slotsConsumed = tokens.filter(t => t.is_used && t.used_at && new Date(t.used_at) > cutoff).length;
slotsAvailable = Math.max(0, totalSlots - slotsConsumed);
}
return res.json({
success: true,
is_admin: isAdmin,
eligible,
criteria,
tokens,
slots_total: isAdmin ? null : totalSlots,
slots_consumed: isAdmin ? null : slotsConsumed,
slots_available: isAdmin ? null : slotsAvailable,
refresh_days: refreshDays,
}, 200);
} catch (e) {
console.error('[INVITES] GET error:', e);
return res.json({ success: false, msg: 'Error fetching invite data' }, 500);
}
});
// POST /api/v2/settings/invites/create
// Generates a new invite token if eligible and slots remain.
group.post(/\/invites\/create$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const userId = +req.session.id;
const username = req.session.user;
const totalSlots = getInviteSlots();
const refreshDays = 30;
const isAdmin = !!req.session.admin;
if (!isAdmin) {
// Eligibility check
const [stats] = await db`
SELECT
(SELECT COUNT(*) FROM items WHERE username = ${username} AND active = true AND is_deleted = false)::int AS upload_count,
EXTRACT(EPOCH FROM (NOW() - u.created_at)) / 86400 AS age_days,
(SELECT COUNT(*) FROM comments WHERE user_id = ${userId} AND is_deleted = false)::int AS comment_count,
(SELECT COUNT(*) FROM tags_assign WHERE user_id = ${userId})::int AS tag_count
FROM "user" u WHERE u.id = ${userId}
`;
const INVITE_CRITERIA = getInviteCriteria();
const eligible =
stats.upload_count >= INVITE_CRITERIA.uploads &&
stats.age_days >= INVITE_CRITERIA.age_days &&
stats.comment_count >= INVITE_CRITERIA.comments &&
stats.tag_count >= INVITE_CRITERIA.tags;
if (!eligible) {
return res.json({ success: false, msg: 'You do not meet the eligibility criteria' }, 403);
}
// Check available slots (used within last 30 days)
const cutoff = new Date(Date.now() - refreshDays * 24 * 60 * 60 * 1000);
const [{ slots_consumed }] = await db`
SELECT COUNT(*)::int AS slots_consumed
FROM invite_tokens
WHERE created_by = ${userId}
AND is_used = true
AND used_at > ${cutoff}
`;
if (slots_consumed >= totalSlots) {
return res.json({ success: false, msg: 'No invite slots available. Slots refresh 30 days after use.' }, 403);
}
}
// Generate token
const token = crypto.randomBytes(16).toString('hex').toUpperCase();
await db`
INSERT INTO invite_tokens (token, created_at, created_by)
VALUES (${token}, ${~~(Date.now() / 1e3)}, ${userId})
`;
console.log(`[INVITES] User ${username} (${userId}) generated invite token ${token}`);
return res.json({ success: true, token }, 200);
} catch (e) {
console.error('[INVITES] Create error:', e);
return res.json({ success: false, msg: 'Error creating invite token' }, 500);
}
});
// POST /api/v2/settings/invites/delete
// Deletes an unused invite token owned by the calling user.
group.post(/\/invites\/delete$/, lib.loggedin, async (req, res) => {
if (cfg.websrv.enable_user_invites === false) {
return res.json({ success: false, msg: 'Invite system is disabled' }, 403);
}
try {
const { id } = req.post;
if (!id) return res.json({ success: false, msg: 'Missing token ID' }, 400);
const result = await db`
DELETE FROM invite_tokens
WHERE id = ${+id}
AND created_by = ${+req.session.id}
AND is_used = false
RETURNING id
`;
if (result.length === 0) {
return res.json({ success: false, msg: 'Token not found or already used' }, 404);
}
return res.json({ success: true }, 200);
} catch (e) {
console.error('[INVITES] Delete error:', e);
return res.json({ success: false, msg: 'Error deleting invite token' }, 500);
}
});
return group;
});

View File

@@ -25,8 +25,7 @@ export default router => {
group.post(/$/, lib.loggedin, async (req, res) => {
// assign and/or create tag
const rawTagname = req.post?.tagname || req.body?.tagname;
if (!req.params.postid || !rawTagname) {
if (!req.params.postid || !req.post.tagname) {
return res.json({
success: false,
msg: 'missing postid or tag'
@@ -34,7 +33,7 @@ export default router => {
}
const postid = +req.params.postid;
const tagname = rawTagname.trim();
const tagname = req.post.tagname?.trim();
const protectedTags = ['sfw', 'nsfw', 'nsfl'];
if (protectedTags.includes(tagname.toLowerCase())) {
@@ -44,7 +43,7 @@ export default router => {
});
}
if (tagname.length > 85) {
if (tagname.length > 70) {
return res.json({
success: false,
msg: 'tag is too long!'
@@ -106,19 +105,8 @@ export default router => {
const cycle = [1, 2, nsflId];
const currentTags = await lib.getTags(postid);
const ratingTagId = currentTags.find(t => [1, 2, nsflId].includes(t.id))?.id ?? 0;
let nextTagId;
const reqRating = req.body?.rating || req.post?.rating || req.url?.qs?.rating;
if (reqRating === 'sfw') {
nextTagId = 1;
} else if (reqRating === 'nsfw') {
nextTagId = 2;
} else if (reqRating === 'nsfl') {
nextTagId = nsflId;
} else {
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (1+1)%3 = 0 → SFW
nextTagId = cycle[(cycleIdx + 1) % cycle.length];
}
const cycleIdx = cycle.indexOf(ratingTagId); // -1 if untagged → (1+1)%3 = 0 → SFW
const nextTagId = cycle[(cycleIdx + 1) % cycle.length];
try {
// Remove any existing rating tag
@@ -127,14 +115,6 @@ export default router => {
await db`INSERT INTO tags_assign ${db({ tag_id: nextTagId, item_id: postid, user_id: +req.session.id })}`;
}
// Automatically generate/verify blurred thumbnail on cycle
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
await queue.genBlurredThumbnail(postid, false);
}
const labels = { 1: { label: 'SFW', cls: 'sfw' }, 2: { label: 'NSFW', cls: 'nsfw' }, [nsflId]: { label: 'NSFL', cls: 'nsfl' } };
const { label, cls } = labels[nextTagId];
@@ -190,13 +170,16 @@ export default router => {
await audit.log(req.session.id, 'toggle_tag', 'item', postid, auditDetails);
// Ensure blurred thumbnail exists on toggle
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
// Doesn't exist - generate it
await queue.genBlurredThumbnail(postid, false);
// Generate blurred thumbnail if toggling TO NSFW
if (hasSFW && !hasNSFW) {
// Was SFW, now NSFW - check if blur exists and generate if not
const blurPath = path.join(cfg.paths.t, `${postid}_blur.webp`);
try {
await fs.promises.access(blurPath);
} catch {
// Doesn't exist - generate it
await queue.genBlurredThumbnail(postid, false);
}
}
const freshTags = await lib.getTags(postid);

View File

@@ -1,97 +1,10 @@
import { promises as fs } from "fs";
import { spawn as _spawnRaw } from 'child_process';
import db from '../../sql.mjs';
import lib from '../../lib.mjs';
import cfg from '../../config.mjs';
import { getEnableItemSlugs } from '../../settings.mjs';
import { applyWordFilter } from '../../wordfilter.mjs';
import queue from '../../queue.mjs';
import path from "path";
// ──────────────────────────────────────────────────────────────────────
// In-memory job progress map (keyed by jobId string)
// Entries: { stage, percent, speed, eta, done, error }
// Auto-cleaned 10 min after completion.
// ──────────────────────────────────────────────────────────────────────
const progressMap = new Map();
const setProgress = (jobId, patch) => {
const existing = progressMap.get(jobId) || { stage: 'queued', percent: 0, speed: null, eta: null, done: false, error: null };
progressMap.set(jobId, { ...existing, ...patch });
};
const cleanupJob = (jobId) => {
setTimeout(() => progressMap.delete(jobId), 10 * 60 * 1000);
};
/**
* Like queue.spawn() but streams stderr lines to a callback for live progress,
* while still resolving with { stdout, stderr } when the process exits.
* Splits on both \n and \r so yt-dlp's carriage-return progress works.
*/
const spawnWithProgress = (cmd, args, onLine) => {
return new Promise((resolve, reject) => {
const child = _spawnRaw(cmd, args);
const stdoutChunks = [];
const stderrChunks = [];
let stderrBuf = '';
if (child.stdout) child.stdout.on('data', d => stdoutChunks.push(d));
if (child.stderr) {
child.stderr.on('data', chunk => {
stderrChunks.push(chunk);
stderrBuf += chunk.toString();
// Split on \r or \n (yt-dlp uses \r to overwrite progress in-place)
const parts = stderrBuf.split(/[\r\n]/);
// Keep the last (potentially incomplete) segment in the buffer
stderrBuf = parts.pop() ?? '';
for (const line of parts) {
// Strip ANSI escape codes
const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
if (clean && onLine) onLine(clean);
}
});
}
child.on('close', code => {
// Process any remaining buffered content
if (stderrBuf.trim() && onLine) {
const clean = stderrBuf.replace(/\x1b\[[0-9;]*m/g, '').trim();
if (clean) onLine(clean);
}
const stdout = Buffer.concat(stdoutChunks).toString();
const stderr = Buffer.concat(stderrChunks).toString();
if (code !== 0) {
const err = new Error(`Command '${cmd} ${args.join(' ')}' failed with code ${code}`);
err.stderr = stderr;
err.stdout = stdout;
return reject(err);
}
resolve({ stdout, stderr });
});
child.on('error', err => {
err.stderr = Buffer.concat(stderrChunks).toString();
err.stdout = Buffer.concat(stdoutChunks).toString();
reject(err);
});
});
};
/** Parse a yt-dlp stderr line and return a progress patch object or null. */
const parseYtdlpLine = (line) => {
// [download] 47.3% of ~ 58.23MiB at 3.22MiB/s ETA 00:13
const dlMatch = line.match(/\[download\]\s+([\d.]+)%.*?at\s+([\d.]+\s*\S+\/s)(?:.*?ETA\s+(\S+))?/);
if (dlMatch) {
return { stage: 'downloading', percent: parseFloat(dlMatch[1]), speed: dlMatch[2] || null, eta: dlMatch[3] || null };
}
// [download] Destination: ...
if (line.includes('[download] Destination:')) return { stage: 'downloading' };
if (line.includes('[Merger]') || line.includes('[ffmpeg]')) return { stage: 'processing', percent: 99 };
if (line.includes('[ExtractAudio]')) return { stage: 'extracting', percent: 99 };
return null;
};
// Native multipart form data parser
const parseMultipart = (buffer, boundary) => {
const parts = {};
@@ -138,71 +51,6 @@ const parseMultipart = (buffer, boundary) => {
import { getManualApproval, getMinTags, getBypassDuplicateCheck } from "../../settings.mjs";
const getTargetVisibility = (req, postVis) => {
if (cfg.enable_private_uploads === false) return 0;
const sysDefault = (typeof cfg.default_upload_visibility === 'number')
? cfg.default_upload_visibility
: (typeof cfg.websrv?.default_upload_visibility === 'number' ? cfg.websrv.default_upload_visibility : 0);
const allowUserOverride = cfg.allow_user_upload_visibility !== false && cfg.websrv?.allow_user_upload_visibility !== false;
if (!allowUserOverride) {
return sysDefault;
}
const rawHeader = req.headers ? req.headers['x-upload-visibility'] : null;
const val = (rawHeader || postVis || '').toString().trim().toLowerCase();
if (val === 'private' || val === '2') return 2;
if (val === 'unlisted' || val === '1') return 1;
if (val === 'public' || val === '0') return 0;
return (req.session?.default_upload_visibility !== undefined && req.session?.default_upload_visibility !== null)
? req.session.default_upload_visibility
: sysDefault;
};
export function calculateExpiresAt(expiryVal, baseStamp = ~~(Date.now() / 1000)) {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) {
return null;
}
if (!expiryVal || expiryVal === 'permanent' || expiryVal === 'never' || expiryVal === '0') {
return null;
}
const val = expiryVal.toString().trim().toLowerCase();
switch (val) {
case '30minutes':
case '30m':
case '1800':
return baseStamp + 1800;
case '1hour':
case '1h':
case '3600':
return baseStamp + 3600;
case '24hours':
case '24h':
case '1day':
case '86400':
return baseStamp + 86400;
case '1week':
case '1w':
case '7days':
case '604800':
return baseStamp + 604800;
case '1month':
case '1m':
case '30days':
case '2592000':
return baseStamp + 2592000;
default:
const parsed = parseInt(val, 10);
if (!isNaN(parsed) && parsed > 0) {
return parsed > 2000000000 ? parsed : baseStamp + parsed;
}
return null;
}
}
// Collect request body as buffer with debug logging
const collectBody = (req) => {
return new Promise((resolve, reject) => {
@@ -231,25 +79,15 @@ const collectBody = (req) => {
export default router => {
router.group(/^\/api\/v2/, group => {
// ── GET /api/v2/upload-url/progress/:jobId ──────────────────────────────
group.get(/\/upload-url\/progress\/(?<jobId>[a-zA-Z0-9_-]+)$/, lib.loggedin, (req, res) => {
const jobId = req.params?.jobId || (req.url?.pathname || req.url || '').split('/').pop();
const state = progressMap.get(jobId);
res.setHeader?.('Cache-Control', 'no-store');
if (!state) return res.json({ success: false, msg: 'Job not found' }, 404);
return res.json({ success: true, ...state });
});
const saveComment = async (itemid, userid, content) => {
if (!content || !content.trim()) return;
try {
const filteredContent = await applyWordFilter(content);
await db`
INSERT INTO comments ${db({
item_id: itemid,
user_id: userid,
parent_id: null,
content: filteredContent.trim()
content: content.trim()
}, 'item_id', 'user_id', 'parent_id', 'content')}
`;
} catch (err) {
@@ -295,6 +133,8 @@ export default router => {
domain = parts.slice(-2).join('.');
}
if (domain) tags.push(domain);
// YouTube-specific tag
if (/(?:youtube\.com|youtu\.be)$/i.test(domain) || /(?:youtube\.com|youtu\.be)$/i.test(host)) {
tags.push('youtube');
@@ -362,13 +202,7 @@ export default router => {
return res.json({ success: false, msg: 'URL uploads are disabled' }, 403);
}
const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc, is_shitpost, title: rawTitle } = req.post || {};
const title = (rawTitle && typeof rawTitle === 'string' && rawTitle.trim()) ? rawTitle.trim().substring(0, 500) : null;
const maxLen = cfg.main.comment_max_length;
if (comment && maxLen !== null && maxLen !== undefined && comment.length > maxLen) {
return res.json({ success: false, msg: `Comment too long (max ${maxLen} characters)` }, 400);
}
const { url: inputUrl, rating, tags: tagsRaw, comment, is_oc, is_shitpost } = req.post || {};
if (!inputUrl || !inputUrl.trim()) {
return res.json({ success: false, msg: 'URL is required' }, 400);
@@ -385,8 +219,8 @@ export default router => {
}
const tags = tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t.length > 0 && !['sfw', 'nsfw', 'nsfl'].includes(t.toLowerCase())) : [];
const minTags = getMinTags();
// In shitpost mode tags are optional; skip entirely when minTags is 0
if (!is_shitpost && minTags > 0 && tags.length < minTags) {
// In shitpost mode tags are optional
if (!is_shitpost && tags.length < minTags) {
return res.json({ success: false, msg: `At least ${minTags} tag${minTags !== 1 ? 's' : ''} required` }, 400);
}
@@ -402,16 +236,7 @@ export default router => {
}
}
let url = inputUrl.trim();
try {
const parsed = new URL(url);
if (parsed.searchParams.has('igsh')) {
parsed.searchParams.delete('igsh');
url = parsed.toString();
}
} catch (e) {
// Ignore malformed URL, keep original string
}
const url = inputUrl.trim();
const ytRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\?(?:\S*?&?v\=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const ytMatch = url.match(ytRegex);
@@ -435,11 +260,6 @@ export default router => {
// Store as a YouTube embed: dest = yt:VIDEO_ID, mime = video/youtube
const filename = `yt:${videoId}`;
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = lib.generateSlug(11);
const nowStamp = ~~(Date.now() / 1000);
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db`
insert into items ${db({
src: ytUrl,
@@ -451,14 +271,10 @@ export default router => {
username: req.session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: nowStamp,
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
RETURNING id
`;
@@ -480,7 +296,9 @@ export default router => {
if (effectiveRating) {
const ratingTagId = effectiveRating === 'sfw' ? 1 : (effectiveRating === 'nsfw' ? 2 : (cfg.nsfl_tag_id || 3));
await db`insert into tags_assign ${db({ item_id: itemid, tag_id: ratingTagId, user_id: req.session.id })} on conflict do nothing`;
await queue.genBlurredThumbnail(itemid, isApprovalRequired).catch(() => {});
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') {
await queue.genBlurredThumbnail(itemid, isApprovalRequired).catch(() => {});
}
}
// Assign user tags + auto-tags
@@ -511,9 +329,6 @@ export default router => {
});
} else {
// ===== REGULAR URL DOWNLOAD (Asynchronous) =====
const targetVisibility = getTargetVisibility(req, req.post?.visibility);
const itemSlug = lib.generateSlug(11);
const session = {
id: req.session.id,
user: req.session.user,
@@ -523,14 +338,9 @@ export default router => {
};
// Return immediately to avoid proxy timeouts
// Generate a client-side trackable job ID
const jobId = await queue.genuuid();
setProgress(jobId, { stage: 'queued', percent: 0, speed: null, eta: null, done: false, error: null });
res.json({
success: true,
pending: true,
jobId,
msg: 'URL processing started in background. You will receive a notification when it is finished.'
});
@@ -575,7 +385,7 @@ export default router => {
try {
const proxyArgs = (cfg.main.socks && cfg.main.socks !== 'undefined') ? ['--proxy', cfg.main.socks] : [];
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web', '--newline', '--no-colors'];
const ytdlpArgs = ['--js-runtimes', 'node', '--geo-bypass', '--extractor-args', 'youtube:player-client=ios,web'];
let maxfilesize = cfg.main.maxfilesize;
if (session.admin) maxfilesize = Math.floor(maxfilesize * cfg.main.adminmultiplier);
@@ -586,10 +396,9 @@ export default router => {
let source;
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 1 (constrained) download for ${url} (user: ${session.user})`);
setProgress(jobId, { stage: 'downloading', percent: 0 });
try {
source = (await spawnWithProgress('yt-dlp', [
source = (await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
'-f', 'bv*[height<=1080]+ba/b[height<=1080] / wv*+ba/w',
url,
@@ -598,30 +407,22 @@ export default router => {
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath',
'--merge-output-format', 'mp4'
], (line) => {
const patch = parseYtdlpLine(line);
if (patch) setProgress(jobId, patch);
})).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 1 failed: ${err.message}`);
if (isInstagram) throw new Error(sanitizeError(err));
setProgress(jobId, { stage: 'downloading', percent: 0, speed: null, eta: null });
try {
source = (await spawnWithProgress('yt-dlp', [
source = (await queue.spawn('yt-dlp', [
...proxyArgs, ...ytdlpArgs,
url,
'--max-filesize', `${maxfilesize / 1024}k`,
'-o', path.join(cfg.paths.tmp, `${uuid}.%(ext)s`),
'--print', 'after_move:filepath'
], (line) => {
const patch = parseYtdlpLine(line);
if (patch) setProgress(jobId, patch);
})).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
])).stdout.trim().split('\n').map(l => l.trim()).filter(l => l.length > 0).pop();
} catch (err2) {
console.warn(`[UPLOAD-URL-ASYNC] Stage 2 failed: ${err2.message}`);
console.log(`[UPLOAD-URL-ASYNC] Starting Stage 3 (curl) fallback for ${url}`);
setProgress(jobId, { stage: 'downloading', percent: 0, speed: null, eta: null });
const fallbackTmp = path.join(cfg.paths.tmp, `${uuid}.tmp`);
let referer = url;
try {
@@ -666,8 +467,6 @@ export default router => {
if (!source || source.match(/larger than/)) throw new Error('File too large or download failed');
setProgress(jobId, { stage: 'analyzing', percent: 100, speed: null, eta: null });
const { stat } = await import('fs/promises');
const size = (await stat(source)).size;
if (size > maxfilesize) {
@@ -709,9 +508,7 @@ export default router => {
const repostSum = await queue.checkrepostsum(checksum);
if (repostSum) {
await fs.unlink(source).catch(() => {});
setProgress(jobId, { stage: 'error', done: true, error: 'This file already exists', itemId: repostSum });
cleanupJob(jobId);
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ jobId, url, msg: 'This file already exists' })})`;
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${repostSum}, ${db.json({ url, msg: 'Duplicate detected (Checksum)' })})`;
return;
}
}
@@ -723,9 +520,7 @@ export default router => {
const phashMatch = await queue.checkrepostphash(phash);
if (phashMatch) {
await fs.unlink(source).catch(() => {});
setProgress(jobId, { stage: 'error', done: true, error: 'This file is a visual duplicate', itemId: phashMatch });
cleanupJob(jobId);
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ jobId, url, msg: 'This file is a visual duplicate' })})`;
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${phashMatch}, ${db.json({ url, msg: 'Visual duplicate detected (PHash)' })})`;
return;
}
}
@@ -746,12 +541,10 @@ export default router => {
} catch (e) { }
}
}
setProgress(jobId, { stage: 'saving', percent: 100 });
if (!linkedToExistingUrl) await fs.copyFile(source, path.join(destDir, filename));
await fs.unlink(source).catch(() => { });
const insertChecksum = getBypassDuplicateCheck() ? `${checksum}_bypass_${Date.now()}` : checksum;
const targetExpiresAt = calculateExpiresAt(req.post?.expiry || req.headers['x-upload-expiry'] || req.post?.expires_at, nowStamp);
const [{ id: itemid }] = await db`
insert into items ${db({
@@ -764,14 +557,10 @@ export default router => {
username: session.user,
userchannel: 'web',
usernetwork: 'web',
stamp: nowStamp,
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
title: title,
visibility: targetVisibility,
slug: itemSlug,
expires_at: targetExpiresAt
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'title', 'visibility', 'slug', 'expires_at')}
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
RETURNING id
`;
@@ -781,7 +570,7 @@ export default router => {
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
await queue.genBlurredThumbnail(itemid, isApprovalRequired);
if (effectiveRating === 'nsfw' || effectiveRating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
} catch (err) {
const tDir = isApprovalRequired ? path.join(cfg.paths.pending, 't') : cfg.paths.t;
await queue.spawn('magick', ['-size', '128x128', 'xc:#1a1a1a', path.join(tDir, `${itemid}.webp`)]).catch(() => {});
@@ -806,8 +595,8 @@ export default router => {
}
}
// Broadcast new_item event for live grid updates (only if auto-approved and public)
if (!isApprovalRequired && targetVisibility === 0) {
// Broadcast new_item event for live grid updates (only if auto-approved)
if (!isApprovalRequired) {
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: itemid,
@@ -823,8 +612,8 @@ export default router => {
}
}
// Push to Matrix Channel (only if auto-approved and public)
if (!isApprovalRequired && targetVisibility === 0) {
// Push to Matrix Channel (only if auto-approved)
if (!isApprovalRequired) {
try {
const self = router.self;
const matrixCfg = cfg.clients?.find(c => c.type === 'matrix');
@@ -843,16 +632,12 @@ export default router => {
}
// Completion notification
setProgress(jobId, { stage: 'done', percent: 100, done: true, error: null, itemId: itemid });
cleanupJob(jobId);
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_success', 0, ${itemid}, ${db.json({ jobId })})`;
await db`INSERT INTO notifications (user_id, type, reference_id, item_id) VALUES (${session.id}, 'upload_success', 0, ${itemid})`;
} catch (err) {
console.error('[UPLOAD-URL-ASYNC] Final Error:', err);
// Error notification
setProgress(jobId, { stage: 'error', done: true, error: sanitizeError(err) });
cleanupJob(jobId);
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ jobId, url, msg: sanitizeError(err) })})`;
await db`INSERT INTO notifications (user_id, type, reference_id, item_id, data) VALUES (${session.id}, 'upload_error', 0, ${null}, ${db.json({ url, msg: sanitizeError(err) })})`;
}
})();
}

View File

@@ -208,7 +208,7 @@ export default (router, tpl) => {
});
// DELETE /api/chat/:id — admin: delete a single message
router.delete(/^\/api\/chat\/(?<id>\d+)/, async (req, res) => {
router.delete(/\/api\/chat\/(?<id>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_global_chat) {
return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
}

View File

@@ -1,11 +1,9 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { promises as fs } from "fs";
import { applyWordFilter } from "../wordfilter.mjs";
import path from "path";
export default (router, tpl) => {
@@ -44,24 +42,6 @@ export default (router, tpl) => {
if (sub.length > 0) is_subscribed = true;
}
// Fill in per-user poll votes
if (req.session && cfg.websrv.enable_comment_polls) {
const pollComments = comments.filter(c => c.poll);
if (pollComments.length > 0) {
const pollIds = pollComments.map(c => c.poll.id);
try {
const votes = await db`
SELECT poll_id, option_id FROM comment_poll_votes
WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id}
`;
const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id]));
for (const c of pollComments) {
if (c.poll) c.poll.user_vote_option_id = voteMap.get(c.poll.id) || null;
}
} catch (e) { /* graceful */ }
}
}
// Transform for frontend if needed, or send as is
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -156,8 +136,7 @@ export default (router, tpl) => {
}
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
/* <mode-override> */
// prioritize query mode (from AJAX) over session default
@@ -166,17 +145,10 @@ export default (router, tpl) => {
mode = parseInt(req.url.qs.mode);
}
/* </mode-override> */
// Multi-rating cookie support (same logic as other routes)
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
// Build mode SQL — replace items.id alias with i.id used in the activity query
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
const comments = await db`
SELECT c.*, i.mime, i.id as item_id, i.slug as item_slug
SELECT c.*, i.mime, i.id as item_id
FROM comments c
LEFT JOIN items i ON c.item_id = i.id
WHERE c.user_id = ${userId} AND c.is_deleted = false
@@ -207,123 +179,13 @@ export default (router, tpl) => {
// Let's modify comments content in-place (or new array) before mapping
const mentionsProcessed = await f0cklib.processMentions(comments);
let processedComments = mentionsProcessed.map(c => {
const processedComments = mentionsProcessed.map(c => {
return {
...c,
item_slug: getEnableItemSlugs() ? c.item_slug : null,
content: c.content
};
});
// Fetch file attachments for all fetched comments
if (processedComments.length > 0) {
const commentIds = processedComments.map(c => c.id);
try {
const files = await db`
SELECT id, comment_id, dest, mime, size, original_filename
FROM comment_files
WHERE comment_id = ANY(${commentIds}::int[])
ORDER BY id ASC
`;
const filesMap = new Map();
for (const f of files) {
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
filesMap.get(f.comment_id).push(f);
}
for (const c of processedComments) {
c.files = filesMap.get(c.id) || [];
}
} catch (e) {
for (const c of processedComments) c.files = [];
}
// Fetch poll data for comments
if (cfg.websrv.enable_comment_polls) {
try {
const commentIds = processedComments.map(c => c.id);
const pollRows = await db`
SELECT
cp.id as poll_id,
cp.comment_id,
cp.question,
cp.expires_at,
COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg(
json_build_object(
'id', cpo.id,
'text', cpo.text,
'sort_order', cpo.sort_order,
'vote_count', COALESCE(vote_counts.cnt, 0)
) ORDER BY cpo.sort_order ASC, cpo.id ASC
) AS options,
COALESCE(SUM(vote_counts.cnt), 0)::int AS total_votes
FROM comment_polls cp
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
LEFT JOIN (
SELECT option_id, COUNT(*) AS cnt
FROM comment_poll_votes
GROUP BY option_id
) vote_counts ON vote_counts.option_id = cpo.id
WHERE cp.comment_id = ANY(${commentIds}::int[])
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
`;
// For non-anonymous polls, fetch voter names
const nonAnonIds = pollRows.filter(p => !p.is_anonymous).map(p => p.poll_id);
let votersByOption = new Map();
if (nonAnonIds.length > 0) {
const voterRows = await db`
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
FROM comment_poll_votes cpv
JOIN public."user" u ON u.id = cpv.user_id
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
WHERE cpv.poll_id = ANY(${nonAnonIds}::int[])
`;
for (const v of voterRows) {
if (!votersByOption.has(v.option_id)) votersByOption.set(v.option_id, []);
votersByOption.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
}
}
const pollMap = new Map();
for (const p of pollRows) {
const options = p.is_anonymous
? p.options
: p.options.map(o => ({ ...o, voters: votersByOption.get(o.id) || [] }));
pollMap.set(p.comment_id, {
id: p.poll_id,
question: p.question,
expires_at: p.expires_at,
is_anonymous: p.is_anonymous,
options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: null
});
}
// Fill in per-user poll votes if logged in
if (req.session && pollRows.length > 0) {
const pollIds = pollRows.map(p => p.poll_id);
try {
const votes = await db`
SELECT poll_id, option_id FROM comment_poll_votes
WHERE poll_id = ANY(${pollIds}::int[]) AND user_id = ${req.session.id}
`;
const voteMap = new Map(votes.map(v => [v.poll_id, v.option_id]));
for (const [comment_id, poll] of pollMap.entries()) {
poll.user_vote_option_id = voteMap.get(poll.id) || null;
}
} catch (e) { /* graceful */ }
}
for (const c of processedComments) {
c.poll = pollMap.get(c.id) || null;
}
} catch (e) {
console.error('[USER_COMMENTS] Poll fetch error:', e.message);
for (const c of processedComments) c.poll = null;
}
} else {
for (const c of processedComments) c.poll = null;
}
}
if (isJson) {
return res.reply({
headers: { 'Content-Type': 'application/json; charset=utf-8' },
@@ -390,20 +252,14 @@ export default (router, tpl) => {
const body = req.post || {};
const item_id = parseInt(body.item_id, 10);
const parent_id = body.parent_id ? parseInt(body.parent_id, 10) : null;
let content = body.content || '';
content = await applyWordFilter(content);
const content = body.content;
const video_time = (body.video_time !== undefined && body.video_time !== '' && !isNaN(parseFloat(body.video_time)))
? parseFloat(body.video_time)
: null;
if (cfg.main.development) console.log("DEBUG: Posting comment:", { item_id, parent_id, content: content?.substring(0, 20) });
const fileIdsRaw = body.file_ids || '';
const fileIds = fileIdsRaw ? fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0) : [];
const hasPoll = body.has_poll === '1' || body.has_poll === 'true';
if ((!content || !content.trim()) && fileIds.length === 0 && !hasPoll) {
if (!content || !content.trim()) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty comment" }) });
}
@@ -425,7 +281,7 @@ export default (router, tpl) => {
item_id,
user_id: req.session.id,
parent_id: parent_id || null,
content: content || ''
content: content
};
if (video_time !== null) insertData.video_time = video_time;
@@ -437,7 +293,6 @@ export default (router, tpl) => {
const commentId = parseInt(newComment[0].id, 10);
// Link uploaded files to this comment (if any)
let activityFiles = [];
const fileIdsRaw = body.file_ids || '';
if (fileIdsRaw) {
const fileIds = fileIdsRaw.split(',').map(id => parseInt(id, 10)).filter(id => !isNaN(id) && id > 0);
@@ -451,13 +306,6 @@ export default (router, tpl) => {
AND user_id = ${req.session.id}
AND comment_id IS NULL
`;
// Fetch the linked files to send with live notification and post response
activityFiles = await db`
SELECT id, comment_id, dest, mime, size, original_filename
FROM comment_files
WHERE comment_id = ${commentId}
ORDER BY id ASC
`;
} catch (err) {
console.error('[COMMENTS] Failed to link files to comment:', err);
}
@@ -570,25 +418,8 @@ export default (router, tpl) => {
}
// Notify for live updates
// Fetch the trigger-updated xd_score and the item rating tag from the DB (trigger runs synchronously before we get here)
const itemQuery = await db`
SELECT
i.slug,
i.xd_score,
COALESCE(i.visibility, 0) as visibility,
(SELECT ta.tag_id FROM tags_assign ta
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id
FROM items i WHERE i.id = ${item_id}
`;
const xdRow = itemQuery[0];
const ratingTagId = itemQuery[0]?.rating_tag_id;
let ratingLabel = '?';
let ratingClass = 'untagged';
if (ratingTagId == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; }
else if (ratingTagId == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; }
else if (ratingTagId == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; }
// Fetch the trigger-updated xd_score from the DB (trigger runs synchronously before we get here)
const [xdRow] = await db`SELECT xd_score FROM items WHERE id = ${item_id}`;
// Truncate body to 500 chars: PostgreSQL NOTIFY has an 8000-byte hard limit.
// Large comments would silently drop the notification. The client fetches
// the full content via _silentSync; the NOTIFY only needs to trigger the update.
@@ -597,7 +428,6 @@ export default (router, tpl) => {
type: 'comment',
id: commentId,
item_id: item_id,
item_slug: (getEnableItemSlugs() && itemQuery[0]?.slug) ? itemQuery[0].slug : null,
parent_id: parent_id || null,
body: notifyBody,
username: req.session.user,
@@ -608,37 +438,20 @@ export default (router, tpl) => {
username_color: req.session.username_color,
display_name: req.session.display_name || null,
xd_score: xdRow?.xd_score ?? null,
video_time: newComment[0]?.video_time ?? null,
files: activityFiles
video_time: newComment[0]?.video_time ?? null
};
// 1. Thread live update
db.notify('comments', JSON.stringify(livePayload));
// 2. Sidebar activity update (only for public items)
const itemVisibility = itemQuery[0]?.visibility ?? 0;
if (itemVisibility === 0) {
const activityIsLong = content.length > 120
|| content.split('\n').length > 2
|| activityFiles.length > 0
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(content);
db.notify('activity', JSON.stringify({
user_id: req.session.id,
item_id: item_id,
type: 'comment',
body: notifyBody,
id: commentId,
item_rating_class: ratingClass,
item_rating_label: ratingLabel,
avatar: req.session.avatar,
avatar_file: req.session.avatar_file,
username: req.session.user,
username_color: req.session.username_color,
display_name: req.session.display_name || null,
files: activityFiles,
is_long: activityIsLong
}));
}
// 2. Sidebar activity update
db.notify('activity', JSON.stringify({
user_id: req.session.id,
item_id: item_id,
type: 'comment',
body: notifyBody,
id: commentId
}));
// Automatically subscribe user to the thread
const subResult = await db`
@@ -653,11 +466,7 @@ export default (router, tpl) => {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({
success: true,
comment: {
...newComment[0],
content,
files: activityFiles
},
comment: newComment[0],
xd_score: xdRow?.xd_score ?? null,
is_new_subscription
})
@@ -710,14 +519,8 @@ export default (router, tpl) => {
const comment = await db`SELECT content, item_id, user_id FROM comments WHERE id = ${commentId}`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: "Not found" }) });
const { getAllowCommentDeletion } = await import("../settings.mjs");
const canDeleteOwn = getAllowCommentDeletion();
const isOwner = comment[0].user_id === req.session.id;
if (!req.session.admin && !req.session.is_moderator) {
if (!canDeleteOwn || !isOwner) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
if (!req.session.admin && !req.session.is_moderator && comment[0].user_id !== req.session.id) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
// Log all deletions in audit log
@@ -888,8 +691,7 @@ export default (router, tpl) => {
const commentId = req.params.id;
const body = req.post || {};
let content = body.content;
content = await applyWordFilter(content);
const content = body.content;
if (!content || !content.trim()) {
return res.reply({ body: JSON.stringify({ success: false, message: "Empty content" }) });
@@ -994,16 +796,8 @@ export default (router, tpl) => {
mode = parseInt(req.url.qs.mode);
}
/* </mode-override> */
// Multi-rating cookie support (same logic as other routes)
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
const multiRatingSQL = (ratingsArr && ratingsArr.length > 0) ? lib.getMultiRatingMode(ratingsArr) : null;
// Build mode SQL — replace items.id alias with i.id used in the activity query
const modequery = (multiRatingSQL ?? lib.getMode(mode)).replace(/items\.id/g, 'i.id');
const globalfilterTags = cfg.websrv.public_nsfw ? (cfg.nsfp || []).filter(id => id !== 2) : (cfg.nsfp || []);
const globalfilter = globalfilterTags.length ? globalfilterTags.map(n => `tag_id = ${n}`).join(' or ') : null;
const modequery = lib.getMode(mode).replace(/items\.id/g, 'i.id');
const globalfilter = cfg.nsfp.map(n => `tag_id = ${n}`).join(' or ');
const excludedTags = req.session ? (req.session.excluded_tags || []) : [];
const comments = await db`
@@ -1011,11 +805,7 @@ export default (router, tpl) => {
c.*,
i.mime,
i.id as item_id,
i.slug as item_slug,
i.dest as item_dest,
(SELECT ta.tag_id FROM tags_assign ta
WHERE ta.item_id = i.id AND ta.tag_id = ANY(${[1, 2, cfg.nsfl_tag_id || 3]}::int[])
ORDER BY ta.tag_id LIMIT 1) AS rating_tag_id,
u.user as username,
uo.avatar,
uo.avatar_file,
@@ -1028,7 +818,6 @@ export default (router, tpl) => {
WHERE c.is_deleted = false
AND i.active = true
AND i.is_deleted = false
AND COALESCE(i.visibility, 0) = 0
AND ${db.unsafe(modequery)}
${!req.session && globalfilter ? db`and not exists (select 1 from tags_assign where item_id = i.id and (${db.unsafe(globalfilter)}))` : db``}
${excludedTags.length > 0 ? db`and not exists (select 1 from tags_assign where item_id = i.id and tag_id = any(${excludedTags}::int[]))` : db``}
@@ -1037,101 +826,11 @@ export default (router, tpl) => {
LIMIT ${limit} OFFSET ${offset}
`;
// Normalize item_slug based on getEnableItemSlugs()
const processedCommentsList = comments.map(c => ({
...c,
item_slug: getEnableItemSlugs() ? c.item_slug : null
}));
// Fetch comment file attachments
const filesMap = new Map();
if (processedCommentsList.length > 0) {
const commentIds = comments.map(c => c.id);
try {
const files = await db`
SELECT id, comment_id, dest, mime, size, original_filename
FROM comment_files
WHERE comment_id = ANY(${commentIds}::int[])
ORDER BY id ASC
`;
for (const f of files) {
if (!filesMap.has(f.comment_id)) filesMap.set(f.comment_id, []);
filesMap.get(f.comment_id).push(f);
}
} catch (e) {
console.error('[ACTIVITY] Failed to fetch comment files:', e);
}
}
// Fetch poll data for these comments
const pollMap = new Map();
if (comments.length > 0 && cfg.websrv.enable_comment_polls) {
try {
const commentIds = comments.map(c => c.id);
const pollRows = await db`
SELECT
cp.id as poll_id,
cp.comment_id,
cp.question,
cp.expires_at,
COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg(
json_build_object(
'id', cpo.id,
'text', cpo.text,
'sort_order', cpo.sort_order,
'vote_count', COALESCE(vc.cnt, 0)
) ORDER BY cpo.sort_order ASC, cpo.id ASC
) AS options,
COALESCE(SUM(vc.cnt), 0)::int AS total_votes
FROM comment_polls cp
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id
WHERE cp.comment_id = ANY(${commentIds}::int[])
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
`;
for (const p of pollRows) {
pollMap.set(p.comment_id, {
id: p.poll_id,
question: p.question,
expires_at: p.expires_at,
is_anonymous: p.is_anonymous,
options: p.options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: null
});
}
} catch (e) {
console.error('[ACTIVITY] Failed to fetch polls:', e.message);
}
}
const processedComments = comments.map(c => {
let ratingLabel = '?';
let ratingClass = 'untagged';
if (c.rating_tag_id == 1) { ratingLabel = 'SFW'; ratingClass = 'sfw'; }
else if (c.rating_tag_id == 2) { ratingLabel = 'NSFW'; ratingClass = 'nsfw'; }
else if (c.rating_tag_id == (cfg.nsfl_tag_id || 3)) { ratingLabel = 'NSFL'; ratingClass = 'nsfl'; }
const commentContent = (c.content || '').trim();
const commentFiles = filesMap.get(c.id) || [];
// Compute overflow hint: true if content is likely taller than the 80px clamp.
// ~120 chars ≈ 2-3 wrapped lines in the sidebar; any newlines > 2 or file
// attachments (images/video) will also push height above the limit.
const isLong = commentContent.length > 120
|| commentContent.split('\n').length > 2
|| commentFiles.length > 0
|| /\[(video|audio|youtube|img)\]|!\[|https?:\/\//i.test(commentContent);
return {
...c,
content: commentContent,
username_color: c.username_color,
item_rating_class: ratingClass,
item_rating_label: ratingLabel,
files: commentFiles,
poll: pollMap.get(c.id) || null,
is_long: isLong
content: (c.content || '').trim(),
username_color: c.username_color
// created_at stays as the raw ISO timestamp so the frontend f0ckTimeAgo can localize it
};
});
@@ -1191,259 +890,5 @@ export default (router, tpl) => {
}
});
// ──────────────────────────────────────────────────────────────────────────
// Poll creation — called after a comment is inserted (internal helper)
// ──────────────────────────────────────────────────────────────────────────
const createPollForComment = async (commentId, pollData) => {
if (!cfg.websrv.enable_comment_polls) return null;
const { question, options, is_anonymous } = pollData || {};
if (!question || !question.trim()) return null;
if (!Array.isArray(options) || options.length < 2) return null;
const cleanOptions = options.map(o => (typeof o === 'string' ? o : String(o)).trim()).filter(Boolean);
if (cleanOptions.length < 2 || cleanOptions.length > 10) return null;
const anonymous = is_anonymous !== false; // default true
const [poll] = await db`
INSERT INTO comment_polls (comment_id, question, is_anonymous)
VALUES (${commentId}, ${question.trim()}, ${anonymous})
RETURNING id, is_anonymous
`;
const pollId = poll.id;
for (let i = 0; i < cleanOptions.length; i++) {
await db`
INSERT INTO comment_poll_options (poll_id, text, sort_order)
VALUES (${pollId}, ${cleanOptions[i]}, ${i})
`;
}
const optRows = await db`SELECT id, text, sort_order FROM comment_poll_options WHERE poll_id = ${pollId} ORDER BY sort_order ASC`;
return {
id: pollId,
question: question.trim(),
is_anonymous: poll.is_anonymous,
options: optRows.map(o => ({ id: o.id, text: o.text, sort_order: o.sort_order, vote_count: 0, voters: [] })),
total_votes: 0,
user_vote_option_id: null
};
};
// Patch POST /api/comments to support optional poll payload
// We cannot re-define the same route, so we intercept via a pre-middleware trick.
// Instead we add a dedicated endpoint that the frontend always uses for polls.
// POST /api/polls/:commentId — attach a poll to an existing just-created comment
// (frontend calls this immediately after posting the comment)
router.post(/\/api\/polls\/attach\/(?<commentId>\d+)/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Polls disabled' }) });
const commentId = req.params.commentId;
const body = req.post || {};
// Verify this comment belongs to the logged-in user and has no poll yet
const comment = await db`SELECT id, user_id FROM comments WHERE id = ${commentId} AND is_deleted = false LIMIT 1`;
if (!comment.length) return res.reply({ code: 404, body: JSON.stringify({ success: false, message: 'Comment not found' }) });
if (comment[0].user_id !== req.session.id && !req.session.admin && !req.session.is_moderator) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
}
const existing = await db`SELECT id FROM comment_polls WHERE comment_id = ${commentId} LIMIT 1`;
if (existing.length) return res.reply({ code: 409, body: JSON.stringify({ success: false, message: 'Poll already exists' }) });
let pollData;
try {
pollData = typeof body.poll === 'string' ? JSON.parse(body.poll) : body.poll;
} catch (e) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll JSON' }) });
}
try {
const poll = await createPollForComment(parseInt(commentId, 10), pollData);
if (!poll) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid poll data (need question + 2-10 options)' }) });
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, poll }) });
} catch (err) {
console.error('[POLLS] createPollForComment error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: 'Database error' }) });
}
});
// GET /api/polls/:pollId — fetch poll with current user's vote
router.get(/\/api\/polls\/(?<pollId>\d+)/, async (req, res) => {
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
try {
const pollRows = await db`
SELECT
cp.id as poll_id, cp.comment_id, cp.question, cp.expires_at, COALESCE(cp.is_anonymous, true) as is_anonymous,
json_agg(
json_build_object(
'id', cpo.id, 'text', cpo.text, 'sort_order', cpo.sort_order,
'vote_count', COALESCE(vc.cnt, 0)
) ORDER BY cpo.sort_order ASC, cpo.id ASC
) AS options,
COALESCE(SUM(vc.cnt), 0)::int AS total_votes
FROM comment_polls cp
JOIN comment_poll_options cpo ON cpo.poll_id = cp.id
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes GROUP BY option_id) vc ON vc.option_id = cpo.id
WHERE cp.id = ${pollId}
GROUP BY cp.id, cp.comment_id, cp.question, cp.expires_at, cp.is_anonymous
`;
if (!pollRows.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const p = pollRows[0];
let userVoteOptionId = null;
if (req.session) {
const vote = await db`SELECT option_id FROM comment_poll_votes WHERE poll_id = ${pollId} AND user_id = ${req.session.id} LIMIT 1`;
if (vote.length) userVoteOptionId = vote[0].option_id;
}
// If not anonymous, attach voter usernames to each option
let options = p.options;
if (!p.is_anonymous) {
const voterRows = await db`
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
FROM comment_poll_votes cpv
JOIN public."user" u ON u.id = cpv.user_id
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
WHERE cpv.poll_id = ${pollId}
`;
const voterMap = new Map();
for (const v of voterRows) {
if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []);
voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
}
options = options.map(o => ({ ...o, voters: voterMap.get(o.id) || [] }));
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
success: true,
poll: {
id: p.poll_id,
comment_id: p.comment_id,
question: p.question,
expires_at: p.expires_at,
is_anonymous: p.is_anonymous,
options,
total_votes: parseInt(p.total_votes) || 0,
user_vote_option_id: userVoteOptionId
}
})
});
} catch (err) {
console.error('[POLLS] GET error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// POST /api/polls/:pollId/vote — cast or change vote
router.post(/\/api\/polls\/(?<pollId>\d+)\/vote/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
const body = req.post || {};
const optionId = parseInt(body.option_id, 10);
if (!optionId) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Missing option_id' }) });
try {
// Verify option belongs to poll
const opt = await db`SELECT id FROM comment_poll_options WHERE id = ${optionId} AND poll_id = ${pollId} LIMIT 1`;
if (!opt.length) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'Invalid option' }) });
// Check expiry
const poll = await db`SELECT expires_at FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
if (poll[0].expires_at && new Date(poll[0].expires_at) < new Date()) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Poll has expired' }) });
}
// Upsert vote (change allowed) — manual check avoids needing a unique constraint
const existing = await db`
SELECT poll_id FROM comment_poll_votes
WHERE poll_id = ${pollId} AND user_id = ${req.session.id}
LIMIT 1
`;
if (existing.length) {
await db`
UPDATE comment_poll_votes
SET option_id = ${optionId}, created_at = now()
WHERE poll_id = ${pollId} AND user_id = ${req.session.id}
`;
} else {
await db`
INSERT INTO comment_poll_votes (poll_id, option_id, user_id)
VALUES (${pollId}, ${optionId}, ${req.session.id})
`;
}
// Return updated tally
const pollMeta = await db`SELECT COALESCE(is_anonymous, true) as is_anonymous FROM comment_polls WHERE id = ${pollId} LIMIT 1`;
const isAnon = pollMeta.length ? pollMeta[0].is_anonymous : true;
const rows = await db`
SELECT cpo.id, cpo.text, cpo.sort_order, COALESCE(vc.cnt, 0)::int AS vote_count
FROM comment_poll_options cpo
LEFT JOIN (SELECT option_id, COUNT(*) AS cnt FROM comment_poll_votes WHERE poll_id = ${pollId} GROUP BY option_id) vc ON vc.option_id = cpo.id
WHERE cpo.poll_id = ${pollId}
ORDER BY cpo.sort_order ASC
`;
const totalVotes = rows.reduce((s, r) => s + r.vote_count, 0);
let options = rows;
if (!isAnon) {
const voterRows = await db`
SELECT cpv.option_id, u."user" as username, uo.avatar, uo.avatar_file
FROM comment_poll_votes cpv
JOIN public."user" u ON u.id = cpv.user_id
LEFT JOIN public.user_options uo ON uo.user_id = cpv.user_id
WHERE cpv.poll_id = ${pollId}
`;
const voterMap = new Map();
for (const v of voterRows) {
if (!voterMap.has(v.option_id)) voterMap.set(v.option_id, []);
voterMap.get(v.option_id).push({ username: v.username, avatar: v.avatar, avatar_file: v.avatar_file });
}
options = rows.map(o => ({ ...o, voters: voterMap.get(o.id) || [] }));
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, is_anonymous: isAnon, options, total_votes: totalVotes, user_vote_option_id: optionId })
});
} catch (err) {
console.error('[POLLS] vote error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// DELETE /api/polls/:pollId — admin/mod or creator can delete
router.post(/\/api\/polls\/(?<pollId>\d+)\/delete/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
if (!cfg.websrv.enable_comment_polls) return res.reply({ code: 403, body: JSON.stringify({ success: false }) });
const pollId = req.params.pollId;
try {
const poll = await db`
SELECT cp.id, cp.comment_id, c.user_id
FROM comment_polls cp
JOIN comments c ON c.id = cp.comment_id
WHERE cp.id = ${pollId} LIMIT 1
`;
if (!poll.length) return res.reply({ code: 404, body: JSON.stringify({ success: false }) });
const isCreator = poll[0].user_id === req.session.id;
if (!isCreator && !req.session.admin && !req.session.is_moderator) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Forbidden' }) });
}
await db`DELETE FROM comment_polls WHERE id = ${pollId}`;
// Notify live update
db.notify('comments', JSON.stringify({ type: 'poll_deleted', poll_id: pollId, comment_id: poll[0].comment_id }));
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
} catch (err) {
console.error('[POLLS] delete error:', err);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
return router;
};

View File

@@ -6,7 +6,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);
@@ -24,312 +23,15 @@ export default (router, tpl) => {
});
});
// List all emojis — returns both a flat list and a grouped-by-pack structure
// List all emojis (Public)
router.get('/api/v2/emojis', async (req, res) => {
try {
// Try the full pack-aware query first; fall back if pack_id column doesn't exist yet
let emojis;
let hasPacks = true;
try {
emojis = await db`
SELECT e.id, e.name, e.url, e.pack_id,
p.name as pack_name, p.tg_name, p.tg_title, p.thumb_url as pack_thumb_url
FROM custom_emojis e
LEFT JOIN sticker_packs p ON p.id = e.pack_id
ORDER BY e.pack_id NULLS FIRST, e.id ASC
`;
} catch (colErr) {
// column pack_id or thumb_url may not exist — try without thumb_url, then flat
try {
emojis = await db`
SELECT e.id, e.name, e.url, e.pack_id,
p.name as pack_name, p.tg_name, p.tg_title, NULL as pack_thumb_url
FROM custom_emojis e
LEFT JOIN sticker_packs p ON p.id = e.pack_id
ORDER BY e.pack_id NULLS FIRST, e.id ASC
`;
} catch (_) {
// pack_id column missing entirely — serve flat list
console.warn('[EMOJIS] pack_id column missing, serving flat list');
hasPacks = false;
emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY id ASC`;
}
}
// Flat list (backwards compat)
const flat = emojis.map(e => ({ id: e.id, name: e.name, url: e.url, pack_id: e.pack_id ?? null }));
if (!hasPacks) {
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, emojis: flat, packs: [] })
});
}
// Grouped by pack
const packsMap = new Map();
// "No pack" group for standalone emojis
packsMap.set(null, { id: null, name: 'Custom Emojis', tg_name: null, emojis: [] });
for (const e of emojis) {
if (e.pack_id && !packsMap.has(e.pack_id)) {
packsMap.set(e.pack_id, {
id: e.pack_id,
name: e.pack_name || e.tg_title || `Pack #${e.pack_id}`,
tg_name: e.tg_name,
thumb_url: e.pack_thumb_url || null,
emojis: []
});
}
const packKey = e.pack_id ?? null;
packsMap.get(packKey).emojis.push({ id: e.id, name: e.name, url: e.url });
}
// Filter out empty groups
const packs = [...packsMap.values()].filter(p => p.emojis.length > 0);
const emojis = await db`SELECT id, name, url FROM custom_emojis ORDER BY name ASC`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, emojis: flat, packs })
body: JSON.stringify({ success: true, emojis })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
// List sticker packs (Admin)
router.get('/api/v2/admin/sticker-packs', async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
try {
let packs;
try {
packs = await db`
SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, sp.thumb_url, sp.sticker_count, sp.created_at,
count(e.id)::int as actual_count
FROM sticker_packs sp
LEFT JOIN custom_emojis e ON e.pack_id = sp.id
GROUP BY sp.id
ORDER BY sp.created_at DESC
`;
} catch (_) {
// thumb_url column may not exist yet — fall back without it
packs = await db`
SELECT sp.id, sp.name, sp.tg_name, sp.tg_title, NULL as thumb_url, sp.sticker_count, sp.created_at,
count(e.id)::int as actual_count
FROM sticker_packs sp
LEFT JOIN custom_emojis e ON e.pack_id = sp.id
GROUP BY sp.id
ORDER BY sp.created_at DESC
`;
}
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, packs })
});
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
// Delete a sticker pack and all its emojis (Admin)
router.delete(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)/, async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
const csrfToken = req.headers['x-csrf-token'];
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
}
const id = req.params.id;
try {
// Fetch emoji file URLs before deletion for filesystem cleanup
const emojiFiles = await db`SELECT url FROM custom_emojis WHERE pack_id = ${id}`;
await db`DELETE FROM custom_emojis WHERE pack_id = ${id}`;
await db`DELETE FROM sticker_packs WHERE id = ${id}`;
// Clean up local files
for (const e of emojiFiles) {
if (e.url && e.url.startsWith('/s/emojis/')) {
const filename = path.basename(e.url);
await fs.unlink(path.join(cfg.paths.emojis, filename)).catch(() => {});
}
}
await db`NOTIFY emojis_updated, '{}'`;
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
});
// Rename a sticker pack (Admin) — optionally backfills emoji names and comment references
router.post(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)\/rename/, async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
const csrfToken = req.headers['x-csrf-token'];
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
}
const id = req.params.id;
let body = {};
try {
const raw = req.body || req.post || {};
body = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (_) {}
const newName = (body.name || '').trim();
const backfill = body.backfill === true;
if (!newName) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name is required' }) });
}
try {
if (!backfill) {
// Simple rename only
await db`UPDATE sticker_packs SET name = ${newName} WHERE id = ${id}`;
await db`NOTIFY emojis_updated, '{}'`;
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
}
// ── Backfill: rename emojis + update comment references ──────────
// Build new slug from new display name (same logic as import)
const newSlug = newName
.replace(/[^a-z0-9]/gi, '_')
.toLowerCase()
.replace(/_+/g, '_')
.replace(/^_|_$/, '');
// Load all emojis in this pack
const emojis = await db`SELECT id, name FROM custom_emojis WHERE pack_id = ${id} ORDER BY id`;
// Build rename map: old_name → new_name
const renames = [];
for (const emoji of emojis) {
const suffixMatch = emoji.name.match(/_(\d+)$/);
if (!suffixMatch) continue; // skip non-standard names
const newEmojiName = newSlug + '_' + suffixMatch[1].padStart(3, '0');
if (newEmojiName !== emoji.name) {
renames.push({ id: emoji.id, oldName: emoji.name, newName: newEmojiName });
}
}
// Apply everything in a transaction
let emojiRenamed = 0;
let commentRowsUpdated = 0;
await db.begin(async sql => {
// Rename the pack
await sql`UPDATE sticker_packs SET name = ${newName} WHERE id = ${id}`;
for (const r of renames) {
// Check name conflict (another emoji already has this name)
const [conflict] = await sql`SELECT id FROM custom_emojis WHERE name = ${r.newName} AND id != ${r.id} LIMIT 1`;
if (conflict) {
console.warn(`[RENAME] Skipping ${r.oldName}${r.newName}: name conflict`);
continue;
}
// Rename the emoji
await sql`UPDATE custom_emojis SET name = ${r.newName} WHERE id = ${r.id}`;
emojiRenamed++;
// Replace :old_name: with :new_name: in all comments
const result = await sql`
UPDATE comments
SET content = REPLACE(content, ${':' + r.oldName + ':'}, ${':' + r.newName + ':'})
WHERE content LIKE ${'%:' + r.oldName + ':%'}
`;
commentRowsUpdated += result.count ?? 0;
}
});
await db`NOTIFY emojis_updated, '{}'`;
return res.reply({
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ success: true, emojiRenamed, commentRowsUpdated })
});
} catch (e) {
console.error('[RENAME PACK]', e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error: " + e.message }) });
}
});
// Set tab thumbnail for a sticker pack (Admin)
router.post(/\/api\/v2\/admin\/sticker-packs\/(?<id>\d+)\/thumb/, async (req, res) => {
if (!req.session || !req.session.admin) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Forbidden" }) });
}
const csrfToken = req.headers['x-csrf-token'];
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: "Invalid CSRF token" }) });
}
const id = req.params.id;
let body = {};
try {
const raw = req.body || req.post || {};
body = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch (_) {}
const thumbUrl = (body.thumb_url || '').trim();
// Only allow local emoji paths to prevent SSRF
if (!thumbUrl || !thumbUrl.startsWith('/s/emojis/')) {
return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'thumb_url must be a local /s/emojis/ path' }) });
}
try {
let finalThumbUrl = thumbUrl;
// For .webm stickers: extract first frame -> save as WebP so tabs always show a static image
if (thumbUrl.endsWith('.webm')) {
try {
const webmPath = path.join(cfg.paths.emojis, path.basename(thumbUrl));
const randSuffix = crypto.randomBytes(24).toString('hex');
const tmpPng = path.join(cfg.paths.emojis, randSuffix + '_tmp.png');
const webpPath = path.join(cfg.paths.emojis, randSuffix + '_thumb.webp');
// Step 1: ffmpeg extracts first frame to a temp PNG file
await execFile('ffmpeg', [
'-y', '-i', webmPath,
'-vframes', '1',
tmpPng
], { maxBuffer: 20 * 1024 * 1024 });
// Step 2: ImageMagick converts PNG -> WebP
try {
await execFile('magick', [tmpPng, '-quality', '85', webpPath], { env: magickEnv });
} finally {
await fs.unlink(tmpPng).catch(() => {});
}
const stat = await fs.stat(webpPath).catch(() => null);
if (stat && stat.size > 0) {
finalThumbUrl = '/s/emojis/' + randSuffix + '_thumb.webp';
console.log('[SET THUMB] Generated WebP thumbnail from .webm: ' + finalThumbUrl);
}
} catch (thumbErr) {
console.warn('[SET THUMB] Could not generate WebP from .webm:', thumbErr.message);
}
}
try {
await db`UPDATE sticker_packs SET thumb_url = ${finalThumbUrl} WHERE id = ${id}`;
} catch (colErr) {
// Column might not exist yet — add it and retry once
if (colErr.message && colErr.message.includes('thumb_url')) {
await db`ALTER TABLE public.sticker_packs ADD COLUMN IF NOT EXISTS thumb_url text`;
await db`UPDATE sticker_packs SET thumb_url = ${finalThumbUrl} WHERE id = ${id}`;
} else {
throw colErr;
}
}
await db`NOTIFY emojis_updated, '{}'`;
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, thumb_url: finalThumbUrl }) });
} catch (e) {
console.error(e);
return res.reply({ code: 500, body: JSON.stringify({ success: false, message: "Database error" }) });
}
@@ -430,61 +132,5 @@ export default (router, tpl) => {
}
});
// ── User Emoji Favorites ─────────────────────────────────────────
// Create table lazily on first use (idempotent)
const ensureFavTable = async () => {
await db`
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)
)`;
};
// GET /api/v2/user/emoji-favorites — returns logged-in user's favorites
router.get('/api/v2/user/emoji-favorites', lib.loggedin, async (req, res) => {
try {
await ensureFavTable();
const rows = await db`
SELECT emoji_name AS name, emoji_url AS url
FROM user_emoji_favorites
WHERE user_id = ${req.session.id}
ORDER BY created_at ASC
`;
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true, favorites: rows }) });
} catch (e) {
console.error('[EMOJI FAVS GET]', e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// POST /api/v2/user/emoji-favorites/toggle — add or remove a single favorite
router.post('/api/v2/user/emoji-favorites/toggle', lib.loggedin, async (req, res) => {
const csrfToken = req.headers['x-csrf-token'];
if (!req.session.csrf_token || !csrfToken || csrfToken !== req.session.csrf_token) {
return res.reply({ code: 403, body: JSON.stringify({ success: false, message: 'Invalid CSRF token' }) });
}
let body = {};
try { const raw = req.body || req.post || {}; body = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (_) {}
const { name, url, action } = body;
if (!name || !url) return res.reply({ code: 400, body: JSON.stringify({ success: false, message: 'name and url required' }) });
try {
await ensureFavTable();
if (action === 'remove') {
await db`DELETE FROM user_emoji_favorites WHERE user_id = ${req.session.id} AND emoji_name = ${name}`;
} else {
await db`INSERT INTO user_emoji_favorites (user_id, emoji_name, emoji_url) VALUES (${req.session.id}, ${name}, ${url}) ON CONFLICT (user_id, emoji_name) DO NOTHING`;
}
return res.reply({ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ success: true }) });
} catch (e) {
console.error('[EMOJI FAVS TOGGLE]', e);
return res.reply({ code: 500, body: JSON.stringify({ success: false }) });
}
});
// Edit emoji (Admin only) — handled by bypass middleware in index.mjs
return router;
};

View File

@@ -319,7 +319,7 @@ export default (router) => {
// POST /api/v2/scroller/rehost
// Downloads an external item and adds it to the platform
router.post(/^\/api\/v2\/scroller\/rehost\/?$/, lib.loggedin, async (req, res) => {
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc, original_filename } = req.post || {};
const { url, rating: initialRating, tags: tagsRaw, comment, is_oc } = req.post || {};
if (!url) return res.reply({ code: 400, body: JSON.stringify({ success: false, msg: 'URL is required' }) });
@@ -436,10 +436,8 @@ export default (router) => {
usernetwork: 'web',
stamp: ~~(Date.now() / 1000),
active: !isApprovalRequired,
is_oc: !!is_oc,
original_filename: original_filename || null,
slug: lib.generateSlug(11)
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc', 'original_filename', 'slug')}
is_oc: !!is_oc
}, 'src', 'dest', 'mime', 'size', 'checksum', 'phash', 'username', 'userchannel', 'usernetwork', 'stamp', 'active', 'is_oc')}
RETURNING id
`;
@@ -451,7 +449,7 @@ export default (router) => {
// Process thumbnail
try {
await queue.genThumbnail(filename, mime, itemid, url, isApprovalRequired);
await queue.genBlurredThumbnail(itemid, isApprovalRequired);
if (rating === 'nsfw' || rating === 'nsfl') await queue.genBlurredThumbnail(itemid, isApprovalRequired);
} catch (err) {
console.error('[REHOST] Thumbnail error:', err);
}

View File

@@ -94,7 +94,8 @@ export default (router, tpl) => {
const util = await import('util');
const execFilePromise = util.promisify(execFile);
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
// If only 1 or 2 items, just use what we have
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));

View File

@@ -2,7 +2,7 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import { createI18n } from "../i18n.mjs";
import { getDefaultFeedLayout } from "../settings.mjs";
const auth = async (req, res, next) => {
if (!req.session)
@@ -63,17 +63,12 @@ export default (router, tpl) => {
};
try {
const isRandom = req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings;
// In All mode (mode=3), ignore the ratings cookie — it would otherwise
// filter out e.g. NSFW uploads even though the user is in "All" mode.
const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
f0cks = await f0cklib.getf0cks({
user: user,
mode: req.mode,
ratings: ratingsArr,
mime: mime,
fav: false,
session: req.session,
session: !!req.session,
user_id: req.session?.id,
random: isRandom
});
@@ -89,19 +84,13 @@ export default (router, tpl) => {
if (!userData.is_ghost) {
try {
const isRandom = req.cookies.random_mode === '1';
const ratingsRaw = req.cookies.ratings;
// In All mode (mode=3), ignore the ratings cookie — a stale ratings cookie
// (e.g. only 'sfw') would cause NSFW favorites to show 0 on the profile.
const ratingsArr = (req.mode === 3) ? null : (ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null);
favs = await f0cklib.getf0cks({
user: user,
mode: req.mode,
ratings: ratingsArr,
mime: mime,
fav: true,
session: req.session,
session: !!req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
random: isRandom
});
if (favs && 'items' in favs) {
@@ -149,7 +138,7 @@ export default (router, tpl) => {
userData.timestamp = {
timeago: lib.timeAgo(userData.created_at, req.lang),
timefull: new Date(userData.created_at).toISOString()
timefull: userData.created_at
};
userData.age_days = Math.floor((Date.now() - new Date(userData.created_at).getTime()) / 86400000);
@@ -224,49 +213,29 @@ export default (router, tpl) => {
req.session.strict_mode = (req.query?.strict === '1' || req.url.qs?.strict === '1');
}
// Decode tag param once — browsers send title%3A... on hard reload, title:... via AJAX
const reqTag = req.params.tag ? decodeURIComponent(req.params.tag) : req.params.tag;
const data = await (req.params.itemid ? f0cklib.getf0ck : f0cklib.getf0cks)({
user: req.params.user,
tag: reqTag,
tag: req.params.tag,
mime: req.cookies.mime !== undefined ? req.cookies.mime : (req.query?.mime || req.url.qs?.mime || req.params.mime || null),
page: req.params.page,
itemid: req.params.itemid,
hall: req.params.hall,
fav: req.params.mode == 'favs',
mode: req.mode,
ratings: (() => { const r = req.cookies.ratings; return r ? decodeURIComponent(r).split(/[|,]/).filter(x => ['sfw','nsfw','nsfl','untagged'].includes(x)) : null; })(),
session: req.session,
session: !!req.session,
user_id: req.session?.id,
is_admin: req.session?.admin,
exclude: req.session ? (req.session.excluded_tags || []) : [],
url: decodeURIComponent(req.url.pathname || req.url),
strict: !!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode),
explicitStrict: !!(req.query?.strict || req.url.qs?.strict),
random: req.cookies.random_mode === '1',
minXdScore: req.params.itemid ? 0 : (req.url.qs?.min_xd !== undefined ? +req.url.qs.min_xd : (req.session?.min_xd_score || 0)),
lang: req.lang,
tagger: req.url.qs?.tagger || null
lang: req.lang
});
console.log(`[DEBUG] Checking strict mode: query=${req.query?.strict}, session=${req.session?.strict_mode}, effective=${!!(req.query?.strict || req.url.qs?.strict || req.session?.strict_mode)}`);
console.log(`[${new Date().toISOString()}] [ROUTE] Data fetch complete in ${Date.now() - tRouteStart}ms`);
if (!data.success) {
if (data.is_private && (data.message === 'private favorites' || req.params.mode === 'favs')) {
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({
code: 403,
body: tpl.render('error', {
message: tErr('profile.private_favorites'),
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false,
error_filter_hint: null,
error_filter_hint_link: null
}, req)
});
}
// For index/grid views with zero items (empty DB), render an empty grid instead of error
if (mode !== 'item') {
data.items = [];
@@ -275,7 +244,7 @@ export default (router, tpl) => {
data.success = true;
if (!data.link) {
if (req.params.hall) data.link = { main: '/h/' + encodeURIComponent(req.params.hall) + '/', path: 'p/', suffix: '' };
else if (reqTag) data.link = { main: '/tag/' + encodeURIComponent(reqTag) + '/', path: 'p/', suffix: '' };
else if (req.params.tag) data.link = { main: '/tag/' + encodeURIComponent(req.params.tag) + '/', path: 'p/', suffix: '' };
else data.link = { main: '/', path: 'p/', suffix: '' };
}
data.tmp = data.tmp || {};
@@ -283,26 +252,18 @@ export default (router, tpl) => {
const hallRow = await db`SELECT id, name, slug, description FROM halls WHERE slug = ${req.params.hall} LIMIT 1`;
data.tmp.hall = hallRow.length ? hallRow[0] : req.params.hall;
}
if (reqTag && !data.tmp.tag) data.tmp.tag = reqTag;
if (req.params.tag && !data.tmp.tag) data.tmp.tag = req.params.tag;
} else {
// Return 200 for filtered NSFW items (has item data) so Discord parses og:image
// Return 404 only for truly missing items
const statusCode = data.item ? 200 : 404;
const reqMode = req.mode ?? 0;
const modeLabels = { 0: 'SFW', 1: 'NSFW', 2: 'Untagged', 4: 'NSFL' };
const errorModeLabel = (req.session && reqMode !== 3) ? (modeLabels[reqMode] || null) : null;
const { t: tErr } = createI18n(req.session?.language || req.lang || 'en');
return res.reply({
code: statusCode,
body: tpl.render('error', {
message: tErr('error.post_not_visible'),
message: data.message,
item: data.item, // For OG meta tags on filtered NSFW items
domain: cfg.main.url.domain,
tmp: null,
session: req.session ? { ...req.session } : false,
error_mode_label: errorModeLabel,
error_filter_hint: errorModeLabel ? tErr('error.filter_hint', { mode: `<strong>${errorModeLabel}</strong>` }) : null,
error_filter_hint_link: tErr('error.filter_hint_link')
tmp: null
}, req)
});
}
@@ -360,6 +321,15 @@ export default (router, tpl) => {
// Only inject session for authenticated users to avoid showing member UI to guests
data.session = (req.session && req.session.user) ? { ...req.session } : false;
// Pre-compute feed layout class (avoids template engine issues with complex ternaries)
// Logic: use user's own feed_layout if they explicitly set one (> 0),
// otherwise fall back to the site-wide default set in the admin dashboard.
const userFeedLayout = data.session ? parseInt(data.session.feed_layout, 10) : 0;
const siteFeedLayout = getDefaultFeedLayout();
const rawFeedLayout = (userFeedLayout > 0) ? userFeedLayout : siteFeedLayout;
const feedLayoutNum = (!isNaN(rawFeedLayout) && rawFeedLayout >= 0 && rawFeedLayout <= 3) ? rawFeedLayout : 0;
data.feed_layout_class = 'layout-' + feedLayoutNum;
// Precompute boolean helpers for template @if() — the flummpress template engine uses a
// non-greedy regex to parse @if(condition) and stops at the FIRST ')' it encounters.
// This means any nested parens (e.g. indexOf('x'), .some(fn), (a || b)) inside @if()
@@ -373,8 +343,7 @@ export default (router, tpl) => {
// Can the current user manage this item (owner, admin, or mod)?
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
// Is the item's MIME type suitable for metadata extraction?
// YouTube items use oEmbed via /meta/fetch; all non-flash MIME types are eligible.
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
// Has the current user favorited this item?
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
// Hall columns for display
@@ -385,13 +354,9 @@ export default (router, tpl) => {
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
data.item_username_lower = (item.username || '').toLowerCase();
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
data.show_repost_row = !!((data.session || cfg.websrv.expose_repost_links_to_guests || cfg.websrv.expose_repost_links) && (item.is_repost || (item.reposts && item.reposts.length > 0)));
data.item.show_repost_row = data.show_repost_row;
}
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
@@ -416,11 +381,11 @@ export default (router, tpl) => {
// Specific route for direct item links: /user/:user/:itemid
// This avoids ambiguity with the profile route
router.get(/^\/user\/(?<user>[^/]+)\/(?<itemid>[a-zA-Z0-9_-]+)$/, handleGenericRoute);
router.get(/\/user\/(?<user>[^/]+)\/(?<itemid>\d+)$/, handleGenericRoute);
// Generic router for everything else (Index, Tags, standard User Grids)
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/, system routes) to prevent the greedy regex from intercepting them.
router.get(/^(?!\/(s|b|t|ca|a|login|register|settings|about|terms|rules|api|logout|auth|admin|comments|notifications|feed)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>[a-zA-Z0-9_-]{11}|\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
// We exclude static paths (/s/, /b/, /t/, /ca/, /a/) to prevent the greedy regex from intercepting them.
router.get(/^(?!\/(s|b|t|ca|a)\/)\/?(?:\/tag\/(?<tag>.+?))?(?:\/h\/(?<hall>.+?))?(?:\/user\/(?<user>.+?)\/(?<mode>f0cks|uploads|favs))?(?:\/(?<mime>(?:video|audio|image)(?:,(?:video|audio|image))*))?(?:\/p\/(?<page>\d+))?(?:\/(?<itemid>\d+))?\/?(?:\?.*)?$/, handleGenericRoute);
/* </routing-refactor> */
router.get(/^\/(about)$/, (req, res) => {

View File

@@ -29,30 +29,6 @@ export default (router, tpl) => {
});
});
// Custom meme template page
router.get(/^\/meme\/custom$/, lib.userauth, async (req, res) => {
if (!cfg.websrv.meme_creator) {
res.writeHead(404).end('Not Found');
return;
}
res.reply({
body: tpl.render('meme-creator', {
template: {
id: 'custom',
name: 'Custom Template',
url: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600" viewBox="0 0 800 600"><rect width="800" height="600" fill="%231a1a1b"/><text x="50%25" y="50%25" fill="%23888888" font-family="sans-serif" font-size="24" dominant-baseline="middle" text-anchor="middle">Click %22Choose Image%22 or Drag and Drop here</text></svg>',
category: 'Custom',
sub_category: ''
},
page_meta: {
title: 'Create Meme - Custom Template',
description: 'Create a meme using your own custom template',
url: `https://${cfg.main.url.domain}/meme/custom`
}
}, req)
});
});
// Meme creator page
router.get(/^\/meme\/(?<id>[a-z0-9-]+)$/, lib.userauth, async (req, res) => {
if (!cfg.websrv.meme_creator) {

View File

@@ -209,8 +209,7 @@ export default (router, tpl) => {
pm.ciphertext,
pm.iv,
pm.is_read,
pm.created_at,
pm.edited_at
pm.created_at
FROM private_messages pm
WHERE (
(pm.sender_id = ${req.session.id} AND pm.recipient_id = ${otherId})
@@ -313,81 +312,6 @@ export default (router, tpl) => {
}
});
// Edit own message (re-encrypt in browser, send new ciphertext + iv)
router.patch(/\/api\/dm\/message\/(?<msgId>\d+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const csrf = req.headers['x-csrf-token'];
if (!csrf || csrf !== req.session.csrf_token) return json(res, { success: false, msg: 'CSRF mismatch' }, 403);
const msgId = parseInt(req.params.msgId, 10);
const body = req.post || {};
const { ciphertext, iv } = body;
if (!ciphertext || !iv || typeof ciphertext !== 'string' || typeof iv !== 'string') {
return json(res, { success: false, msg: 'Missing ciphertext or iv' }, 400);
}
if (ciphertext.length > 65536 || iv.length > 32) {
return json(res, { success: false, msg: 'Payload too large' }, 413);
}
try {
const result = await db`
UPDATE private_messages
SET ciphertext = ${ciphertext}, iv = ${iv}, edited_at = NOW()
WHERE id = ${msgId} AND sender_id = ${req.session.id}
RETURNING id, edited_at
`;
if (!result.length) return json(res, { success: false, msg: 'Not found or not yours' }, 404);
return json(res, { success: true, id: result[0].id, edited_at: result[0].edited_at });
} catch (err) {
console.error('[DM] edit message failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Delete own message (and optionally its attachment blobs)
router.delete(/\/api\/dm\/message\/(?<msgId>\d+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401);
const csrf = req.headers['x-csrf-token'];
if (!csrf || csrf !== req.session.csrf_token) return json(res, { success: false, msg: 'CSRF mismatch' }, 403);
const msgId = parseInt(req.params.msgId, 10);
const body = req.post || {};
const rawIds = body['attachment_ids[]'] ?? body.attachment_ids;
const attachmentIds = (Array.isArray(rawIds) ? rawIds : rawIds ? [rawIds] : [])
.map(Number).filter(n => Number.isFinite(n) && n > 0);
// Verify message belongs to sender
const rows = await db`SELECT id FROM private_messages WHERE id = ${msgId} AND sender_id = ${req.session.id} LIMIT 1`;
if (!rows.length) return json(res, { success: false, msg: 'Not found or not yours' }, 404);
try {
// Clean up attachments the client identified (verify sender ownership server-side)
if (attachmentIds.length) {
const { promises: fsP } = await import('fs');
const atts = await db`
SELECT id, file_path FROM dm_attachments
WHERE id = ANY(${attachmentIds}) AND sender_id = ${req.session.id}
`;
for (const att of atts) await fsP.unlink(att.file_path).catch(() => {});
if (atts.length) {
const ids = atts.map(a => a.id);
await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`;
}
}
await db`DELETE FROM private_messages WHERE id = ${msgId} AND sender_id = ${req.session.id}`;
return json(res, { success: true });
} catch (err) {
console.error('[DM] delete message failed:', err);
return json(res, { success: false, msg: 'DB error' }, 500);
}
});
// Hide a whole conversation (Close DM)
router.post(/\/api\/dm\/conversation\/(?<userId>\d+)\/delete/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404);
@@ -406,24 +330,6 @@ export default (router, tpl) => {
}
});
// Presence check — last_seen timestamp for a given user (online = seen < 5 min ago)
router.get(/\/api\/dm\/presence\/(?<userId>\d+)/, async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: false }, 404);
if (!req.session) return json(res, { success: false }, 401);
const userId = parseInt(req.params.userId, 10);
try {
const rows = await db`SELECT last_seen FROM "user" WHERE id = ${userId} AND banned = false LIMIT 1`;
if (!rows.length) return json(res, { success: false, msg: 'User not found' }, 404);
const lastSeen = rows[0].last_seen || 0; // unix seconds
const now = ~~(Date.now() / 1000);
const online = (now - lastSeen) < 300; // 5-minute window
return json(res, { success: true, online, last_seen: lastSeen });
} catch (err) {
console.error('[DM] presence failed:', err);
return json(res, { success: false }, 500);
}
});
// Total unread DM count (for navbar badge polling)
router.get('/api/dm/unread', async (req, res) => {
if (!getPrivateMessages()) return json(res, { success: true, count: 0 });

View File

@@ -114,7 +114,7 @@ export default (router, tpl) => {
}
const f0ck = await db`
select i.dest, i.mime, i.username, i.id, i.visibility, ta.tag_id
select i.dest, i.mime, i.username, i.id, ta.tag_id
from "items" i
left join tags_assign ta on ta.item_id = i.id and ta.tag_id in (1, 2)
where i.id = ${id} and i.active = false
@@ -156,69 +156,65 @@ export default (router, tpl) => {
console.error('[MOD APPROVE] Failed to notify user:', err);
}
const isPublic = (f0ck[0].visibility || 0) === 0;
if (isPublic) {
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
// Push to Discord Webhook (Direct)
try {
const discordClient = cfg.clients.find(c => c.type === 'discord');
if (discordClient && discordClient.webhook_url) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
const payload = JSON.stringify({ content: message });
const url = new URL(discordClient.webhook_url);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload)
}
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
};
const reqDiscord = https.request(options, (resDiscord) => {
if (resDiscord.statusCode >= 400) {
console.error(`[MOD APPROVE] Webhook returned status ${resDiscord.statusCode}`);
}
});
reqDiscord.on('error', (err) => {
console.error('[MOD APPROVE] Webhook failed:', err);
});
reqDiscord.write(payload);
reqDiscord.end();
}
} catch (err) {
console.error('[MOD APPROVE] Discord Webhook error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
// Push to Matrix Channel
try {
const matrixCfg = cfg.clients.find(c => c.type === 'matrix');
if (matrixCfg?.notification_channel_id && router.self?.bot?.clients) {
const clients = await Promise.all(router.self.bot.clients);
const matrixWrapper = clients.find(c => c.type === 'matrix');
if (matrixWrapper?.client) {
const message = `${f0ck[0].username} uploaded a new video ${cfg.main.url.full}/${id}`;
await matrixWrapper.client.send(matrixCfg.notification_channel_id, message);
console.log(`[MOD APPROVE] Matrix notification sent for item ${id}`);
}
}
} catch (err) {
console.error('[MOD APPROVE] Matrix notification error:', err);
}
// Broadcast new_item event for live grid updates
try {
await db`SELECT pg_notify('new_item', ${JSON.stringify({
id: id,
dest: f0ck[0].dest,
mime: f0ck[0].mime,
username: f0ck[0].username,
tag_id: f0ck[0].tag_id,
is_oc: !!f0ck[0].is_oc
})})`;
} catch (err) {
console.error('[MOD APPROVE] new_item notify failed:', err);
}
}

View File

@@ -1,7 +1,6 @@
import db from "../sql.mjs";
import f0cklib from "../routeinc/f0cklib.mjs";
import cfg from "../config.mjs";
import { getEnableItemSlugs } from "../settings.mjs";
import { setMotd } from "../motd.mjs";
export const clients = new Set();
@@ -17,7 +16,6 @@ function broadcastChatPresence() {
if (!seen.has(client.userId)) {
seen.add(client.userId);
users.push({
id: client.userId,
username: client.username,
display_name: client.display_name,
avatar_file: client.avatar_file,
@@ -58,8 +56,7 @@ db.listen('notifications', (payload) => {
if (client.do_not_disturb === true) continue;
if (SYSTEM_TYPES.includes(data.type) && client.receive_system_notifications === false) continue;
// warnings bypass user settings
if (data.type !== 'warning' && USER_TYPES.includes(data.type) && client.receive_user_notifications === false) continue;
if (USER_TYPES.includes(data.type) && client.receive_user_notifications === false) continue;
client.send({ type: 'notify', data });
}
}
@@ -95,20 +92,6 @@ db.listen('profile_update', (payload) => {
if (data.do_not_disturb !== undefined) client.do_not_disturb = data.do_not_disturb;
client.send({ type: 'profile_update', data });
} else {
// For other users, only send public profile fields (so their DOM can live-update)
const publicData = {
user_id: data.user_id,
user: data.user,
display_name: data.display_name,
banner_file: data.banner_file,
banner_position: data.banner_position,
banner_size: data.banner_size,
avatar_file: data.avatar_file,
username_color: data.username_color,
description: data.description
};
client.send({ type: 'profile_update', data: publicData });
}
}
} catch (e) {
@@ -123,7 +106,7 @@ db.listen('activity', async (payload) => {
// We need the username, avatar, and item mime for the preview
// trigger only gave us user_id and item_id
const [details] = await db`
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime, i.slug as item_slug, COALESCE(i.visibility, 0) as visibility,
SELECT u.id as user_id, u.user as username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, i.mime,
(SELECT tag_id FROM tags_assign WHERE item_id = i.id AND tag_id IN (1, 2) LIMIT 1) as tag_id
FROM "user" u
LEFT JOIN user_options uo ON u.id = uo.user_id
@@ -132,7 +115,6 @@ db.listen('activity', async (payload) => {
`;
if (details) {
if (details.visibility > 0) return;
data.username = details.username;
data.avatar = details.avatar;
data.avatar_file = details.avatar_file;
@@ -140,7 +122,6 @@ db.listen('activity', async (payload) => {
data.username_color = details.username_color;
data.display_name = details.display_name || null;
data.tag_id = details.tag_id;
data.item_slug = getEnableItemSlugs() ? details.item_slug : null;
} else {
data.username = 'System';
}
@@ -213,7 +194,6 @@ db.listen('motd', (payload) => {
db.listen('new_item', (payload) => {
try {
const data = JSON.parse(payload);
if (data.visibility && data.visibility !== 0) return;
console.log(`[SSE] Broadcasting new_item (id: ${data.id}) to ${clients.size} clients`);
for (const client of clients) {
client.send({ type: 'new_item', data });
@@ -362,27 +342,19 @@ db.listen('global_chat_topic', (payload) => {
export default (router, tpl) => {
const USER_TYPES = ['comment_reply', 'subscription', 'mention', 'upload_comment'];
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report', 'warning'];
const nsflTagId = cfg.nsfl_tag_id || 3;
const SYSTEM_TYPES = ['approve', 'deny', 'item_deleted', 'upload_success', 'upload_error', 'admin_pending', 'report'];
async function getNotificationHistory(userId, page = 1, limit = 50, tab = null) {
const offset = (page - 1) * limit;
const typeFilter = tab === 'system' ? SYSTEM_TYPES : (tab === 'user' ? USER_TYPES : null);
const notifications = typeFilter
? await db`
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime,
CASE (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = n.item_id AND ta.tag_id IN (1, 2, ${nsflTagId}) LIMIT 1)
WHEN 1 THEN 'sfw'
WHEN 2 THEN 'nsfw'
WHEN ${nsflTagId} THEN 'nsfl'
ELSE NULL
END as item_mode
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
@@ -390,31 +362,25 @@ export default (router, tpl) => {
LEFT JOIN items i ON n.item_id = i.id
WHERE n.user_id = ${userId}
AND n.type = ANY(${typeFilter})
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
ORDER BY n.created_at DESC
LIMIT ${limit + 1}
OFFSET ${offset}
`
: await db`
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime,
CASE (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = n.item_id AND ta.tag_id IN (1, 2, ${nsflTagId}) LIMIT 1)
WHEN 1 THEN 'sfw'
WHEN 2 THEN 'nsfw'
WHEN ${nsflTagId} THEN 'nsfl'
ELSE NULL
END as item_mode
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON n.item_id = i.id
WHERE n.user_id = ${userId}
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report') OR i.id IS NULL OR (i.active = true AND i.is_deleted = false))
ORDER BY n.created_at DESC
LIMIT ${limit + 1}
OFFSET ${offset}
@@ -430,7 +396,7 @@ export default (router, tpl) => {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason;
}
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
return { ...n, reason };
});
return {
@@ -447,25 +413,19 @@ export default (router, tpl) => {
try {
const notifications = await db`
SELECT n.id, n.type, n.item_id, i.slug as item_slug, n.reference_id, n.created_at, n.is_read, n.data,
SELECT n.id, n.type, n.item_id, n.reference_id, n.created_at, n.is_read, n.data,
COALESCE(u.user, 'System') as from_user,
COALESCE(uo.display_name, '') as from_display_name,
COALESCE(u.id, 0) as from_user_id,
uo.username_color,
i.dest, i.mime,
CASE (SELECT ta.tag_id FROM tags_assign ta WHERE ta.item_id = n.item_id AND ta.tag_id IN (1, 2, ${nsflTagId}) LIMIT 1)
WHEN 1 THEN 'sfw'
WHEN 2 THEN 'nsfw'
WHEN ${nsflTagId} THEN 'nsfl'
ELSE NULL
END as item_mode
i.dest, i.mime
FROM notifications n
LEFT JOIN comments c ON n.reference_id = c.id
LEFT JOIN "user" u ON c.user_id = u.id
LEFT JOIN user_options uo ON u.id = uo.user_id
LEFT JOIN items i ON n.item_id = i.id
WHERE n.user_id = ${req.session.id} AND n.is_read = false
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve', 'warning')
AND (n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'approve')
OR (
${req.session.do_not_disturb !== true} AND (
(n.type IN ('upload_success', 'upload_error') AND ${req.session.receive_system_notifications !== false})
@@ -473,7 +433,7 @@ export default (router, tpl) => {
)
)
)
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report', 'warning'))
AND (n.item_id IS NULL OR (i.active = true AND i.is_deleted = false) OR n.type IN ('admin_pending', 'deny', 'item_deleted', 'report'))
ORDER BY n.created_at DESC
LIMIT 1000
`;
@@ -484,7 +444,7 @@ export default (router, tpl) => {
const data = typeof n.data === 'string' ? JSON.parse(n.data) : n.data;
reason = data.reason || reason;
}
return { ...n, item_slug: getEnableItemSlugs() ? n.item_slug : null, reason };
return { ...n, reason };
});
return res.reply({
@@ -535,7 +495,7 @@ export default (router, tpl) => {
router.post(/\/api\/notifications\/item\/(?<itemId>\d+)\/read/, async (req, res) => {
if (!req.session) return res.reply({ code: 401, body: JSON.stringify({ success: false }) });
const itemId = req.params.itemId;
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report', 'warning'];
const SYSTEM_TYPES = ['item_deleted', 'deny', 'admin_pending', 'report'];
console.log(`[NotificationRoute] Marking comment notifications for item ${itemId} as read for user ${req.session.id}`);
try {
await db`
@@ -685,9 +645,7 @@ export default (router, tpl) => {
next: data.hasMore ? 2 : null
};
data.link = { main: '/notifications', path: '/' };
data.activeTab = tab;
data.domain = cfg.main.url.domain; // For header
data.active_mode = req.session?.mode ?? 0;
return res.html(tpl.render('notifications', data, req));
});
@@ -700,7 +658,7 @@ export default (router, tpl) => {
const tab = req.url.qs.tab || null;
const data = await getNotificationHistory(req.session.id, page, 50, tab);
const html = tpl.render('snippets/notifications-list', { ...data, active_mode: req.session?.mode ?? 0 }, req);
const html = tpl.render('snippets/notifications-list', data, req);
return res.json({
success: true,

View File

@@ -1,136 +0,0 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
import { getNsfpIds, setNsfpIds } from "../settings.mjs";
export default (router, tpl) => {
// Admin page
router.get(/^\/admin\/nsfp\/?$/, lib.auth, async (req, res) => {
try {
res.reply({
body: tpl.render("admin/nsfp", {
session: req.session,
totals: await lib.countf0cks(),
csrf_token: req.session?.csrf_token || ''
}, req)
});
} catch (err) {
console.error('[NSFP] Page render failed:', err);
res.reply({ code: 500, body: 'Internal server error' });
}
});
// API: list current NSFP tag IDs with names
router.get('/api/v2/admin/nsfp', lib.auth, async (req, res) => {
try {
const ids = getNsfpIds();
const tagRows = ids.length > 0
? await db`SELECT id, tag, normalized FROM tags WHERE id IN ${db(ids)} ORDER BY id`
: [];
const tagMap = Object.fromEntries(tagRows.map(r => [r.id, r]));
const enriched = ids.map(id => tagMap[id] || { id, tag: '(unknown)', normalized: null });
const blockedCount = ids.length > 0
? Number((await db`
SELECT COUNT(DISTINCT item_id) AS cnt
FROM tags_assign
WHERE tag_id IN ${db(ids)}
AND item_id IN (SELECT id FROM items WHERE active = true)
`)[0].cnt)
: 0;
return res.json({ success: true, nsfp: enriched, raw_ids: ids, blocked_count: blockedCount });
} catch (err) {
return res.json({ success: false, msg: err.message }, 500);
}
});
// API: search tags for the add-tag autocomplete
router.get('/api/v2/admin/nsfp/search', lib.auth, async (req, res) => {
try {
const q = (req.url.qs?.q || '').trim();
if (!q) return res.json({ success: true, tags: [] });
const pattern = '%' + q + '%';
const tags = await db`
SELECT id, tag, normalized
FROM tags
WHERE tag ILIKE ${pattern} OR normalized ILIKE ${pattern}
ORDER BY tag
LIMIT 20
`;
return res.json({ success: true, tags });
} catch (err) {
return res.json({ success: false, msg: err.message }, 500);
}
});
// API: add a tag ID to the NSFP list
router.post('/api/v2/admin/nsfp/add', lib.auth, async (req, res) => {
try {
const tagId = parseInt(req.post?.tag_id);
if (!tagId || isNaN(tagId) || tagId <= 0) {
return res.json({ success: false, msg: 'A valid tag_id is required' }, 400);
}
const tag = await db`SELECT id, tag FROM tags WHERE id = ${tagId} LIMIT 1`;
if (tag.length === 0) {
return res.json({ success: false, msg: 'Tag with id ' + tagId + ' does not exist' }, 404);
}
const current = getNsfpIds();
if (current.includes(tagId)) {
return res.json({ success: false, msg: 'Tag #' + tagId + ' (' + tag[0].tag + ') is already in the NSFP list' }, 409);
}
const updated = [...current, tagId];
setNsfpIds(updated);
await db`
INSERT INTO site_settings (key, value)
VALUES ('nsfp', ${JSON.stringify(updated)})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
await audit.log(req.session.id, 'nsfp_add', 'tag', tagId, { tag: tag[0].tag, nsfp: updated });
return res.json({ success: true, nsfp_ids: getNsfpIds(), added: tag[0] });
} catch (err) {
console.error('[NSFP] Add failed:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
// API: remove a tag ID from the NSFP list
router.post('/api/v2/admin/nsfp/remove', lib.auth, async (req, res) => {
try {
const tagId = parseInt(req.post?.tag_id);
if (!tagId || isNaN(tagId)) {
return res.json({ success: false, msg: 'tag_id is required' }, 400);
}
const current = getNsfpIds();
if (!current.includes(tagId)) {
return res.json({ success: false, msg: 'Tag #' + tagId + ' is not in the NSFP list' }, 404);
}
const updated = current.filter(id => id !== tagId);
setNsfpIds(updated);
await db`
INSERT INTO site_settings (key, value)
VALUES ('nsfp', ${JSON.stringify(updated)})
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
`;
await audit.log(req.session.id, 'nsfp_remove', 'tag', tagId, { nsfp: updated });
return res.json({ success: true, nsfp_ids: getNsfpIds() });
} catch (err) {
console.error('[NSFP] Remove failed:', err);
return res.json({ success: false, msg: err.message }, 500);
}
});
return router;
};

View File

@@ -39,10 +39,6 @@ export default (router, tpl) => {
}
}
const ratingsRaw = req.cookies.ratings;
const ratingsArr = ratingsRaw ? decodeURIComponent(ratingsRaw).split(/[|,]/).filter(r => ['sfw','nsfw','nsfl','untagged'].includes(r)) : null;
console.log('[RANDOM] ratings cookie:', ratingsRaw, '→ parsed:', ratingsArr);
const data = await f0cklib.getRandom({
user: opts.user,
tag: opts.tag,
@@ -51,7 +47,6 @@ export default (router, tpl) => {
page: opts.page,
fav: opts.mode === 'favs',
mode: req.mode,
ratings: ratingsArr,
strict: opts.strict,
session: !!req.session
});

View File

@@ -78,11 +78,6 @@ export default (router, tpl) => {
where items.active = true
`)[0].total;
const totalUsers = +(await db`
select count(*) as total
from "user"
`)[0].total;
const hoster = await db`
with t as (
select
@@ -97,11 +92,11 @@ export default (router, tpl) => {
`;
const favotop = await db`
select favorites.item_id as id, items.slug, count(*) favs
select favorites.item_id, count(*) favs
from favorites
join items on items.id = favorites.item_id
where items.active = true
group by favorites.item_id, items.slug
group by favorites.item_id
having count(*) > 1
order by favs desc
limit 10
@@ -110,7 +105,7 @@ export default (router, tpl) => {
let xdtop = [];
if (config.websrv.enable_xd_score) {
const xdRows = await db`
select id, slug, xd_score
select id, xd_score
from items
where active = true and is_deleted = false and xd_score > 0
order by xd_score desc
@@ -140,7 +135,6 @@ export default (router, tpl) => {
xdtop,
totalComments,
totalFavs,
totalUsers,
enable_nsfl: config.enable_nsfl,
diskSize: cachedDiskSize,
tmp: null,

View File

@@ -1,7 +1,7 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import security from "../security.mjs";
import { getRegistrationOpen, getRegistrationRequireMailAndorToken, getDefaultLayout } from "../settings.mjs";
import { getRegistrationOpen, getDefaultLayout, getDefaultFeedLayout } from "../settings.mjs";
import { sendMail } from "../../lib/smtp.mjs";
import cfg from "../config.mjs";
import crypto from "crypto";
@@ -48,7 +48,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg: errorMsg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() }, req)
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: errorMsg, registration_open: getRegistrationOpen() })
});
}
@@ -58,7 +58,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: false, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() }, req)
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), error: msg, registration_open: getRegistrationOpen() })
});
};
@@ -67,7 +67,7 @@ export default (router, tpl) => {
return res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ success: true, msg }));
}
return res.reply({
body: tpl.render("register", { theme: req.cookies?.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() }, req)
body: tpl.render("register", { theme: req.cookies.theme ?? (cfg.websrv.theme || "f0ck"), success: msg, registration_open: getRegistrationOpen() })
});
};
@@ -88,49 +88,26 @@ export default (router, tpl) => {
return renderError("Passwords do not match.");
}
// reCAPTCHA verification (bypassed for .onion requests as Google reCAPTCHA cannot validate .onion domains)
const isOnion = lib.isOnionRequest(req);
if (!isOnion && cfg.recaptcha?.enabled && cfg.recaptcha?.secret_key) {
const rcToken = req.post['g-recaptcha-response'];
if (!rcToken) return renderError("Please complete the reCAPTCHA.");
try {
const verifyRes = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ secret: cfg.recaptcha.secret_key, response: rcToken, remoteip: ip })
});
const { success } = await verifyRes.json();
if (!success) return renderError("reCAPTCHA verification failed. Please try again.");
} catch (e) {
console.error('[REGISTER] reCAPTCHA error:', e.message);
return renderError("reCAPTCHA check failed. Please try again.");
}
}
// Registration Logic
let activated = true;
let activationToken = null;
const registrationOpen = getRegistrationOpen();
const requireMailOrToken = getRegistrationRequireMailAndorToken();
if (!registrationOpen && !token) {
// Closed registration — invite token is always required
if (!token && !getRegistrationOpen()) {
return renderError("Invite token is required for registration.");
}
if (token) {
// Invite token path — validate and activate immediately
const tokenRow = await db`
select * from invite_tokens where token = ${token} and is_used = false
`;
if (tokenRow.length === 0) return renderError("Invalid or used invite token");
// Token is valid; account activated immediately
} else if (requireMailOrToken) {
// Open registration but email/token required — email path
if (!email || !email.includes('@')) return renderError("A valid email is required for registration.");
// Token used, so it will be activated by default
} else {
// No token, Open Registration
if (!email || !email.includes('@')) return renderError("A valid email is required for no-token registration.");
activated = false;
activationToken = crypto.randomBytes(32).toString('hex');
}
// else: open registration, no mail/token required — just username+password, activated immediately
// Check user existence
const existing = await db`
@@ -168,8 +145,8 @@ export default (router, tpl) => {
const avatarFile = 'default.png';
await db`
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, disable_autoplay, disable_swiping, use_alternative_infobox)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultLayout() === 'modern'}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false}, ${cfg.websrv.user_alternative_infobox !== false})
insert into user_options (user_id, mode, theme, fullscreen, avatar, avatar_file, use_new_layout, feed_layout, disable_autoplay, disable_swiping)
values (${userId}, 3, 'amoled', 0, ${avatarId}, ${avatarFile}, ${getDefaultFeedLayout() === 1}, ${getDefaultFeedLayout()}, ${cfg.websrv.enable_autoplay === false}, ${cfg.websrv.enable_swiping === false})
`;
} catch (err) {
console.error(`[REGISTER] DB Error during user creation:`, err);
@@ -209,7 +186,7 @@ export default (router, tpl) => {
if (tokenRow.length > 0) {
await db`
update invite_tokens
set is_used = true, used_by = ${userId}, used_at = now()
set is_used = true, used_by = ${userId}
where id = ${tokenRow[0].id}
`;
}

View File

@@ -4,57 +4,22 @@ import lib from "../lib.mjs";
export default (router, tpl) => {
// Serve the scroller page
router.get(/^\/abyss(?:\/(?<id>[a-zA-Z0-9_\/-]+))?\/?$/, async (req, res) => {
router.get(/^\/abyss\/?$/, async (req, res) => {
if (cfg.websrv.abyss_enabled === false) return res.reply({ code: 404, body: tpl.render('error', { message: 'Not found', tmp: null }, req) });
if (cfg.websrv.private_society && !req.session) {
return res.reply({ code: 502, body: '<html><body>502 Bad Gateway</body></html>' });
}
const id = req.params?.id || req.params?.[0];
console.log('[SCROLLER] URL:', req.url.pathname, 'Params:', req.params, 'ID:', id);
let page_meta = {
title: 'doomscroll',
description: 'Scroll through content endlessly',
url: `https://${cfg.main.url.domain}/abyss`
};
if (id && /^\d+$/.test(id.trim())) {
try {
const items = await db`
select i.*, uo.display_name
from "items" i
left join "user" u on u."user" = i.username or u.login = i.username
left join user_options uo on uo.user_id = u.id
where i.id = ${+id} and i.active = true
limit 1
`;
if (items.length > 0) {
const item = items[0];
// Fetch tags to check for NSFW/NSFL
const tags = await db`
select tag_id from tags_assign where item_id = ${+id}
`;
const tagIds = tags.map(t => t.tag_id);
const isBlurred = tagIds.includes(2) || tagIds.includes(cfg.nsfl_tag_id || 3);
page_meta.title = `${id}`;
page_meta.description = cfg.websrv.description || "The webs dumpster";
page_meta.url = `https://${cfg.main.url.domain}/abyss/${id}`;
page_meta.image = `https://${cfg.main.url.domain}/t/${id}${isBlurred ? '_blur' : ''}.webp`;
}
} catch (e) {
console.error('[SCROLLER] Failed to fetch meta for ID:', id, e);
}
}
return res.reply({
body: tpl.render('scroller', {
tmp: null,
session: req.session ? { ...req.session } : false,
enable_nsfl: !!cfg.enable_nsfl,
enable_swf: !!cfg.websrv.enable_swf,
page_meta
page_meta: {
title: 'doomscroll',
description: 'Scroll through content endlessly',
url: `https://${cfg.main.url.domain}/abyss`
}
}, req)
});
});
@@ -166,12 +131,8 @@ export default (router, tpl) => {
const anchorId = qs.anchor ? parseInt(qs.anchor, 10) : null;
const swfMimes = ['application/x-shockwave-flash', 'application/vnd.adobe.flash.movie'];
const archiveMimes = Object.entries(cfg.mimes)
.filter(([mime, ext]) => mime.startsWith('application/') && !['swf', 'pdf'].includes(ext))
.map(([mime]) => mime);
const excludeSwfSQL = !cfg.websrv.enable_swf ? db`AND items.mime != ALL(${swfMimes})` : db``;
const excludePdfSQL = db`AND items.mime != 'application/pdf'`;
const excludeArchiveSQL = db`AND items.mime != ALL(${archiveMimes})`;
const mimeParts = (mime || '').split(',').filter(m => ['video', 'audio', 'image'].includes(m));
const mimeSQL = mimeParts.length > 0
? db`AND (${mimeParts.map(m => db`items.mime ilike ${m + '/%'}`).reduce((a, b) => db`${a} OR ${b}`)})`
@@ -250,9 +211,7 @@ export default (router, tpl) => {
WHERE items.id = ${anchorId}
AND items.active = true
AND ${db.unsafe(modeQuery)}
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}
${!req.session && nsfp ? db`AND NOT EXISTS (SELECT 1 FROM tags_assign WHERE item_id = items.id AND (${db.unsafe(nsfp)}))` : db``}
`;
// If the anchor item doesn't pass the rating filter, it's inaccessible to this user.
@@ -268,10 +227,8 @@ export default (router, tpl) => {
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}
AND items.id != ${anchorId}
${excludeSQL}
${mimeSQL}
@@ -293,10 +250,8 @@ export default (router, tpl) => {
WHERE
${db.unsafe(modeQuery)}
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
${excludeSwfSQL}
${excludePdfSQL}
${excludeArchiveSQL}
${cursorSQL}
${excludeSQL}
${mimeSQL}
@@ -308,8 +263,6 @@ export default (router, tpl) => {
`;
}
const ytSrcRegex = /(?:youtube\.com\/\S*(?:(?:\/e(?:mbed))?\/|watch\/?\/?\?(?:\S*?&?v=))|youtu\.be\/)([a-zA-Z0-9_-]{6,11})/i;
const items = rows.map(row => {
const isVideo = row.mime && row.mime.startsWith('video') && row.mime !== 'video/youtube';
const isYouTube = row.mime === 'video/youtube';
@@ -317,16 +270,7 @@ export default (router, tpl) => {
const isImage = row.mime && row.mime.startsWith('image');
let dest = row.dest;
if (isYouTube) {
// Guard against dest values corrupted by the UUID backfill script:
// dest should be "yt:VIDEO_ID" — if it isn't, recover the ID from src.
if (!dest || !dest.startsWith('yt:')) {
const m = row.src && row.src.match(ytSrcRegex);
if (m) dest = `yt:${m[1]}`;
}
} else if (dest) {
dest = `${cfg.websrv.paths.images}/${row.dest}`;
}
if (!isYouTube && dest) dest = `${cfg.websrv.paths.images}/${row.dest}`;
const thumbnail = `${cfg.websrv.paths.thumbnails}/${row.id}.webp`;
let ratingLabel = '?'; let ratingClass = 'untagged';
@@ -351,7 +295,6 @@ export default (router, tpl) => {
fav_count: +row.fav_count || 0,
comment_count: +row.comment_count || 0,
is_swf: !!(row.mime === 'application/x-shockwave-flash' || row.mime === 'application/vnd.adobe.flash.movie'),
is_archive: !!(row.mime && archiveMimes.includes(row.mime)),
is_video: isVideo,
is_youtube: isYouTube,
is_audio: isAudio,

View File

@@ -56,48 +56,6 @@ export default (router, tpl) => {
path: '&page='
};
}
else if (tag.startsWith('title:')) {
const titleQuery = tag.substring(6).trim();
const q = '%' + titleQuery + '%';
total = (await db`
select count(*) as total
from "items"
where title ilike ${q} and active = true
`)[0]?.total ?? 0;
total = +total;
const pages = +Math.ceil(total / _eps);
const act_page = Math.min(Math.max(pages, 1), page || 1);
const offset = Math.max(0, (act_page - 1) * _eps);
ret = await db`
select *
from "items"
where title ilike ${q} and active = true
order by id desc
offset ${offset}
limit ${_eps}
`;
const cheat = [];
for (let i = Math.max(1, act_page - 3); i <= Math.min(act_page + 3, pages); i++)
cheat.push(i);
pagination = {
start: 1,
end: pages,
prev: (act_page > 1) ? act_page - 1 : null,
next: (act_page < pages) ? act_page + 1 : null,
page: act_page,
cheat: cheat,
uff: false
};
link = {
main: `/search/?tag=${encodeURIComponent(tag)}`,
path: '&page='
};
}
else if (mode === 'strict') {
const tags = tag.split(',').map(t => t.trim()).filter(t => t.length > 0);

View File

@@ -23,9 +23,9 @@ export default (router, tpl) => {
join tags t on t.id = et.id
`;
// Get custom avatar file and banner file if exists
// Get custom avatar file if exists
const userOptions = (await db`
select avatar_file, banner_file from user_options where user_id = ${+req.session.id}
select avatar_file from user_options where user_id = ${+req.session.id}
`)[0];
// Get full user info
@@ -46,16 +46,10 @@ export default (router, tpl) => {
sessions,
excluded_tags: excluded_tags || [],
avatar_file: userOptions?.avatar_file || null,
banner_file: userOptions?.banner_file || null,
banner_position: userOptions?.banner_position || 'center',
banner_size: userOptions?.banner_size || 'cover',
email: user?.email || '',
joined: user?.created_at || null,
user_banner_enabled: cfg.websrv.user_banner_enabled !== false,
enable_swf: cfg.enable_swf,
enable_data_export: cfg.websrv.enable_data_export,
enable_user_api_keys: cfg.websrv.enable_user_api_keys !== false,
enable_user_invites: cfg.websrv.enable_user_invites !== false,
site_domain: cfg.main.url.domain,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {

View File

@@ -23,11 +23,6 @@ export default (router, tpl) => {
route: /^\/s\/koepfe\//
});
router.static({
dir: cfg.paths.fonts,
route: /^\/s\/fonts\//
});
router.static({
dir: path.join(path.resolve(), 'node_modules/@ruffle-rs/ruffle'),
route: /^\/s\/ruffle\//

View File

@@ -34,7 +34,7 @@ export default (router, tpl) => {
const subs = await db`
SELECT
s.created_at as sub_date,
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
i.id, i.dest, i.mime, i.username as uploader_name
FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
@@ -45,7 +45,6 @@ export default (router, tpl) => {
const items = subs.map(i => ({
id: i.id,
slug: i.slug || null,
user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp`
@@ -109,7 +108,7 @@ export default (router, tpl) => {
const subs = await db`
SELECT
s.created_at as sub_date,
i.id, i.slug, i.dest, i.mime, i.username as uploader_name
i.id, i.dest, i.mime, i.username as uploader_name
FROM comment_subscriptions s
JOIN items i ON s.item_id = i.id
WHERE s.user_id = ${req.session.id} AND s.is_subscribed = true
@@ -119,7 +118,6 @@ export default (router, tpl) => {
const items = subs.map(i => ({
id: i.id,
slug: i.slug || null,
user: i.uploader_name || 'System',
sub_created: new Date(i.sub_date).toLocaleString(),
thumb: `/t/${i.id}.webp`

View File

@@ -31,9 +31,7 @@ export async function regenerateTagImage(tag, mode) {
JOIN tags_assign ta ON ta.item_id = i.id
JOIN tags t ON t.id = ta.tag_id
${modeFilter}
WHERE (t.tag = ${tag} OR t.normalized = ${tag})
AND i.active = true
AND COALESCE(i.visibility, 0) = 0
WHERE (t.tag = ${tag} OR t.normalized = ${tag}) AND i.active = true
ORDER BY RANDOM()
LIMIT 3
`;
@@ -42,7 +40,7 @@ export async function regenerateTagImage(tag, mode) {
const inputs = items.map(item => path.join(cfg.paths.t, `${item.id}.webp`));
await fs.mkdir(path.dirname(cachePath), { recursive: true });
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath]);
await execFilePromise('magick', [...inputs, '+append', '-background', 'none', '-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath]);
return cachePath;
}
} catch (err) {
@@ -125,17 +123,17 @@ function generateFallbackSvg(tag) {
const n2 = parseInt(hash.substring(20, 22), 16);
return `
<svg width="600" height="300" viewBox="0 0 600 300" xmlns="http://www.w3.org/2000/svg">
<svg width="300" height="150" viewBox="0 0 300 150" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:${c1};stop-opacity:1" />
<stop offset="100%" style="stop-color:${c2};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="600" height="300" fill="url(#grad)" />
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 2}" fill="${c3}" fill-opacity="0.25" />
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 1.5}" fill="${c3}" fill-opacity="0.15" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="monospace, sans-serif" font-size="36" fill="#fff" fill-opacity="0.95" font-weight="bold">${displayTag}</text>
<rect width="300" height="150" fill="url(#grad)" />
<circle cx="${n1}%" cy="${n2}%" r="${(n1 + n2) / 4}" fill="${c3}" fill-opacity="0.3" />
<circle cx="${100 - n1}%" cy="${100 - n2}%" r="${(n1 + n2) / 3}" fill="${c3}" fill-opacity="0.2" />
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="sans-serif" font-size="24" fill="#fff" fill-opacity="0.9" font-weight="bold">${displayTag}</text>
</svg>
`.trim();
}

View File

@@ -9,23 +9,7 @@ export default (router, tpl) => {
const getTagsQuery = async (mode, offset, limit, sessionObj = false, strict = false) => {
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
const isGuest = !sessionObj;
let baseMode = lib.getMode(mode);
if (isGuest) {
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
if (cfg.websrv.public_nsfw) {
baseMode = cfg.websrv.public_untagged
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
} else {
baseMode = cfg.websrv.public_untagged
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
: "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (!cfg.websrv.public_untagged && mode === 2) {
baseMode = "1 = 0";
}
}
const modequery = baseMode;
const modequery = lib.getMode(mode);
let restrictedFilter = db``;
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
@@ -51,7 +35,6 @@ export default (router, tpl) => {
JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id
WHERE items.active = true
AND COALESCE(items.visibility, 0) = 0
AND t.id NOT IN (1, 2)
AND ${db.unsafe(modequery)}
${restrictedFilter}
@@ -75,7 +58,6 @@ export default (router, tpl) => {
JOIN items ON items.id = ta.item_id
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
AND ${db.unsafe(modequery)}
${restrictedFilter}
${userExcludeFilter}
@@ -113,7 +95,7 @@ export default (router, tpl) => {
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
return res.json({
success: true,
html: tpl.render('tag-cards', { toptags: tags, user: req.session?.user ? { user: req.session.user } : null, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
html: tpl.render('tag-cards', { toptags: tags, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE
});
@@ -146,7 +128,6 @@ export default (router, tpl) => {
phrase,
tmp: null,
hidePagination: true,
user: req.session?.user ? { user: req.session.user } : null,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: 'Tags',

View File

@@ -92,7 +92,7 @@ export default (router, tpl) => {
const data = await f0cklib.getf0cks({
page: req.params.page,
mode: req.mode,
session: req.session,
session: !!req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
userHall: slug,
@@ -138,7 +138,7 @@ export default (router, tpl) => {
const data = await f0cklib.getf0ck({
itemid: req.params.itemid,
mode: req.mode,
session: req.session,
session: !!req.session,
exclude: req.session?.excluded_tags || [],
user_id: req.session?.id,
userHall: slug,
@@ -163,7 +163,7 @@ export default (router, tpl) => {
const item = data.item;
data.is_mod_or_admin = !!(session && (session.admin || session.is_moderator));
data.can_manage_item = !!(session && (session.admin || session.is_moderator || session.user === item.username));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && !(item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime])));
data.can_extract_meta = !!(item.mime && item.mime.indexOf('flash') === -1 && item.mime.indexOf('youtube') === -1);
data.user_has_favorited = !!(session && Array.isArray(item.favorites) && item.favorites.some(f => f.user === session.user));
data.halls_slugs = Array.isArray(item.halls) ? item.halls.map(h => h.slug).join(',') : '';
data.user_halls_slugs = Array.isArray(item.user_halls) ? item.user_halls.map(h => h.slug).join(',') : '';
@@ -171,11 +171,9 @@ export default (router, tpl) => {
data.item_rating_label = item.is_nsfl ? 'NSFL' : (item.is_nsfw ? 'NSFW' : (item.is_sfw ? 'SFW' : '?'));
data.item_username_lower = (item.username || '').toLowerCase();
data.is_flash_item = !!(item.mime && (item.mime.indexOf('flash') !== -1 || item.mime.indexOf('shockwave') !== -1));
data.is_archive_item = !!(item.mime && item.mime.startsWith('application/') && cfg.mimes[item.mime] && !['swf', 'pdf'].includes(cfg.mimes[item.mime]));
data.current_hall_slug = (data.tmp && data.tmp.hall && typeof data.tmp.hall === 'object') ? data.tmp.hall.slug : (data.tmp && data.tmp.hall ? data.tmp.hall : '');
data.current_user_hall_slug = (data.tmp && data.tmp.userHall && typeof data.tmp.userHall === 'object') ? data.tmp.userHall.slug : (data.tmp && data.tmp.userHall ? data.tmp.userHall : '');
data.current_user_hall_owner = (data.tmp && data.tmp.userHallOwner) ? data.tmp.userHallOwner : '';
data.item_has_dimensions = !!(item.width && item.height);
}
// Precompute hall display
@@ -260,7 +258,7 @@ export default (router, tpl) => {
FROM items i
JOIN user_halls_assign uha ON uha.item_id = i.id
${modeFilter}
WHERE uha.hall_id = ${hall.id} AND i.active = true AND COALESCE(i.visibility, 0) = 0
WHERE uha.hall_id = ${hall.id} AND i.active = true
ORDER BY RANDOM()
LIMIT 3
`;
@@ -270,7 +268,7 @@ export default (router, tpl) => {
await fs.mkdir(CACHE_DIR, { recursive: true });
await execFile('magick', [
...inputs, '+append', '-background', 'none',
'-resize', '600x300^', '-gravity', 'center', '-extent', '600x300', cachePath
'-resize', '300x150^', '-gravity', 'center', '-extent', '300x150', cachePath
]);
res.writeHead(200, { 'Content-Type': 'image/webp', 'Cache-Control': 'public, max-age=3600' });
return res.end(await fs.readFile(cachePath));

View File

@@ -1,215 +0,0 @@
import db from "../../inc/sql.mjs";
import lib from "../../inc/lib.mjs";
import cfg from "../../inc/config.mjs";
import url from "url";
const TAGS_PER_PAGE = 50; // Smaller chunks for better infinite scroll
export default (router, tpl) => {
const getTagsQuery = async (userId, mode, offset, limit, sessionObj = false, strict = false) => {
const excludedTags = sessionObj ? (sessionObj.excluded_tags || []) : [];
const isGuest = !sessionObj;
let baseMode = lib.getMode(mode);
if (isGuest) {
if (mode === 3 || mode === 0 || mode === undefined || mode === null) {
if (cfg.websrv.public_nsfw) {
baseMode = cfg.websrv.public_untagged
? "(items.id in (select item_id from tags_assign where tag_id in (1, 2)) or not exists (select 1 from tags_assign where item_id = items.id))"
: "items.id in (select item_id from tags_assign where tag_id in (1, 2))";
} else {
baseMode = cfg.websrv.public_untagged
? "(items.id in (select item_id from tags_assign where tag_id = 1) or not exists (select 1 from tags_assign where item_id = items.id))"
: "items.id in (select item_id from tags_assign where tag_id = 1)";
}
} else if (!cfg.websrv.public_untagged && mode === 2) {
baseMode = "1 = 0";
}
}
const modequery = baseMode;
let restrictedFilter = db``;
if (isGuest && cfg.nsfp && cfg.nsfp.length > 0) {
restrictedFilter = db`
AND t.id NOT IN ${db(cfg.nsfp)}
AND NOT EXISTS (
SELECT 1 FROM tags_assign ta_res
WHERE ta_res.item_id = items.id
AND ta_res.tag_id IN ${db(cfg.nsfp)}
)
`;
}
const userExcludeFilter = excludedTags.length > 0
? db`AND NOT EXISTS (SELECT 1 FROM tags_assign ta_ex WHERE ta_ex.item_id = items.id AND ta_ex.tag_id = ANY(${excludedTags}::int[]))`
: db``;
// Step 1: Get tags sorted by exact count (fast, indexed)
// Group by normalized to merge duplicates (e.g. "Music" and "music")
const baseTags = await db`
SELECT MIN(t.id) as id, MIN(t.tag) as tag, t.normalized, COUNT(DISTINCT items.id) AS total_items
FROM tags t
JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id
WHERE items.active = true
AND COALESCE(items.visibility, 0) = 0
AND t.id NOT IN (1, 2)
AND ta.user_id = ${userId}
AND ${db.unsafe(modequery)}
${restrictedFilter}
${userExcludeFilter}
GROUP BY t.normalized
HAVING COUNT(DISTINCT items.id) >= 1
ORDER BY total_items DESC, MIN(t.id) DESC
OFFSET ${offset}
LIMIT ${limit}
`;
// Step 2: In normal (non-strict) mode, replace counts with fuzzy counts
// Only runs for the ~50 tags being displayed, not all tags
if (!strict && baseTags.length > 0) {
await Promise.all(baseTags.map(async (tag) => {
if (!tag.normalized) return;
const [row] = await db`
SELECT COUNT(DISTINCT items.id) as total
FROM tags t
JOIN tags_assign ta ON t.id = ta.tag_id
JOIN items ON items.id = ta.item_id
WHERE t.normalized LIKE '%' || ${tag.normalized} || '%'
AND items.active = true
AND COALESCE(items.visibility, 0) = 0
AND ta.user_id = ${userId}
AND ${db.unsafe(modequery)}
${restrictedFilter}
${userExcludeFilter}
`;
tag.total_items = +row.total;
}));
}
return baseTags;
};
const processTags = (tags) => tags.map(t => ({
...t,
safe_tag: t.normalized || encodeURIComponent(t.tag),
encoded_tag: encodeURIComponent(t.tag)
}));
// API endpoint for lazy loading tags for a user
router.get(/^\/api\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
const userParam = decodeURIComponent(req.params.user);
const u = await db`
SELECT "user".id, "user".user
FROM "user"
WHERE "user".user ILIKE ${userParam}
`;
if (!u.length) {
return res.reply({ code: 404, body: JSON.stringify({ success: false, msg: "User not found" }) });
}
const userId = u[0].id;
const userName = u[0].user;
let query = {};
if (typeof req.url === 'string') {
const parsedUrl = url.parse(req.url, true);
query = parsedUrl.query;
} else {
query = req.url.qs || {};
}
const page = Math.max(1, +(query.page ?? 1));
const offset = (page - 1) * TAGS_PER_PAGE;
const mode = req.mode ?? 0;
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
const tags = processTags(await getTagsQuery(userId, mode, offset, TAGS_PER_PAGE, req.session, isStrict));
if (req.headers['x-requested-with'] === 'XMLHttpRequest' || query.ajax) {
const paginationHtml = tpl.render('snippets/pagination', {
pagination: {
page: page,
prev: page > 1 ? page - 1 : null,
next: tags.length === TAGS_PER_PAGE ? page + 1 : null,
cheat: [1]
},
link: `/user/${encodeURIComponent(userName)}/tags`
}, req);
return res.json({
success: true,
html: tpl.render('tag-cards', { toptags: tags, user: { user: userName }, session: (req.session && req.session.user) ? { ...req.session } : false }, req),
currentPage: page,
pagination: paginationHtml,
hasMore: tags.length === TAGS_PER_PAGE
});
}
res.json({
success: true,
tags,
currentPage: page,
hasMore: tags.length === TAGS_PER_PAGE
});
});
// Main tags page
router.get(/^\/user\/(?<user>[^\/]+)\/tags$/, async (req, res) => {
const userParam = decodeURIComponent(req.params.user);
const u = await db`
SELECT "user".id, "user".user, user_options.avatar, user_options.avatar_file, user_options.username_color
FROM "user"
LEFT JOIN user_options ON "user".id = user_options.user_id
WHERE "user".user ILIKE ${userParam}
`;
if (!u.length) {
return res.reply({ code: 404, body: "User not found" });
}
const userRow = u[0];
const userId = userRow.id;
const mode = req.mode ?? 0;
const query = req.url.qs || {};
const isStrict = !!(query.strict === '1' || req.session?.strict_mode);
const toptags = processTags(await getTagsQuery(userId, mode, 0, TAGS_PER_PAGE, req.session, isStrict));
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.setHeader('Surrogate-Control', 'no-store');
const data = {
user: userRow,
toptags: toptags,
tmp: null,
hidePagination: false,
pagination: {
page: 1,
prev: null,
next: toptags.length === TAGS_PER_PAGE ? 2 : null,
cheat: [1]
},
link: `/user/${encodeURIComponent(userRow.user)}/tags`,
session: (req.session && req.session.user) ? { ...req.session } : false,
page_meta: {
title: `${userRow.user}'s Tags`,
description: `Browse ${toptags.length}+ tags made by ${userRow.user}`,
url: `https://${cfg.main.url.domain}/user/${encodeURIComponent(userRow.user)}/tags`
}
};
if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
return res.reply({
body: tpl.render('tags_user-partial', data, req)
});
}
res.reply({
body: tpl.render('tags_user', data, req)
});
});
return router;
};

View File

@@ -21,11 +21,6 @@ export default (router, tpl) => {
// Broadcast to SSE clients instantly
if (result.length > 0) {
await db`
INSERT INTO notifications (user_id, type, reference_id, data, is_read)
VALUES (${+user_id}, 'warning', 0, ${JSON.stringify({ reason: reason.trim(), warning_id: result[0].id })}, false)
`;
await db`SELECT pg_notify('warnings', ${JSON.stringify({
user_id: +user_id,
warning_id: result[0].id,

View File

@@ -1,83 +0,0 @@
import db from "../sql.mjs";
import lib from "../lib.mjs";
import audit from "../audit.mjs";
export default (router, tpl) => {
// Auto-migration on startup
db`CREATE TABLE IF NOT EXISTS public.wordfilter (
id SERIAL PRIMARY KEY,
word VARCHAR(255) NOT NULL UNIQUE,
replacement VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`.catch(err => console.error('[WORDFILTER] DB Table Setup Failed:', err));
// Admin View Render
router.get(/^\/admin\/wordfilter\/?$/, lib.auth, async (req, res) => {
res.reply({
body: tpl.render("admin/wordfilter", {
session: req.session,
totals: await lib.countf0cks(),
csrf_token: req.session?.csrf_token || ''
}, req)
});
});
// API list rules
router.get('/api/v2/admin/wordfilter', lib.auth, async (req, res) => {
try {
const filters = await db`SELECT * FROM wordfilter ORDER BY created_at DESC`;
return res.json({ success: true, filters });
} catch (err) {
return res.json({ success: false, msg: err.message }, 500);
}
});
// API create rule
router.post('/api/v2/admin/wordfilter', lib.auth, async (req, res) => {
try {
const { word, replacement } = req.post || {};
if (!word || !word.trim() || !replacement || !replacement.trim()) {
return res.json({ success: false, msg: 'Word and replacement are required' }, 400);
}
const cleanWord = word.trim();
const cleanReplacement = replacement.trim();
// Ensure no infinite loops or matches
if (cleanWord.toLowerCase() === cleanReplacement.toLowerCase()) {
return res.json({ success: false, msg: 'Word and replacement cannot be identical' }, 400);
}
await db`
INSERT INTO wordfilter (word, replacement)
VALUES (${cleanWord}, ${cleanReplacement})
ON CONFLICT (word) DO UPDATE SET replacement = EXCLUDED.replacement
`;
await audit.log(req.session.id, 'add_wordfilter', 'wordfilter', 0, { word: cleanWord, replacement: cleanReplacement });
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message }, 500);
}
});
// API delete rule
router.post('/api/v2/admin/wordfilter/delete', lib.auth, async (req, res) => {
try {
const { id } = req.post || {};
if (!id) return res.json({ success: false, msg: 'ID required' }, 400);
const rows = await db`DELETE FROM wordfilter WHERE id = ${id} RETURNING word`;
if (rows.length > 0) {
await audit.log(req.session.id, 'delete_wordfilter', 'wordfilter', id, { word: rows[0].word });
}
return res.json({ success: true });
} catch (err) {
return res.json({ success: false, msg: err.message }, 500);
}
});
return router;
};

View File

@@ -26,17 +26,14 @@ export default new class {
* @returns {string}
*/
getRealIP(req) {
let rawIp = req.headers['cf-connecting-ip'] ||
let ip = req.headers['cf-connecting-ip'] ||
req.headers['true-client-ip'] ||
req.headers['x-client-ip'] ||
req.headers['x-real-ip'] ||
req.headers['x-forwarded-for'] ||
req.socket?.remoteAddress;
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
req.socket.remoteAddress;
if (!rawIp) return "unknown";
// If the header contains a comma-separated proxy chain (e.g. "IP1, IP2"), extract the client IP (first element)
let ip = String(rawIp).split(',')[0].trim();
if (!ip) return "unknown";
// Handle IPv6 loopback and mapped IPv4
if (ip === "::1") ip = "127.0.0.1";

View File

@@ -1,6 +1,4 @@
import cfg from "./config.mjs";
import db from "./sql.mjs";
import lib from "./lib.mjs";
let manual_approval = true;
let min_tags = 3;
@@ -9,44 +7,15 @@ let trusted_uploads = 0;
let bypass_duplicate_check = false;
let protect_files = false;
let private_messages = true;
let dm_attachments = true;
let dm_unencrypted = false;
let default_layout = 'modern';
let default_feed_layout = 0;
let enable_pdf = false;
let enable_cleanup = false;
let cleanup_start_date = '';
let cleanup_end_date = '';
let cleanup_include_engaged = false;
export const getShitpostMode = () => !!cfg.websrv.shitpost_mode;
export const setShitpostMode = (val) => {}; // No-op, strictly config-based
export const getEnableExpiringUploads = () => {
if (cfg.enable_expiring_uploads === false || cfg.websrv?.enable_expiring_uploads === false) return false;
return true;
};
export const getEnableItemSlugs = () => {
if (cfg.enable_item_slugs === false || cfg.websrv?.enable_item_slugs === false) return false;
return true;
};
export const ensureAllItemsHaveSlugs = async () => {
try {
const rows = await db`SELECT id FROM items WHERE slug IS NULL OR slug = ''`;
if (!rows || rows.length === 0) return;
console.log(`[SLUG_BACKFILL] Found ${rows.length} item(s) missing slugs. Backfilling...`);
for (const row of rows) {
const newSlug = lib.generateSlug(11);
await db`UPDATE items SET slug = ${newSlug} WHERE id = ${row.id} AND (slug IS NULL OR slug = '')`;
}
console.log(`[SLUG_BACKFILL] Successfully backfilled ${rows.length} item slug(s).`);
} catch (err) {
console.error('[SLUG_BACKFILL] Error during slug backfill:', err.message);
}
};
export const getEnableCleanup = () => {
if (cfg.websrv.enable_cleanup === false) return false;
return enable_cleanup;
@@ -59,9 +28,6 @@ export const setCleanupStartDate = (val) => cleanup_start_date = val || '';
export const getCleanupEndDate = () => cleanup_end_date;
export const setCleanupEndDate = (val) => cleanup_end_date = val || '';
export const getCleanupIncludeEngaged = () => cleanup_include_engaged;
export const setCleanupIncludeEngaged = (val) => cleanup_include_engaged = !!val;
export const getEnablePdf = () => enable_pdf;
export const setEnablePdf = (val) => enable_pdf = !!val;
@@ -82,11 +48,6 @@ export const getRegistrationOpen = () => {
};
export const setRegistrationOpen = (val) => registration_open = !!val;
// When false (default): open_registration=true means anyone can register with just username+password, activated immediately.
// When true: even in open registration, a valid email OR invite token is required.
export const getRegistrationRequireMailAndorToken = () => !!cfg.websrv.open_registration_require_mail_andor_token;
export const setRegistrationRequireMailAndorToken = (val) => {}; // No-op, strictly config-based
export const getTrustedUploads = () => trusted_uploads;
export const setTrustedUploads = (val) => trusted_uploads = Math.max(0, parseInt(val) ?? 3);
@@ -99,36 +60,17 @@ export const setProtectFiles = (val) => protect_files = !!val;
export const getPrivateMessages = () => private_messages;
export const setPrivateMessages = (val) => private_messages = !!val;
export const getDmAttachments = () => dm_attachments;
export const setDmAttachments = (val) => dm_attachments = !!val;
export const getDmUnencrypted = () => dm_unencrypted;
export const setDmUnencrypted = (val) => dm_unencrypted = !!val;
export const getDmAttachmentExpiryDays = () => {
const v = parseInt(cfg.websrv.dm_attachment_expiry_days);
return (Number.isFinite(v) && v > 0) ? v : 90;
};
export const getDefaultLayout = () => default_layout;
export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');
export const getDefaultFeedLayout = () => default_feed_layout;
export const setDefaultFeedLayout = (val) => {
const parsed = parseInt(val, 10);
default_feed_layout = (!isNaN(parsed) && parsed >= 0 && parsed <= 3) ? parsed : 0;
};
export const getLogUserIps = () => !!cfg.websrv.log_user_ips;
export const setLogUserIps = (val) => {}; // No-op, strictly config-based
export const getHashUserIps = () => !!cfg.websrv.hash_user_ips;
export const setHashUserIps = (val) => {}; // No-op, strictly config-based
export const getAllowCommentDeletion = () => !!cfg.websrv.allow_comment_deletion;
export const setAllowCommentDeletion = (val) => {}; // No-op, strictly config-based
// Live-editable NSFP tag ID list — seeded from config.json, can be overridden by DB setting
let nsfp_ids = Array.isArray(cfg.nsfp) ? [...cfg.nsfp.map(Number).filter(n => !isNaN(n))] : [];
export const getNsfpIds = () => nsfp_ids;
export const setNsfpIds = (ids) => {
nsfp_ids = Array.isArray(ids) ? ids.map(Number).filter(n => !isNaN(n) && n > 0) : [];
// Also sync to cfg.nsfp so all code reading cfg.nsfp directly still works
cfg.nsfp = [...nsfp_ids];
};

View File

@@ -2,7 +2,7 @@ import cfg from "../config.mjs";
import db from "../sql.mjs";
import lib from "../lib.mjs";
const regex = new RegExp(`(https?:\\/\\/${cfg.main.url.regex})(\\/(?:video|image|audio|tag\\/[^/\\s]+|user\\/[^/\\s]+(?:\\/favs)?|h\\/[^/\\s]+))?\\/(\\d+|(?:b\\/)\\w{8}\\.(?:jpg|webm|gif|mp4|png|mov|mp3|ogg|flac))`, 'gi');
const regex = new RegExp(`(https?:\\/\\/${cfg.main.url.regex})(\\/(?:video|image|audio|tag\\/[^/\\s]+|user\\/[^/\\s]+(?:\\/favs)?))?\\/(\\d+|(?:b\\/)\\w{8}\\.(?:jpg|webm|gif|mp4|png|mov|mp3|ogg|flac))`, 'gi');
export default async bot => {
@@ -12,10 +12,9 @@ export default async bot => {
active: true,
f: async e => {
const dat = e.message.match(regex)[0].split(/\//).pop();
const nsflId = cfg.nsfl_tag_id || 3;
const rows = await db`
select i.id, i.mime, i.size, i.username, i.stamp,
(select t.tag from tags_assign ta join tags t on t.id = ta.tag_id where ta.item_id = i.id and ta.tag_id in (1, 2, ${nsflId}) order by ta.tag_id asc limit 1) as rating
(select t.tag from tags_assign ta join tags t on t.id = ta.tag_id where ta.item_id = i.id and t.id in (1,2) limit 1) as rating
from "items" i
${dat.includes('.')
? db`where i.dest = ${dat}`
@@ -33,6 +32,15 @@ export default async bot => {
if (e.type === 'irc') {
const color = rating === 'sfw' ? 'green' : (rating === 'nsfw' ? 'red' : 'brown');
ratingStr = `[color=${color}]${rating}[/color]`;
} else if (e.type === 'matrix') {
const color = rating === 'sfw' ? '#00ff00' : (rating === 'nsfw' ? '#ff0000' : '#888888');
ratingStr = `[b][color=${color}]${rating}[/color][/b]`;
// matrix.mjs format() handles [b], but not [color].
// However, matrix.mjs send() handles objects with formatted_body.
// Let's use a simpler approach that works with the existing formatter if possible,
// or just construct the object.
} else if (e.type === 'tg') {
ratingStr = `[b]${rating}[/b]`;
}
const link = `${cfg.main.url.full}/${row.id}`.replace('http://', 'https://');

View File

@@ -34,7 +34,7 @@ export default async bot => {
rows = await db`
select id, mime, username, size
from "items"
where id >= ${randomId} and active = true and coalesce(visibility, 0) = 0
where id >= ${randomId} and active = true
order by id asc
limit 1
`;
@@ -43,7 +43,7 @@ export default async bot => {
rows = await db`
select id, mime, username, size
from "items"
where active = true and coalesce(visibility, 0) = 0
where active = true
order by id asc
limit 1
`;
@@ -54,7 +54,7 @@ export default async bot => {
rows = await db`
select id, mime, username, size
from "items"
where active = true and coalesce(visibility, 0) = 0 and
where
${args.map(a => a.charAt(0) === "!"
? db`username not ilike ${a.slice(1)}`
: db`username ilike ${a}`

View File

@@ -1,5 +1,4 @@
import { getLevel } from "../admin.mjs";
import cfg from "../config.mjs";
export default async self => {
return [{
@@ -46,11 +45,10 @@ export default async self => {
// Deduplicate and sort
const uniqueCommands = [...new Set(availableCommands)].sort();
const trigger = cfg.main.trigger || "!w";
if (uniqueCommands.length === 0) {
availableCommands.push(e.type === 'matrix' ? `${trigger} -sfw/nsfw -t tag1,tag2,tag3` : `${trigger} <url> or attachment`);
availableCommands.push(e.type === 'matrix' ? "!w -sfw/nsfw -t tag1,tag2,tag3" : "!w <url> or attachment");
} else {
availableCommands.push(e.type === 'matrix' ? `${trigger} -sfw/nsfw -t tag1,tag2,tag3` : `${trigger} <url> or attachment`);
availableCommands.push(e.type === 'matrix' ? "!w -sfw/nsfw -t tag1,tag2,tag3" : "!w <url> or attachment");
}
// Re-sort to include manually added command

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