This commit is contained in:
2026-10-01 00:58:00 +02:00
parent 61d86cf727
commit 3a88bb6ad3
14 changed files with 1076 additions and 43 deletions
+22
View File
@@ -110,3 +110,25 @@ To advertise your `.onion` address to Tor Browser users visiting your clearnet s
"onion": "http://yourgeneratedaddress.onion" "onion": "http://yourgeneratedaddress.onion"
} }
``` ```
## Chat uploads (temporary file hosting for mumh5)
Upload-only API keys for external chat clients. Files are public, never appear on the imageboard, and expire (default 30 days, see `chat_upload_*` in `config.yaml`).
Manage keys (the key is shown once, only its hash is stored):
`node scripts/chat-upload-key.mjs create <name> [max_mb]`
`node scripts/chat-upload-key.mjs list`
`node scripts/chat-upload-key.mjs revoke <id>`
Upload (raw body, returns JSON with `url` and `delete_token`):
```bash
curl -X POST https://your.host/api/chat/upload \
-H "X-API-Key: cu_..." -H "X-Filename: clip.webm" -H "X-Upload-Expiry: 7d" \
--data-binary @clip.webm
```
Delete: `curl -X DELETE https://your.host/api/chat/upload/<slug> -H "X-Delete-Token: ..."`
Files are served at `/cu/<slug>/<name>` with Range support. Only sniffed images, video and audio are served inline; anything else is forced to download.
+6
View File
@@ -131,6 +131,12 @@
"dm_attachments": true, "dm_attachments": true,
"dm_unencrypted": false, "dm_unencrypted": false,
"dm_attachment_expiry_days": 90, "dm_attachment_expiry_days": 90,
"chat_uploads": true,
"chat_upload_max_bytes": 104857600,
"chat_upload_expiry_days": 30,
"chat_upload_max_expiry_days": 30,
"chat_upload_rate_per_minute": 30,
"chat_link_previews": true,
"halls_enabled": true, "halls_enabled": true,
"userhalls_enabled": true, "userhalls_enabled": true,
"square_clicker_enabled": true, "square_clicker_enabled": true,
+13
View File
@@ -126,6 +126,19 @@ websrv:
dm_attachments: true dm_attachments: true
dm_unencrypted: false dm_unencrypted: false
dm_attachment_expiry_days: 90 dm_attachment_expiry_days: 90
# Temporary chat file hosting for external clients (mumh5), keys via scripts/chat-upload-key.mjs
chat_uploads: true
chat_upload_max_bytes: 104857600
chat_upload_expiry_days: 30
chat_upload_max_expiry_days: 30
chat_upload_rate_per_minute: 30
# Link previews for chat clients, fetched by this server (needs a chat upload key)
chat_link_previews: true
# Optional: allowed types for chat uploads, same format as allowedMimes (defaults to allowedMimes)
# chat_upload_mimes:
# - image
# - video
# - audio
halls_enabled: true halls_enabled: true
userhalls_enabled: true userhalls_enabled: true
# Square Clicker: audio/video items become playable rhythm-game maps (hotkey o); false turns it off # Square Clicker: audio/video items become playable rhythm-game maps (hotkey o); false turns it off
+1
View File
@@ -19,6 +19,7 @@ services:
- ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z - ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z
- ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z - ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z
- ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z - ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z
- ./f0ckm-data/cu/:/opt/f0ckm/public/cu/:Z
- ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z - ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z
- ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z - ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z
- ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z - ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z
+25
View File
@@ -0,0 +1,25 @@
-- Migration: temporary chat file hosting for external clients (mumh5)
CREATE TABLE IF NOT EXISTS public.upload_api_keys (
id serial PRIMARY KEY,
name text NOT NULL,
key_hash text NOT NULL UNIQUE,
max_bytes bigint DEFAULT NULL,
revoked boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
last_used_at timestamp with time zone
);
CREATE TABLE IF NOT EXISTS public.chat_uploads (
id bigserial PRIMARY KEY,
slug text NOT NULL UNIQUE,
key_id integer REFERENCES public.upload_api_keys(id) ON DELETE SET NULL,
original_name text DEFAULT ''::text NOT NULL,
mime text NOT NULL,
mime_hint text DEFAULT ''::text NOT NULL,
size_bytes bigint DEFAULT 0 NOT NULL,
delete_token_hash text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_uploads_expires_at ON public.chat_uploads(expires_at);
+32
View File
@@ -291,6 +291,8 @@ DROP TABLE IF EXISTS public.global_chat;
DROP TABLE IF EXISTS public.favorites; DROP TABLE IF EXISTS public.favorites;
DROP SEQUENCE IF EXISTS public.dm_attachments_id_seq; DROP SEQUENCE IF EXISTS public.dm_attachments_id_seq;
DROP TABLE IF EXISTS public.dm_attachments; DROP TABLE IF EXISTS public.dm_attachments;
DROP TABLE IF EXISTS public.chat_uploads;
DROP TABLE IF EXISTS public.upload_api_keys;
DROP SEQUENCE IF EXISTS public.discord_queue_id_seq; DROP SEQUENCE IF EXISTS public.discord_queue_id_seq;
DROP TABLE IF EXISTS public.discord_queue; DROP TABLE IF EXISTS public.discord_queue;
DROP SEQUENCE IF EXISTS public.custom_emojis_id_seq; DROP SEQUENCE IF EXISTS public.custom_emojis_id_seq;
@@ -3706,6 +3708,36 @@ CREATE INDEX IF NOT EXISTS idx_sqc_scores_map ON public.sqc_scores(map_id, score
CREATE PUBLICATION alltables FOR ALL TABLES WITH (publish = 'insert, update, delete, truncate'); CREATE PUBLICATION alltables FOR ALL TABLES WITH (publish = 'insert, update, delete, truncate');
--
-- Name: upload_api_keys, chat_uploads; temporary chat file hosting (mumh5)
--
CREATE TABLE IF NOT EXISTS public.upload_api_keys (
id serial PRIMARY KEY,
name text NOT NULL,
key_hash text NOT NULL UNIQUE,
max_bytes bigint DEFAULT NULL,
revoked boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
last_used_at timestamp with time zone
);
CREATE TABLE IF NOT EXISTS public.chat_uploads (
id bigserial PRIMARY KEY,
slug text NOT NULL UNIQUE,
key_id integer REFERENCES public.upload_api_keys(id) ON DELETE SET NULL,
original_name text DEFAULT ''::text NOT NULL,
mime text NOT NULL,
mime_hint text DEFAULT ''::text NOT NULL,
size_bytes bigint DEFAULT 0 NOT NULL,
delete_token_hash text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_chat_uploads_expires_at ON public.chat_uploads(expires_at);
-- --
-- PostgreSQL database dump complete -- PostgreSQL database dump complete
-- --
+10 -5
View File
@@ -7373,11 +7373,14 @@ window.cancelAnimFrame = (function () {
const f = input.files && input.files[0]; const f = input.files && input.files[0];
input.value = ''; input.value = '';
if (!f || !api()) return; if (!f || !api()) return;
const r = await api().setCustomSound(f); const r = api().setFile ? await api().setFile(key, f) : await api().setCustomSound(f);
if (!r.ok && window.flashMessage) window.flashMessage(r.msg || 'Could not use that file', 3000, 'error'); if (!r.ok && window.flashMessage) window.flashMessage(r.msg || 'Could not use that file', 3000, 'error');
else if (api()) api().preview(); else if (key === 'customSound' && api()) api().preview();
});
rowEl.querySelector('[data-sqc-clear]').addEventListener('click', () => {
if (!api()) return;
if (api().clearFile) api().clearFile(key); else api().clearCustomSound();
}); });
rowEl.querySelector('[data-sqc-clear]').addEventListener('click', () => { if (api()) api().clearCustomSound(); });
return; return;
} }
const input = rowEl.querySelector('input, select'); const input = rowEl.querySelector('input, select');
@@ -7406,8 +7409,10 @@ window.cancelAnimFrame = (function () {
const row = a.schema.find(r => r.key === key); const row = a.schema.find(r => r.key === key);
const valEl = rowEl.querySelector('[data-sqc-value]'); const valEl = rowEl.querySelector('[data-sqc-value]');
if (row.type === 'file') { if (row.type === 'file') {
if (valEl) valEl.textContent = st.customSound || 'none'; // file name, dimmed while the setting that uses it is not selected (activeWhen: [key, value])
rowEl.style.opacity = st.hitSound === 'custom' || !st.customSound ? '' : '0.6'; if (valEl) valEl.textContent = st[key] || 'none';
const on = !row.activeWhen || st[row.activeWhen[0]] === row.activeWhen[1];
rowEl.style.opacity = on || !st[key] ? '' : '0.6';
return; return;
} }
if (row.showIf) rowEl.style.display = +st[row.showIf] ? '' : 'none'; if (row.showIf) rowEl.style.display = +st[row.showIf] ? '' : 'none';
+273 -36
View File
@@ -26,9 +26,14 @@
const SETTINGS_KEY = 'f0ck_sqc_settings'; const SETTINGS_KEY = 'f0ck_sqc_settings';
const CUSTOM_SOUND_KEY = 'f0ck_sqc_custom_sound'; const CUSTOM_SOUND_KEY = 'f0ck_sqc_custom_sound';
const CUSTOM_SOUND_MAX = 700 * 1024; // bytes of the uploaded file const CUSTOM_SOUND_MAX = 700 * 1024; // bytes of the uploaded file
const NOTE_IMAGE_KEY = 'f0ck_sqc_note_image'; // note image / GIF (tuner: note shape "Image"), data URL
const NOTE_IMAGE_MAX = 2 * 1024 * 1024;
const DEFAULT_HIT_SOUND = '/s/sfx/hitsound.mp3';
const LAST_SETTING_KEY = 'f0ck_sqc_last_setting'; // tuner row changed last (the pause menu's tuner jumps there)
let noteImg = null, noteImgFor = null; // loaded note image (see loadNoteImage)
const SQC_DEFAULTS = { const SQC_DEFAULTS = {
hitSound: 'tick', volume: 0.35, missSound: 1, countdownTicks: 1, hitSound: 'default', volume: 0.35, missSound: 1, countdownTicks: 1,
approachMs: 900, noteSize: 70, noteNumbers: 0, followMul: 1.6, leadInMs: 1500, offsetMs: 0, approachMs: 900, noteSize: 70, noteShape: 'square', noteNumbers: 0, followMul: 1.6, leadInMs: 1500, offsetMs: 0,
dimColor: '#000000', dimAudio: 0.35, dimVideo: 0.15, dimColor: '#000000', dimAudio: 0.35, dimVideo: 0.15,
autoPreset: 'balanced', autoDifficulty: 'normal', autoPreset: 'balanced', autoDifficulty: 'normal',
bpmMode: 'auto', bpm: 120, beatOffsetMs: 0, beatsPerBar: 4, bpmMode: 'auto', bpm: 120, beatOffsetMs: 0, beatsPerBar: 4,
@@ -40,7 +45,12 @@
autoJump: 1, autoSliderChance: 0.45, autoSliderGapMs: 750, autoJump: 1, autoSliderChance: 0.45, autoSliderGapMs: 750,
}; };
const O = Object.assign({}, SQC_DEFAULTS); const O = Object.assign({}, SQC_DEFAULTS);
try { Object.assign(O, JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}')); } catch (_) {} try {
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}');
// settings saved before the hitsound.mp3 default: the old default 'tick' becomes the new default
if (stored && !stored.v && stored.hitSound === 'tick') stored.hitSound = 'default';
Object.assign(O, stored, { v: 2 });
} catch (_) {}
const saveSettings = () => { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(O)); } catch (_) {} }; const saveSettings = () => { try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(O)); } catch (_) {} };
// Values derived from the settings (updated by applySettings) // Values derived from the settings (updated by applySettings)
@@ -49,6 +59,7 @@
APPROACH_MS = Math.max(300, +O.approachMs || 900); // how early a note appears APPROACH_MS = Math.max(300, +O.approachMs || 900); // how early a note appears
NOTE = Math.max(30, +O.noteSize || 70); // note size, px NOTE = Math.max(30, +O.noteSize || 70); // note size, px
LEAD_IN_MS = Math.max(0, +O.leadInMs || 0); // countdown before the song starts LEAD_IN_MS = Math.max(0, +O.leadInMs || 0); // countdown before the song starts
if (typeof loadNoteImage === 'function') loadNoteImage();
FOLLOW = NOTE * Math.max(0.8, +O.followMul || 1.6); // half-size of the slider follow zone FOLLOW = NOTE * Math.max(0.8, +O.followMul || 1.6); // half-size of the slider follow zone
}; };
applySettings(); applySettings();
@@ -80,7 +91,7 @@
s.view.ov.addEventListener('contextmenu', (e) => e.preventDefault()); s.view.ov.addEventListener('contextmenu', (e) => e.preventDefault());
s.onKey = (e) => { s.onKey = (e) => {
if (!S) return; if (!S) return;
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (!e.repeat) (S.paused ? resume() : pause()); return; } if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); if (!e.repeat) (S.paused ? resume() : pause(true)); return; }
if (e.ctrlKey || e.altKey || e.metaKey || /^F\d{1,2}$/.test(e.key)) return; if (e.ctrlKey || e.altKey || e.metaKey || /^F\d{1,2}$/.test(e.key)) return;
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
if (!e.repeat) down('key:' + e.code, S.mouse.x, S.mouse.y); if (!e.repeat) down('key:' + e.code, S.mouse.x, S.mouse.y);
@@ -109,6 +120,8 @@
if (e.type === 'keydown' && (e.key === 'Enter' || e.key === 'Escape')) tg.blur(); if (e.type === 'keydown' && (e.key === 'Enter' || e.key === 'Escape')) tg.blur();
return; return;
} }
// Settings open: keys for the tuner's controls (sliders, selects) do their normal thing; Esc resumes
if (S.settings && tg && tg.closest && tg.closest(SIDEBAR_SEL) && e.key !== 'Escape') return;
if (e.type === 'keydown') S.onKey(e); if (e.type === 'keydown') S.onKey(e);
else if (e.type === 'keyup') { if (S.onKeyUp) S.onKeyUp(e); } else if (e.type === 'keyup') { if (S.onKeyUp) S.onKeyUp(e); }
else if (!(e.ctrlKey || e.altKey || e.metaKey)) e.preventDefault(); // keypress else if (!(e.ctrlKey || e.altKey || e.metaKey)) e.preventDefault(); // keypress
@@ -275,6 +288,59 @@
const l = p[p.length - 1]; const l = p[p.length - 1];
return { x: l[1], y: l[2] }; return { x: l[1], y: l[2] };
}; };
// ── note shapes (tuner: note shape) ──
// Outline of a note of size s px centred on x, y. "image" draws the uploaded picture / GIF as the note
// body; its outlines (approach frame, ball, follow zone) are circles.
const SHAPES = [['square', 'Square'], ['circle', 'Circle'], ['diamond', 'Diamond'], ['hexagon', 'Hexagon'],
['octagon', 'Octagon'], ['triangle', 'Triangle'], ['star', 'Star'], ['image', 'Image / GIF (upload below)']];
const shapePath = (ctx, x, y, s) => {
const r = s / 2;
const poly = (k, rot, rr) => {
for (let i = 0; i < k; i++) { const a = rot + i * 2 * Math.PI / k; ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
};
ctx.beginPath();
switch (O.noteShape) {
case 'circle': case 'image': ctx.arc(x, y, r, 0, 2 * Math.PI); break;
case 'diamond': poly(4, -Math.PI / 2, r * 1.2); break;
case 'hexagon': poly(6, 0, r * 1.08); break;
case 'octagon': poly(8, Math.PI / 8, r * 1.05); break;
case 'triangle': poly(3, -Math.PI / 2, r * 1.25); break;
case 'star':
for (let i = 0; i < 10; i++) { const a = -Math.PI / 2 + i * Math.PI / 5, rr = i % 2 ? r * 0.55 : r * 1.2; ctx[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr); }
break;
default: ctx.rect(x - r, y - r, s, s);
}
ctx.closePath();
};
const strokeShape = (ctx, x, y, s) => { shapePath(ctx, x, y, s); ctx.stroke(); };
const fillShape = (ctx, x, y, s) => { shapePath(ctx, x, y, s); ctx.fill(); };
// The uploaded note image. Kept in the page (tiny, invisible) so animated GIFs keep animating when
// drawn onto the canvas (Chromium only advances GIF frames of images that are in the document).
function loadNoteImage() {
let url = null;
if (O.noteShape === 'image') { try { url = localStorage.getItem(NOTE_IMAGE_KEY); } catch (_) {} }
if (url === noteImgFor) return;
if (noteImg) noteImg.remove();
noteImg = null; noteImgFor = url;
if (!url) return;
noteImg = new Image();
noteImg.alt = '';
noteImg.style.cssText = 'position: fixed; left: 0; top: 0; width: 1px; height: 1px; opacity: 0.01; pointer-events: none; z-index: -1;';
noteImg.src = url;
document.body.appendChild(noteImg);
}
// A note's body: the image, or the shape filled dark with an accent outline
const drawNoteBody = (ctx, x, y, s, col, lineWidth) => {
if (O.noteShape === 'image' && noteImg && noteImg.complete && noteImg.naturalWidth) {
const k = s * 1.1 / Math.max(noteImg.naturalWidth, noteImg.naturalHeight);
const w = noteImg.naturalWidth * k, h = noteImg.naturalHeight * k;
ctx.drawImage(noteImg, x - w / 2, y - h / 2, w, h);
return;
}
ctx.fillStyle = 'rgba(0,0,0,0.55)'; fillShape(ctx, x, y, s);
ctx.strokeStyle = col; ctx.lineWidth = lineWidth || 3; strokeShape(ctx, x, y, s);
};
// Stroke a slider track (path in viewport fractions) as a wide band: accent edge, dark core // Stroke a slider track (path in viewport fractions) as a wide band: accent edge, dark core
const drawTrack = (ctx, pts, col, alpha) => { const drawTrack = (ctx, pts, col, alpha) => {
if (!pts || pts.length < 2) return; if (!pts || pts.length < 2) return;
@@ -288,7 +354,7 @@
ctx.restore(); ctx.restore();
const e = pts[pts.length - 1]; const e = pts[pts.length - 1];
ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.save(); ctx.globalAlpha = alpha; ctx.strokeStyle = col; ctx.lineWidth = 2;
ctx.strokeRect(e[1] * innerWidth - NOTE * 0.3, e[2] * innerHeight - NOTE * 0.3, NOTE * 0.6, NOTE * 0.6); strokeShape(ctx, e[1] * innerWidth, e[2] * innerHeight, NOTE * 0.6);
ctx.restore(); ctx.restore();
}; };
@@ -341,6 +407,7 @@
const Sfx = (() => { const Sfx = (() => {
let ctx = null, noise = null; let ctx = null, noise = null;
let customBuf = null, customFor = null; // decoded upload + the data URL it came from let customBuf = null, customFor = null; // decoded upload + the data URL it came from
let defaultBuf = null, defaultLoading = false; // hitsound.mp3, the default hit sound
// Created/resumed from a click (browsers only allow audio after a user gesture) // Created/resumed from a click (browsers only allow audio after a user gesture)
const ensure = () => { const ensure = () => {
if (!ctx) { if (!ctx) {
@@ -382,6 +449,17 @@
case 'soft': tone(c, dest, t0, 'sine', 950 * m, 800 * m, 0.09, 0.6); return; case 'soft': tone(c, dest, t0, 'sine', 950 * m, 800 * m, 0.09, 0.6); return;
case 'chip': tone(c, dest, t0, 'square', 880 * m, 870 * m, 0.05, 0.25); return; case 'chip': tone(c, dest, t0, 'square', 880 * m, 870 * m, 0.05, 0.25); return;
case 'wood': tone(c, dest, t0, 'triangle', 620 * m, 560 * m, 0.05, 0.7); snap(c, dest, t0, 820 * m, 6, 0.04, 0.5); return; case 'wood': tone(c, dest, t0, 'triangle', 620 * m, 560 * m, 0.05, 0.7); snap(c, dest, t0, 820 * m, 6, 0.04, 0.5); return;
case 'default':
if (defaultBuf) {
const src = c.createBufferSource(); src.buffer = defaultBuf; src.playbackRate.value = m;
src.connect(dest); src.start(t0);
return;
}
loadDefault();
// not decoded (yet): the synthesized tick meanwhile
snap(c, dest, t0, 4200 * m, 1.2, 0.05, 0.9);
tone(c, dest, t0, 'triangle', 1400 * m, 700 * m, 0.08, 0.5);
return;
case 'custom': case 'custom':
if (customBuf) { if (customBuf) {
const src = c.createBufferSource(); src.buffer = customBuf; src.playbackRate.value = m; const src = c.createBufferSource(); src.buffer = customBuf; src.playbackRate.value = m;
@@ -399,6 +477,14 @@
if (!c) return; if (!c) return;
tone(c, out(c, 0.9), c.currentTime, 'sine', 170, 55, 0.18, 1); tone(c, out(c, 0.9), c.currentTime, 'sine', 170, 55, 0.18, 1);
}; };
const loadDefault = async () => {
if (defaultBuf || defaultLoading) return;
const c = ensure();
if (!c) return;
defaultLoading = true;
try { defaultBuf = await c.decodeAudioData(await (await fetch(DEFAULT_HIT_SOUND)).arrayBuffer()); } catch (_) {}
defaultLoading = false;
};
// Decode the uploaded sound (data URL in localStorage) once an audio context exists // Decode the uploaded sound (data URL in localStorage) once an audio context exists
const loadCustom = async () => { const loadCustom = async () => {
let url = null; let url = null;
@@ -425,7 +511,7 @@
}; };
return { return {
metronome, metronome,
unlock() { ensure(); if (O.hitSound === 'custom') loadCustom(); }, unlock() { ensure(); if (O.hitSound === 'custom') loadCustom(); if (O.hitSound === 'default') loadDefault(); },
loadCustom, loadCustom,
play(kind) { play(kind) {
switch (kind) { switch (kind) {
@@ -443,7 +529,7 @@
// ── styles (this script is only loaded when the feature is on) ───────────── // ── styles (this script is only loaded when the feature is on) ─────────────
const style = el('style'); const style = el('style');
style.textContent = ` style.textContent = `
.sqc-panel { position: fixed; right: 20px; bottom: 20px; z-index: 2147482000; width: 340px; max-height: 70vh; .sqc-panel { position: fixed; right: 20px; bottom: 20px; transition: right 600ms cubic-bezier(0.45, 0, 0.2, 1); z-index: 2147482000; width: 340px; max-height: 70vh;
overflow-y: auto; background: var(--dropdown-bg, #232323); border: 1px solid var(--accent, #9f0); overflow-y: auto; background: var(--dropdown-bg, #232323); border: 1px solid var(--accent, #9f0);
color: var(--white, #fff); font-family: var(--font, monospace); font-size: 13px; } color: var(--white, #fff); font-family: var(--font, monospace); font-size: 13px; }
.sqc-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px; .sqc-panel header { display: flex; align-items: center; justify-content: space-between; gap: 8px;
@@ -464,8 +550,18 @@
.sqc-panel table { width: 100%; border-collapse: collapse; font-size: 12px; } .sqc-panel table { width: 100%; border-collapse: collapse; font-size: 12px; }
.sqc-panel td { padding: 3px 4px; border-bottom: 1px solid var(--nav-border-color, #333); } .sqc-panel td { padding: 3px 4px; border-bottom: 1px solid var(--nav-border-color, #333); }
.sqc-panel .sqc-grade { font-size: 44px; font-weight: bold; color: var(--accent, #9f0); line-height: 1; } .sqc-panel .sqc-grade { font-size: 44px; font-weight: bold; color: var(--accent, #9f0); line-height: 1; }
.sqc-overlay { position: fixed; inset: 0; z-index: 2147481000; cursor: default; touch-action: none; } .sqc-overlay { position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; z-index: 2147481000; cursor: default; touch-action: none; overflow: hidden; }
.sqc-overlay canvas { position: absolute; inset: 0; width: 100%; height: 100%; } .sqc-overlay canvas { position: absolute; left: 0; top: 0; display: block; }
.sqc-pausebtn { position: absolute; left: 8px; top: 42px; z-index: 3; width: 44px; height: 44px; padding: 0;
display: flex; align-items: center; justify-content: center; font-size: 18px; cursor: pointer;
background: rgba(0,0,0,0.55); color: var(--white, #fff); border: 1px solid var(--accent, #9f0); }
.sqc-overlay.sqc-is-paused .sqc-pausebtn { display: none; }
body.sqc-settings .global-sidebar-right, body.sqc-settings .item-sidebar-right, body.sqc-settings .index-sidebar-right { z-index: 2147481500 !important; }
.sqc-settings-back { display: none; position: fixed; left: 12px; top: 12px; z-index: 2147482100; padding: 8px 14px; cursor: pointer;
background: var(--accent, #9f0); color: var(--black, #000); border: 1px solid var(--accent, #9f0); font-family: var(--font, monospace); font-weight: bold; }
/* phones: the sidebar covers the game and its pause menu, this gets back to them */
@media (max-width: 599px), (hover: none) and (pointer: coarse) { .sqc-settings-back { display: block; } }
.sqc-last-setting { outline: 1px solid var(--accent, #9f0); outline-offset: 2px; transition: outline-color 1.5s; }
.sqc-pause { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; width: 260px; .sqc-pause { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 2; width: 260px;
display: flex; flex-direction: column; gap: 8px; padding: 16px; background: var(--dropdown-bg, #232323); display: flex; flex-direction: column; gap: 8px; padding: 16px; background: var(--dropdown-bg, #232323);
border: 1px solid var(--accent, #9f0); font-family: var(--font, monospace); color: var(--white, #fff); cursor: default; } border: 1px solid var(--accent, #9f0); font-family: var(--font, monospace); color: var(--white, #fff); cursor: default; }
@@ -840,15 +936,17 @@
const SCHEMA = [ const SCHEMA = [
{ section: 'Sounds' }, { section: 'Sounds' },
{ key: 'hitSound', label: 'Hit sound', type: 'select', options: [ { key: 'hitSound', label: 'Hit sound', type: 'select', options: [
['tick', 'Tick'], ['clap', 'Clap'], ['drum', 'Drum'], ['soft', 'Soft'], ['chip', '8-bit'], ['wood', 'Wood'], ['custom', 'Custom sound file'], ['none', 'None'], ['default', 'Default (hitsound.mp3)'], ['tick', 'Tick'], ['clap', 'Clap'], ['drum', 'Drum'], ['soft', 'Soft'], ['chip', '8-bit'], ['wood', 'Wood'], ['custom', 'Custom sound file'], ['none', 'None'],
] }, ] },
{ key: 'customSound', label: 'Custom sound file', type: 'file', accept: 'audio/*' }, { key: 'customSound', label: 'Custom sound file', type: 'file', accept: 'audio/*', activeWhen: ['hitSound', 'custom'] },
{ key: 'volume', label: 'Volume', type: 'range', min: 0, max: 1, step: 0.05 }, { key: 'volume', label: 'Volume', type: 'range', min: 0, max: 1, step: 0.05 },
{ key: 'missSound', label: 'Combo break sound', type: 'toggle' }, { key: 'missSound', label: 'Combo break sound', type: 'toggle' },
{ key: 'countdownTicks', label: 'Countdown ticks', type: 'toggle' }, { key: 'countdownTicks', label: 'Countdown ticks', type: 'toggle' },
{ section: 'Gameplay' }, { section: 'Gameplay' },
{ key: 'approachMs', label: 'Approach time (lower = faster notes)', type: 'range', min: 400, max: 1800, step: 50, unit: 'ms' }, { key: 'approachMs', label: 'Approach time (lower = faster notes)', type: 'range', min: 400, max: 1800, step: 50, unit: 'ms' },
{ key: 'noteSize', label: 'Note size', type: 'range', min: 40, max: 130, step: 2, unit: 'px' }, { key: 'noteSize', label: 'Note size', type: 'range', min: 40, max: 130, step: 2, unit: 'px' },
{ key: 'noteShape', label: 'Note shape', type: 'select', options: SHAPES },
{ key: 'noteImage', label: 'Note image / GIF', type: 'file', accept: 'image/*', activeWhen: ['noteShape', 'image'] },
{ key: 'noteNumbers', label: 'Numbers on notes', type: 'toggle' }, { key: 'noteNumbers', label: 'Numbers on notes', type: 'toggle' },
{ key: 'followMul', label: 'Slider follow zone', type: 'range', min: 1, max: 3, step: 0.1, unit: 'x' }, { key: 'followMul', label: 'Slider follow zone', type: 'range', min: 1, max: 3, step: 0.1, unit: 'x' },
{ key: 'leadInMs', label: 'Countdown before the song', type: 'range', min: 0, max: 5000, step: 250, unit: 'ms' }, { key: 'leadInMs', label: 'Countdown before the song', type: 'range', min: 0, max: 5000, step: 250, unit: 'ms' },
@@ -891,17 +989,45 @@
{ key: 'autoSliderGapMs', label: 'Gap needed for a slider', type: 'range', min: 200, max: 3000, step: 50, unit: 'ms', showIf: 'autoAdvanced' }, { key: 'autoSliderGapMs', label: 'Gap needed for a slider', type: 'range', min: 200, max: 3000, step: 50, unit: 'ms', showIf: 'autoAdvanced' },
]; ];
const customSoundName = () => { try { return localStorage.getItem(CUSTOM_SOUND_KEY + '_name') || ''; } catch (_) { return ''; } }; const customSoundName = () => { try { return localStorage.getItem(CUSTOM_SOUND_KEY + '_name') || ''; } catch (_) { return ''; } };
const noteImageName = () => { try { return localStorage.getItem(NOTE_IMAGE_KEY + '_name') || ''; } catch (_) { return ''; } };
const readDataUrl = (file) => new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); });
const settingsChanged = () => { const settingsChanged = () => {
saveSettings(); saveSettings();
applySettings(); applySettings();
window.dispatchEvent(new CustomEvent('f0ck:sqc_settings_changed', { detail: settingsApi.getState() })); window.dispatchEvent(new CustomEvent('f0ck:sqc_settings_changed', { detail: settingsApi.getState() }));
}; };
const rememberSetting = (key) => { try { localStorage.setItem(LAST_SETTING_KEY, key); } catch (_) {} };
const settingsApi = { const settingsApi = {
schema: SCHEMA, schema: SCHEMA,
defaults: Object.assign({}, SQC_DEFAULTS), defaults: Object.assign({}, SQC_DEFAULTS),
getState: () => Object.assign({}, O, { customSound: customSoundName() }), getState: () => Object.assign({}, O, { customSound: customSoundName(), noteImage: noteImageName() }),
// File rows of the schema: the hit sound (customSound) or the note image (noteImage)
async setFile(key, file) {
rememberSetting(key);
if (key === 'customSound') return settingsApi.setCustomSound(file);
if (key !== 'noteImage') return { ok: false, msg: 'Unknown setting' };
if (!file || !/^image\//.test(file.type)) return { ok: false, msg: 'Pick an image or GIF' };
if (file.size > NOTE_IMAGE_MAX) return { ok: false, msg: 'Image too large (max 2 MB)' };
const url = await readDataUrl(file);
try {
localStorage.setItem(NOTE_IMAGE_KEY, url);
localStorage.setItem(NOTE_IMAGE_KEY + '_name', file.name);
} catch (_) { return { ok: false, msg: 'Not enough browser storage' }; }
O.noteShape = 'image';
noteImgFor = null; // reload even if the same shape was already on
settingsChanged();
return { ok: true };
},
clearFile(key) {
if (key === 'customSound') return settingsApi.clearCustomSound();
if (key !== 'noteImage') return;
try { localStorage.removeItem(NOTE_IMAGE_KEY); localStorage.removeItem(NOTE_IMAGE_KEY + '_name'); } catch (_) {}
if (O.noteShape === 'image') O.noteShape = 'square';
settingsChanged();
},
set(key, val) { set(key, val) {
if (!(key in SQC_DEFAULTS)) return; if (!(key in SQC_DEFAULTS)) return;
rememberSetting(key);
O[key] = typeof SQC_DEFAULTS[key] === 'number' ? (Number(val) || 0) : String(val); O[key] = typeof SQC_DEFAULTS[key] === 'number' ? (Number(val) || 0) : String(val);
settingsChanged(); settingsChanged();
if (key === 'hitSound' && val === 'custom') Sfx.loadCustom(); if (key === 'hitSound' && val === 'custom') Sfx.loadCustom();
@@ -911,7 +1037,7 @@
async setCustomSound(file) { async setCustomSound(file) {
if (!file) return { ok: false, msg: 'No file' }; if (!file) return { ok: false, msg: 'No file' };
if (file.size > CUSTOM_SOUND_MAX) return { ok: false, msg: 'File too large (max 700 KB)' }; if (file.size > CUSTOM_SOUND_MAX) return { ok: false, msg: 'File too large (max 700 KB)' };
const url = await new Promise((res, rej) => { const r = new FileReader(); r.onload = () => res(r.result); r.onerror = rej; r.readAsDataURL(file); }); const url = await readDataUrl(file);
try { try {
localStorage.setItem(CUSTOM_SOUND_KEY, url); localStorage.setItem(CUSTOM_SOUND_KEY, url);
localStorage.setItem(CUSTOM_SOUND_KEY + '_name', file.name); localStorage.setItem(CUSTOM_SOUND_KEY + '_name', file.name);
@@ -923,7 +1049,7 @@
}, },
clearCustomSound() { clearCustomSound() {
try { localStorage.removeItem(CUSTOM_SOUND_KEY); localStorage.removeItem(CUSTOM_SOUND_KEY + '_name'); } catch (_) {} try { localStorage.removeItem(CUSTOM_SOUND_KEY); localStorage.removeItem(CUSTOM_SOUND_KEY + '_name'); } catch (_) {}
if (O.hitSound === 'custom') O.hitSound = 'tick'; if (O.hitSound === 'custom') O.hitSound = 'default';
Sfx.loadCustom(); Sfx.loadCustom();
settingsChanged(); settingsChanged();
}, },
@@ -1038,6 +1164,16 @@
input.click(); input.click();
}; };
const closePanel = () => { if (panel) panel.remove(); panel = null; }; const closePanel = () => { if (panel) panel.remove(); panel = null; };
// The maps panel sits left of the right sidebar when that is open (on phones the sidebar covers the
// screen: there the panel stays at the edge). Follows the sidebar opening / closing.
const placePanel = () => {
if (!panel) return;
const bar = document.querySelector(SIDEBAR_SEL);
const open = bar && !document.body.classList.contains('sidebar-right-hidden') && innerWidth > 599;
panel.style.right = (open ? (bar.offsetWidth || 300) + 20 : 20) + 'px';
};
new MutationObserver(placePanel).observe(document.body, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('resize', placePanel);
const openPanel = (title) => { const openPanel = (title) => {
closePanel(); closePanel();
panelFor = null; // set again by showMaps; other panels (draft, results) stay when the album entry changes panelFor = null; // set again by showMaps; other panels (draft, results) stay when the album entry changes
@@ -1052,6 +1188,7 @@
const body = el('div', 'sqc-body'); const body = el('div', 'sqc-body');
panel.appendChild(body); panel.appendChild(body);
document.body.appendChild(panel); document.body.appendChild(panel);
placePanel();
return body; return body;
}; };
@@ -1171,8 +1308,14 @@
ov.appendChild(cv); ov.appendChild(cv);
document.body.appendChild(ov); document.body.appendChild(ov);
const ctx = cv.getContext('2d'); const ctx = cv.getContext('2d');
// The overlay is sized to exactly innerWidth x innerHeight (the coordinates everything is drawn and
// hit-tested in) and the canvas backing store to that at the real pixel ratio. A plain inset: 0
// overlay leaves out the page scrollbar in Chromium, so the canvas was squeezed by the scrollbar
// width: resampled every frame (shimmering, smeared edges) and drawn slightly off from where clicks count.
const size = () => { const size = () => {
const dpr = Math.min(1.5, window.devicePixelRatio || 1); const dpr = Math.min(2, window.devicePixelRatio || 1);
ov.style.width = innerWidth + 'px'; ov.style.height = innerHeight + 'px';
cv.style.width = innerWidth + 'px'; cv.style.height = innerHeight + 'px';
cv.width = Math.round(innerWidth * dpr); cv.height = Math.round(innerHeight * dpr); cv.width = Math.round(innerWidth * dpr); cv.height = Math.round(innerHeight * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}; };
@@ -1186,6 +1329,7 @@
window.removeEventListener('blur', S.onBlur); window.removeEventListener('blur', S.onBlur);
window.removeEventListener('resize', S.view.size); window.removeEventListener('resize', S.view.size);
S.audio.removeEventListener('ended', S.onEnded); S.audio.removeEventListener('ended', S.onEnded);
closeSettings();
S.view.ov.remove(); S.view.ov.remove();
document.body.classList.remove('sqc-session'); document.body.classList.remove('sqc-session');
const s = S; const s = S;
@@ -1201,6 +1345,14 @@
const view = makeOverlay(); const view = makeOverlay();
S = Object.assign({ mode, audio, view, lastAudioT: -1, lastPerf: 0, mouse: { x: innerWidth / 2, y: innerHeight / 2 }, fx: [] }, extra); S = Object.assign({ mode, audio, view, lastAudioT: -1, lastPerf: 0, mouse: { x: innerWidth / 2, y: innerHeight / 2 }, fx: [] }, extra);
window.addEventListener('resize', view.size); window.addEventListener('resize', view.size);
// Pause button: the way to pause (and from the menu stop) without a keyboard, e.g. on phones
const pb = el('button', 'sqc-pausebtn');
pb.type = 'button';
pb.title = 'Pause (Esc)';
pb.setAttribute('aria-label', 'Pause');
pb.innerHTML = '<i class="fa-solid fa-pause"></i>';
pb.addEventListener('pointerdown', (e) => { e.preventDefault(); e.stopPropagation(); pause(true); });
view.ov.appendChild(pb);
// No player controls popping up while playing (v0ck ignores hover during a session, see v0ck.js) // No player controls popping up while playing (v0ck ignores hover during a session, see v0ck.js)
document.body.classList.add('sqc-session'); document.body.classList.add('sqc-session');
document.querySelectorAll('.v0ck_hover').forEach(e => e.classList.remove('v0ck_hover')); document.querySelectorAll('.v0ck_hover').forEach(e => e.classList.remove('v0ck_hover'));
@@ -1251,7 +1403,7 @@
ctx.globalAlpha = 1 - p; ctx.globalAlpha = 1 - p;
ctx.strokeStyle = f.color; ctx.lineWidth = 2; ctx.strokeStyle = f.color; ctx.lineWidth = 2;
const s = NOTE * (1 + p * 0.6); const s = NOTE * (1 + p * 0.6);
ctx.strokeRect(f.x - s / 2, f.y - s / 2, s, s); strokeShape(ctx, f.x, f.y, s);
if (f.text) { if (f.text) {
ctx.fillStyle = f.color; ctx.font = 'bold 22px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = f.color; ctx.font = 'bold 22px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(f.text, f.x, f.y - NOTE * 0.8 - p * 14); ctx.fillText(f.text, f.x, f.y - NOTE * 0.8 - p * 14);
@@ -1274,7 +1426,8 @@
// Esc (or leaving the window) pauses: song, notes, metronome and countdown freeze, hits are ignored. // Esc (or leaving the window) pauses: song, notes, metronome and countdown freeze, hits are ignored.
// Esc again / Resume continues after a short get-ready countdown. // Esc again / Resume continues after a short get-ready countdown.
const RESUME_MS = 1000; const RESUME_MS = 1000;
const pause = () => { // byUser (Esc, the pause button): the tuner opens next to the menu. Pauses from leaving the window don't.
const pause = (byUser) => {
if (!S || S.paused || S.finished) return; if (!S || S.paused || S.finished) return;
S.paused = true; S.paused = true;
if (S.leadUntil) S.leadRemaining = Math.max(0, S.leadUntil - performance.now()); if (S.leadUntil) S.leadRemaining = Math.max(0, S.leadUntil - performance.now());
@@ -1309,16 +1462,71 @@
// Buttons must not count as hits // Buttons must not count as hits
menu.addEventListener('pointerdown', (e) => e.stopPropagation()); menu.addEventListener('pointerdown', (e) => e.stopPropagation());
S.view.ov.appendChild(menu); S.view.ov.appendChild(menu);
S.view.ov.classList.add('sqc-is-paused');
S.pauseMenu = menu; S.pauseMenu = menu;
if (byUser === true) openSettings();
}; };
const resume = () => { const resume = () => {
if (!S || !S.paused) return; if (!S || !S.paused) return;
closeSettings();
if (S.pauseMenu) { S.pauseMenu.remove(); S.pauseMenu = null; } if (S.pauseMenu) { S.pauseMenu.remove(); S.pauseMenu = null; }
S.view.ov.classList.remove('sqc-is-paused');
S.paused = false; S.paused = false;
if (S.mode === 'edit') return; // the editor stays stopped; Space plays if (S.mode === 'edit') return; // the editor stays stopped; Space plays
if (S.leadRemaining != null) { S.leadUntil = performance.now() + S.leadRemaining; S.leadRemaining = null; } if (S.leadRemaining != null) { S.leadUntil = performance.now() + S.leadRemaining; S.leadRemaining = null; }
else S.resumeUntil = performance.now() + RESUME_MS; else S.resumeUntil = performance.now() + RESUME_MS;
}; };
// Pausing (Esc / pause button) opens the sidebar's tuner, Square Clicker tab, raised above the game so it
// can be used right there (changes apply at once), and scrolls to the setting changed last unless that is
// already in view. Resume / Esc (and "Back to the game" on phones, where the sidebar covers the game)
// close it again: previous sidebar tab back, sidebar hidden again if it was hidden.
const SIDEBAR_SEL = '.global-sidebar-right, .item-sidebar-right, .index-sidebar-right';
const openSettings = () => {
if (!S || S.settings) return;
if (!document.querySelector(SIDEBAR_SEL)) { flash('The settings sidebar is not available on this page', 'error'); return; }
S.settings = { hidSidebar: document.body.classList.contains('sidebar-right-hidden'), prevTab: document.querySelector('.sidebar-tab.active') };
document.body.classList.add('sqc-settings');
if (S.settings.hidSidebar && window.toggleSidebarRight) window.toggleSidebarRight();
const tab = document.querySelector('#sidebar-tab-tuner');
if (tab && !tab.classList.contains('active')) tab.click();
setTimeout(() => {
const sub = document.querySelector('.f0ck-tuner-subtab-btn[data-subtab="sqc"]');
if (sub && !sub.classList.contains('active')) sub.click();
// after the tab switch (and its own "pane start into view" scroll) has settled
setTimeout(jumpToLastSetting, 250);
}, 60);
const back = el('button', 'sqc-settings-back', 'Back to the game');
back.type = 'button';
back.addEventListener('click', () => closeSettings());
document.body.appendChild(back);
S.settings.back = back;
};
const jumpToLastSetting = () => {
let key = null;
try { key = localStorage.getItem(LAST_SETTING_KEY); } catch (_) {}
const row = key && document.querySelector('#f0ck-tuner-pane-sqc [data-sqc-key="' + key + '"]');
if (!row || !row.offsetParent) return;
const sc = window._f0ckScrollerFor ? window._f0ckScrollerFor(row) : null;
if (!sc) return;
const rr = row.getBoundingClientRect(), sr = sc.getBoundingClientRect();
const pane = document.querySelector('#f0ck-tuner-pane-sqc');
const header = pane && pane.parentElement && pane.parentElement.querySelector('.f0ck-tuner-header');
const top = header && getComputedStyle(header).position === 'sticky' ? Math.max(sr.top, header.getBoundingClientRect().bottom) : sr.top;
if (rr.top >= top && rr.bottom <= sr.bottom) return; // already in view: leave the scroll position alone
sc.scrollBy({ top: rr.top - top - (sr.bottom - top) / 3, behavior: 'smooth' });
row.classList.add('sqc-last-setting');
setTimeout(() => row.classList.remove('sqc-last-setting'), 1500);
};
function closeSettings() {
if (!S || !S.settings) return;
const st = S.settings;
S.settings = null;
st.back.remove();
document.body.classList.remove('sqc-settings');
if (st.prevTab && st.prevTab.id !== 'sidebar-tab-tuner' && document.contains(st.prevTab)) st.prevTab.click();
if (st.hidSidebar && !document.body.classList.contains('sidebar-right-hidden') && window.toggleSidebarRight) window.toggleSidebarRight();
}
// "Get ready" countdown after a resume // "Get ready" countdown after a resume
const drawResume = (ctx) => { const drawResume = (ctx) => {
if (!S.resumeUntil) return; if (!S.resumeUntil) return;
@@ -1432,7 +1640,7 @@
ctx.globalAlpha = Math.max(0, 1 - age / 1200) * 0.8; ctx.globalAlpha = Math.max(0, 1 - age / 1200) * 0.8;
if (n.p) { drawTrack(ctx, n.p, accent(), ctx.globalAlpha * 0.6); ctx.globalAlpha = Math.max(0, 1 - age / 1200) * 0.8; } if (n.p) { drawTrack(ctx, n.p, accent(), ctx.globalAlpha * 0.6); ctx.globalAlpha = Math.max(0, 1 - age / 1200) * 0.8; }
ctx.strokeStyle = accent(); ctx.lineWidth = 2; ctx.strokeStyle = accent(); ctx.lineWidth = 2;
ctx.strokeRect(n.x * innerWidth - NOTE / 2, n.y * innerHeight - NOTE / 2, NOTE, NOTE); strokeShape(ctx, n.x * innerWidth, n.y * innerHeight, NOTE);
if (+O.noteNumbers) { if (+O.noteNumbers) {
ctx.fillStyle = '#fff'; ctx.font = 'bold 16px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#fff'; ctx.font = 'bold 16px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), n.x * innerWidth, n.y * innerHeight); ctx.fillText(String(i + 1), n.x * innerWidth, n.y * innerHeight);
@@ -1627,7 +1835,7 @@
// keys (through the key guard; typing in the title field is left to the field) // keys (through the key guard; typing in the title field is left to the field)
s.onKey = (e) => { s.onKey = (e) => {
if (S !== s) return; if (S !== s) return;
if (e.key === 'Escape') { e.preventDefault(); if (!e.repeat) (s.paused ? resume() : pause()); return; } if (e.key === 'Escape') { e.preventDefault(); if (!e.repeat) (s.paused ? resume() : pause(true)); return; }
if (s.paused || e.ctrlKey || e.altKey || e.metaKey || /^F\d{1,2}$/.test(e.key)) return; if (s.paused || e.ctrlKey || e.altKey || e.metaKey || /^F\d{1,2}$/.test(e.key)) return;
e.preventDefault(); e.preventDefault();
const t = songTime(); const t = songTime();
@@ -1675,15 +1883,14 @@
const dt = n.t - t; const dt = n.t - t;
ctx.globalAlpha = dt >= 0 ? Math.max(0.2, 1 - dt / EDIT_AFTER) : Math.max(0.2, 1 + (dt + n.d) / EDIT_BEFORE); ctx.globalAlpha = dt >= 0 ? Math.max(0.2, 1 - dt / EDIT_AFTER) : Math.max(0.2, 1 + (dt + n.d) / EDIT_BEFORE);
if (n.p) drawTrack(ctx, n.p, col, 0.6 * ctx.globalAlpha); if (n.p) drawTrack(ctx, n.p, col, 0.6 * ctx.globalAlpha);
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(px - NOTE / 2, py - NOTE / 2, NOTE, NOTE); drawNoteBody(ctx, px, py, NOTE, col, i === s.sel ? 4 : 2);
ctx.strokeStyle = col; ctx.lineWidth = i === s.sel ? 4 : 2; ctx.strokeStyle = col;
ctx.strokeRect(px - NOTE / 2, py - NOTE / 2, NOTE, NOTE); if (i === s.sel) { ctx.lineWidth = 2; ctx.setLineDash([5, 4]); strokeShape(ctx, px, py, NOTE + 16); ctx.setLineDash([]); }
if (i === s.sel) { ctx.setLineDash([5, 4]); ctx.strokeRect(px - NOTE / 2 - 8, py - NOTE / 2 - 8, NOTE + 16, NOTE + 16); ctx.setLineDash([]); }
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(String(i + 1), px, py); ctx.fillText(String(i + 1), px, py);
if (n.d && !n.p) { ctx.fillStyle = col; ctx.fillRect(px - NOTE / 2, py + NOTE / 2 + 6, NOTE, 6); } if (n.d && !n.p) { ctx.fillStyle = col; ctx.fillRect(px - NOTE / 2, py + NOTE / 2 + 6, NOTE, 6); }
// approach frame for notes still to come // approach frame for notes still to come
if (dt > 0 && dt < APPROACH_MS) { const s2 = NOTE * (1 + 2.2 * dt / APPROACH_MS); ctx.lineWidth = 1; ctx.strokeRect(px - s2 / 2, py - s2 / 2, s2, s2); } if (dt > 0 && dt < APPROACH_MS) { const s2 = NOTE * (1 + 2.2 * dt / APPROACH_MS); ctx.lineWidth = 1; strokeShape(ctx, px, py, s2); }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
// timeline: every note a tick, the selected one in the accent, the playhead in white // timeline: every note a tick, the selected one in the accent, the playhead in white
@@ -1764,14 +1971,47 @@
}; };
// Play any map: { id (null for a generated auto map), title, notes } // Play any map: { id (null for a generated auto map), title, notes }
// Auto map settings changed while an auto map plays (tuner, e.g. via Settings in the pause menu): the
// rest of the map is rebuilt with them and the game goes on. Notes up to a little ahead of now (what is
// on screen, plus a margin) stay as they are; the new notes start after that. Score and combo carry on.
const autoSig = () => JSON.stringify(Object.keys(O).filter(k => /^auto/.test(k)).map(k => [k, O[k]]));
let regenTimer = null;
const regenAuto = async () => {
const s = S;
if (!s || s.mode !== 'play' || !s.map.auto || s.finished) return;
const sig = autoSig();
if (sig === s.autoSig) return;
s.autoSig = sig;
let map;
try { map = await Auto.build(s.audio, s.map.itemId, O.autoPreset, O.autoDifficulty); } catch (e) { flash('Could not rebuild the auto map', 'error'); return; }
if (S !== s || s.finished || s.autoSig !== sig) return; // left, ended, or changed again meanwhile
const from = Math.max(0, songTime()) + Math.max(APPROACH_MS, 1500);
const keep = s.notes.filter(n => n.t < from);
const fresh = toPlayNotes(map.notes.filter(n => n.t >= from));
s.notes = keep.concat(fresh);
s.notes.forEach((n, i) => { n.i = i; });
map.subId = s.map.subId;
s.map = map; // Retry and "Save as map" use the new map
flash('Auto map updated: ' + map.title + ' (' + fresh.length + ' notes from ' + fmtTime(from) + ')');
};
window.addEventListener('f0ck:sqc_settings_changed', () => {
if (!S || S.mode !== 'play' || !S.map.auto || autoSig() === S.autoSig) return;
clearTimeout(regenTimer);
regenTimer = setTimeout(regenAuto, 400); // sliders fire on every step: rebuild once they settle
});
// opts.returnTo: where to go when the game ends or is left (playtests go back to the draft / editor) // opts.returnTo: where to go when the game ends or is left (playtests go back to the draft / editor)
const playMap = (map, opts = {}) => { // Map notes -> game notes (judging state, stacking, prepared sliders)
const notes = (typeof map.notes === 'string' ? JSON.parse(map.notes) : map.notes) const toPlayNotes = (raw) => {
.map((n, i) => ({ t: n.t, x: n.x, y: n.y, d: n.d || 0, p: Array.isArray(n.p) && n.p.length > 1 ? n.p : null, i, state: 'pending', judge: 0, outSince: 0 })); const notes = raw.map((n, i) => ({ t: n.t, x: n.x, y: n.y, d: n.d || 0, p: Array.isArray(n.p) && n.p.length > 1 ? n.p : null, i, state: 'pending', judge: 0, outSince: 0 }));
stackNotes(notes); stackNotes(notes);
notes.forEach(prepSlider); notes.forEach(prepSlider);
return notes;
};
const playMap = (map, opts = {}) => {
const notes = toPlayNotes(typeof map.notes === 'string' ? JSON.parse(map.notes) : map.notes);
const s = startSession('play', { const s = startSession('play', {
map, notes, next: 0, score: 0, combo: 0, maxCombo: 0, returnTo: opts.returnTo || null, map, notes, next: 0, score: 0, combo: 0, maxCombo: 0, returnTo: opts.returnTo || null, autoSig: map.auto ? autoSig() : null,
hits: { 300: 0, 100: 0, 50: 0, miss: 0 }, leadUntil: performance.now() + LEAD_IN_MS, finished: false, hits: { 300: 0, 100: 0, 50: 0, miss: 0 }, leadUntil: performance.now() + LEAD_IN_MS, finished: false,
}); });
if (!s) return; if (!s) return;
@@ -1902,10 +2142,7 @@
const going = n.state === 'holding' || n.state === 'broken'; const going = n.state === 'holding' || n.state === 'broken';
if (going) ctx.globalAlpha = n.p ? 0.35 : n.state === 'broken' ? 0.5 : 1; // the ball takes over from the start square if (going) ctx.globalAlpha = n.p ? 0.35 : n.state === 'broken' ? 0.5 : 1; // the ball takes over from the start square
// body // body
ctx.fillStyle = 'rgba(0,0,0,0.55)'; drawNoteBody(ctx, p.x, p.y, NOTE, col, 3);
ctx.fillRect(p.x - NOTE / 2, p.y - NOTE / 2, NOTE, NOTE);
ctx.strokeStyle = col; ctx.lineWidth = 3;
ctx.strokeRect(p.x - NOTE / 2, p.y - NOTE / 2, NOTE, NOTE);
if (+O.noteNumbers) { // off by default (tuner: numbers on notes) if (+O.noteNumbers) { // off by default (tuner: numbers on notes)
ctx.fillStyle = '#fff'; ctx.font = 'bold 22px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#fff'; ctx.font = 'bold 22px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(String(n.i + 1), p.x, p.y); ctx.fillText(String(n.i + 1), p.x, p.y);
@@ -1918,10 +2155,10 @@
const inside = Math.abs(S.mouse.x - b.x) <= FOLLOW && Math.abs(S.mouse.y - b.y) <= FOLLOW; const inside = Math.abs(S.mouse.x - b.x) <= FOLLOW && Math.abs(S.mouse.y - b.y) <= FOLLOW;
ctx.globalAlpha = broken ? 0.5 : 1; ctx.globalAlpha = broken ? 0.5 : 1;
ctx.fillStyle = broken ? '#ff4d4d' : col; ctx.fillStyle = broken ? '#ff4d4d' : col;
ctx.fillRect(b.x - NOTE * 0.28, b.y - NOTE * 0.28, NOTE * 0.56, NOTE * 0.56); fillShape(ctx, b.x, b.y, NOTE * 0.56);
ctx.setLineDash([6, 5]); ctx.setLineDash([6, 5]);
ctx.strokeStyle = (broken ? !inside : n.outSince) ? '#ff4d4d' : col; ctx.lineWidth = 2; ctx.strokeStyle = (broken ? !inside : n.outSince) ? '#ff4d4d' : col; ctx.lineWidth = 2;
ctx.strokeRect(b.x - FOLLOW, b.y - FOLLOW, FOLLOW * 2, FOLLOW * 2); strokeShape(ctx, b.x, b.y, FOLLOW * 2);
ctx.setLineDash([]); ctx.setLineDash([]);
} }
// plain hold notes: a bar under the note that fills while held // plain hold notes: a bar under the note that fills while held
@@ -1936,7 +2173,7 @@
const sc = 1 + 2.2 * (until / APPROACH_MS); const sc = 1 + 2.2 * (until / APPROACH_MS);
const s2 = NOTE * sc; const s2 = NOTE * sc;
ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.strokeStyle = col; ctx.lineWidth = 2;
ctx.strokeRect(p.x - s2 / 2, p.y - s2 / 2, s2, s2); strokeShape(ctx, p.x, p.y, s2);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
} }
@@ -1951,7 +2188,7 @@
// done once every note is judged and the last effects faded // done once every note is judged and the last effects faded
const last = S.notes[S.notes.length - 1]; const last = S.notes[S.notes.length - 1];
if (S.next >= S.notes.length && t > last.t + last.d + 800) { finishPlay(); return; } if (S.next >= S.notes.length && (!last || t > last.t + last.d + 800)) { finishPlay(); return; }
S.raf = requestAnimationFrame(frame); S.raf = requestAnimationFrame(frame);
}; };
S.raf = requestAnimationFrame(frame); S.raf = requestAnimationFrame(frame);
Binary file not shown.
+56
View File
@@ -0,0 +1,56 @@
import crypto from "crypto";
import db from "../src/inc/sql.mjs";
import lib from "../src/inc/lib.mjs";
// Manage upload-only API keys for chat uploads (POST /api/chat/upload).
// The plain key is shown once on creation; only its sha256 is stored.
const [cmd, arg, maxMb] = process.argv.slice(2);
const usage = () => {
console.error("Usage:");
console.error(" node scripts/chat-upload-key.mjs create <name> [max_mb]");
console.error(" node scripts/chat-upload-key.mjs list");
console.error(" node scripts/chat-upload-key.mjs revoke <id>");
process.exit(1);
};
async function run() {
if (cmd === "create") {
if (!arg) usage();
const key = "cu_" + crypto.randomBytes(24).toString("base64url");
const maxBytes = maxMb ? parseInt(maxMb, 10) * 1024 * 1024 : null;
const [row] = await db`
INSERT INTO upload_api_keys ${db({ name: arg, key_hash: lib.sha256(key), max_bytes: maxBytes })}
RETURNING id
`;
console.log(`Created key #${row.id} "${arg}"${maxBytes ? ` (max ${maxMb} MB)` : ""}`);
console.log(`Key (shown once): ${key}`);
} else if (cmd === "list") {
const rows = await db`
SELECT k.id, k.name, k.max_bytes, k.revoked, k.created_at, k.last_used_at,
count(c.id)::int AS files, coalesce(sum(c.size_bytes), 0)::bigint AS bytes
FROM upload_api_keys k
LEFT JOIN chat_uploads c ON c.key_id = k.id
GROUP BY k.id ORDER BY k.id
`;
console.table(rows.map(r => ({
id: r.id,
name: r.name,
revoked: r.revoked,
max_mb: r.max_bytes ? Number(r.max_bytes) / 1048576 : "-",
files: r.files,
used_mb: (Number(r.bytes) / 1048576).toFixed(1),
last_used: r.last_used_at ? r.last_used_at.toISOString() : "-"
})));
} else if (cmd === "revoke") {
const id = parseInt(arg, 10);
if (!id) usage();
const rows = await db`UPDATE upload_api_keys SET revoked = true WHERE id = ${id} RETURNING id`;
console.log(rows.length ? `Revoked key #${id}` : `No key #${id}`);
} else {
usage();
}
}
run().catch(e => { console.error(e.message); process.exitCode = 1; }).finally(() => db.end());
+237
View File
@@ -0,0 +1,237 @@
/**
* chat_preview_handler.mjs — Link previews for external chat clients (mumh5).
*
* The server fetches the linked page instead of the client, so people posting links cannot
* learn chat participants' IP addresses. Preview images are proxied through signed URLs.
*
* Routes (registered as bypass middlewares in index.mjs):
* GET /api/chat/preview?url=... — page metadata, X-API-Key (upload key) required
* GET /api/chat/preview/image?url=...&sig=... — proxied preview image, signature required
*
* Fetches refuse private, loopback and link-local addresses (checked again on every redirect),
* are size and time limited, and results are cached.
*/
import http from 'http';
import https from 'https';
import dns from 'dns';
import net from 'net';
import crypto from 'crypto';
import cfg from './inc/config.mjs';
import { authKey } from './chat_upload_handler.mjs';
const isEnabled = () => cfg.websrv.chat_uploads !== false && cfg.websrv.chat_link_previews !== false;
const USER_AGENT = 'Mozilla/5.0 (compatible; mumh5-linkpreview/1.0)';
const TIMEOUT_MS = 6000;
const MAX_HTML = 768 * 1024;
const MAX_IMAGE = 5 * 1024 * 1024;
const MAX_REDIRECTS = 4;
const CACHE_TTL = 6 * 3600 * 1000;
const FAIL_TTL = 30 * 60 * 1000;
const CACHE_MAX = 2000;
// Signs image URLs so the image route cannot be used as an open proxy
const SECRET = crypto.randomBytes(32);
const sign = url => crypto.createHmac('sha256', SECRET).update(url).digest('base64url').slice(0, 32);
const cache = new Map();
function sendJson(res, data, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
// ─── Private address guard ────────────────────────────────────────────────────
function isPrivateV4(ip) {
const [a, b] = ip.split('.').map(Number);
return a === 0 || a === 10 || a === 127 || a >= 224 ||
(a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) ||
(a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
}
export function isPrivateAddress(ip) {
if (net.isIPv4(ip)) return isPrivateV4(ip);
const v6 = ip.toLowerCase();
const mapped = /^(?:::ffff:|64:ff9b::)(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
if (mapped) return isPrivateV4(mapped[1]);
return v6 === '::' || v6 === '::1' || /^f[cd]/.test(v6) || /^fe[89ab]/.test(v6) || /^ff/.test(v6);
}
// DNS lookup that only returns public addresses; the socket connects to exactly these
function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { all: true }, (err, addresses) => {
if (err) return callback(err);
const ok = addresses.filter(a => !isPrivateAddress(a.address));
if (!ok.length) return callback(new Error('Refusing private address'));
if (options?.all) return callback(null, ok);
callback(null, ok[0].address, ok[0].family);
});
}
// GET with redirects, each hop validated; resolves with the response stream
function safeGet(rawUrl, accept, hops = 0) {
return new Promise((resolve, reject) => {
let url;
try { url = new URL(rawUrl); } catch { return reject(new Error('Invalid URL')); }
if (!/^https?:$/.test(url.protocol)) return reject(new Error('Unsupported protocol'));
const host = url.hostname.replace(/^\[|\]$/g, '');
if (net.isIP(host) && isPrivateAddress(host)) return reject(new Error('Refusing private address'));
const lib = url.protocol === 'https:' ? https : http;
const req = lib.get(url, {
lookup: safeLookup,
timeout: TIMEOUT_MS,
headers: { 'User-Agent': USER_AGENT, 'Accept': accept, 'Accept-Language': 'en,*;q=0.5' }
}, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
if (hops >= MAX_REDIRECTS) return reject(new Error('Too many redirects'));
return resolve(safeGet(new URL(res.headers.location, url).href, accept, hops + 1));
}
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode}`));
}
res.finalUrl = url.href;
resolve(res);
});
req.on('timeout', () => req.destroy(new Error('Timed out')));
req.on('error', reject);
});
}
function readLimited(res, max, stopAt) {
return new Promise((resolve, reject) => {
const chunks = [];
let size = 0;
const timer = setTimeout(() => res.destroy(new Error('Timed out')), TIMEOUT_MS);
res.on('data', chunk => {
chunks.push(chunk);
size += chunk.length;
// Metadata lives in <head>; stop reading once it is complete
if (size > max || (stopAt && chunk.toString('latin1').toLowerCase().includes(stopAt))) res.destroy();
});
const done = () => { clearTimeout(timer); resolve(Buffer.concat(chunks).subarray(0, max)); };
res.on('end', done);
res.on('close', done);
res.on('error', e => { clearTimeout(timer); chunks.length ? done() : reject(e); });
});
}
// ─── Metadata parsing ─────────────────────────────────────────────────────────
const decodeEntities = s => s
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
.replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16)))
.replace(/&quot;/g, '"').replace(/&#39;|&apos;/g, "'").replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&');
const clean = (s, max) => {
if (!s) return '';
const t = decodeEntities(s).replace(/\s+/g, ' ').trim();
return t.length > max ? t.slice(0, max - 1) + '…' : t;
};
export function parsePreview(html, pageUrl) {
const head = html.split(/<\/head>/i)[0];
const meta = {};
for (const [, attrs] of head.matchAll(/<meta\s+([^>]+?)\/?>/gi)) {
const a = {};
for (const m of attrs.matchAll(/([a-zA-Z:_-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g)) {
a[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? '';
}
const key = (a.property || a.name || '').toLowerCase();
if (key && a.content !== undefined && !(key in meta)) meta[key] = a.content;
}
const titleTag = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(head)?.[1];
const abs = u => { try { const x = new URL(decodeEntities(u), pageUrl); return /^https?:$/.test(x.protocol) ? x.href : ''; } catch { return ''; } };
const image = abs(meta['og:image:secure_url'] || meta['og:image'] || meta['twitter:image'] || meta['twitter:image:src'] || '');
const color = /^#[0-9a-f]{3,8}$/i.test(meta['theme-color'] ?? '') ? meta['theme-color'] : '';
return {
title: clean(meta['og:title'] || meta['twitter:title'] || titleTag, 200),
description: clean(meta['og:description'] || meta['twitter:description'] || meta['description'], 400),
site: clean(meta['og:site_name'] || new URL(pageUrl).hostname.replace(/^www\./, ''), 80),
image,
large: meta['twitter:card'] === 'summary_large_image' || Number(meta['og:image:width']) >= 600,
color
};
}
// ─── Routes ───────────────────────────────────────────────────────────────────
async function buildPreview(url) {
const res = await safeGet(url, 'text/html,application/xhtml+xml;q=0.9,*/*;q=0.1');
const type = String(res.headers['content-type'] || '');
if (!/text\/html|application\/xhtml/i.test(type)) {
res.resume();
throw new Error('Not an HTML page');
}
const charset = /charset=([\w-]+)/i.exec(type)?.[1]?.toLowerCase() || 'utf-8';
const body = await readLimited(res, MAX_HTML, '</head>');
let html;
try { html = new TextDecoder(charset).decode(body); } catch { html = body.toString('utf8'); }
const p = parsePreview(html, res.finalUrl);
if (!p.title && !p.description) throw new Error('No preview data');
return { ...p, url: res.finalUrl };
}
export async function handleChatPreview(req, res) {
if (!isEnabled()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const key = await authKey(req, res);
if (!key) return;
const url = String(req.url.qs?.url || new URLSearchParams(req.url.search || '').get('url') || '').slice(0, 2048);
if (!/^https?:\/\//i.test(url)) return sendJson(res, { success: false, msg: 'Invalid URL' }, 400);
const hit = cache.get(url);
let result;
if (hit && hit.expires > Date.now()) {
result = hit.value;
} else {
try {
result = { success: true, ...(await buildPreview(url)) };
cache.set(url, { value: result, expires: Date.now() + CACHE_TTL });
} catch (e) {
result = { success: false, msg: e.message };
cache.set(url, { value: result, expires: Date.now() + FAIL_TTL });
}
if (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value);
}
if (!result.success) return sendJson(res, result, 200);
const base = (cfg.main?.url?.full || '').replace(/\/+$/, '');
const image = result.image ? `${base}/api/chat/preview/image?url=${encodeURIComponent(result.image)}&sig=${sign(result.image)}` : '';
return sendJson(res, { ...result, image });
}
export async function handleChatPreviewImage(req, res) {
const fail = (code = 404) => { res.writeHead(code, { 'Content-Type': 'text/plain' }); res.end('Not available'); };
if (!isEnabled()) return fail();
const params = new URLSearchParams(req.url.search || '');
const url = String(req.url.qs?.url || params.get('url') || '');
const sig = String(req.url.qs?.sig || params.get('sig') || '');
const expected = sign(url);
if (!url || sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return fail(403);
try {
const up = await safeGet(url, 'image/avif,image/webp,image/png,image/jpeg,image/gif;q=0.9');
const type = String(up.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
if (!/^image\/(png|jpeg|gif|webp|avif)$/.test(type)) { up.resume(); return fail(415); }
const body = await readLimited(up, MAX_IMAGE + 1);
if (body.length > MAX_IMAGE) return fail(413);
res.writeHead(200, {
'Content-Type': type,
'Content-Length': String(body.length),
'Cache-Control': 'public, max-age=86400',
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Access-Control-Allow-Origin': '*',
'Cross-Origin-Resource-Policy': 'cross-origin'
});
res.end(body);
} catch {
return fail(502);
}
}
+341
View File
@@ -0,0 +1,341 @@
/**
* chat_upload_handler.mjs — Temporary public file hosting for external chat clients (mumh5).
*
* Auth is a dedicated upload-only key from the upload_api_keys table (not a user API key),
* managed with scripts/chat-upload-key.mjs. Files live in cfg.paths.cu and always expire.
*
* Routes (registered as bypass middlewares in index.mjs):
* GET /api/chat/upload — allowed types and limits, X-API-Key required
* POST /api/chat/upload — raw file as request body, X-API-Key required
* DELETE /api/chat/upload/:slug — X-Delete-Token (returned by the upload) required
* GET /cu/:slug[/:name] — public download, Range supported
*
* Allowed types follow the normal upload rules: cfg.allowedMimes categories resolved against
* cfg.mimes, overridable with websrv.chat_upload_mimes (same format as fileupload_comments_mimes).
*
* Upload request headers:
* X-API-Key upload key
* Content-Type client mime type; rejected early if not allowed, then verified with `file`
* X-Filename original file name (URI-encoded)
* X-Upload-Expiry optional lifetime: seconds, or 30m / 1h / 24h / 7d / 30d; clamped to the max
*/
import { promises as fs, createReadStream, createWriteStream } from 'fs';
import path from 'path';
import crypto from 'crypto';
import { execFile } from 'child_process';
import db from './inc/sql.mjs';
import lib from './inc/lib.mjs';
import cfg from './inc/config.mjs';
// ─── Config ──────────────────────────────────────────────────────────────────
const UPLOAD_DIR = cfg.paths.cu;
const isEnabled = () => cfg.websrv.chat_uploads !== false;
const maxBytes = () => parseInt(cfg.websrv.chat_upload_max_bytes, 10) || 100 * 1024 * 1024;
const defaultExpiry = () => (parseInt(cfg.websrv.chat_upload_expiry_days, 10) || 30) * 86400;
const maxExpiry = () => (parseInt(cfg.websrv.chat_upload_max_expiry_days, 10) || 30) * 86400;
const ratePerMinute = () => parseInt(cfg.websrv.chat_upload_rate_per_minute, 10) || 30;
// Same resolution as upload_handler: categories ("image") or exact types ("application/pdf")
export function getAllowedChatMimes() {
const src = Array.isArray(cfg.websrv.chat_upload_mimes) ? cfg.websrv.chat_upload_mimes
: Array.isArray(cfg.allowedMimes) ? cfg.allowedMimes : null;
const all = Object.keys(cfg.mimes || {});
if (!src) return all;
const cats = src.map(c => String(c).toLowerCase());
return all.filter(m => cats.some(cat => cat.includes('/') ? m === cat : m.startsWith(`${cat}/`)));
}
// Authoritative type check, the same `file` call the other upload handlers use
function detectMime(filePath) {
return new Promise(resolve => {
execFile('file', ['--mime-type', '-b', filePath], (err, stdout) => resolve(err ? '' : stdout.trim()));
});
}
fs.mkdir(UPLOAD_DIR, { recursive: true }).catch(e =>
console.error('[CHAT_UP] Failed to create upload dir:', e.message)
);
// ─── Expiry cleanup ───────────────────────────────────────────────────────────
export async function cleanupExpiredChatUploads() {
try {
const expired = await db`SELECT id, slug FROM chat_uploads WHERE expires_at < now()`;
if (!expired.length) return;
for (const row of expired) {
await fs.unlink(path.join(UPLOAD_DIR, row.slug)).catch(() => {});
}
await db`DELETE FROM chat_uploads WHERE id = ANY(${expired.map(r => r.id)})`;
console.log(`[CHAT_UP] Cleanup: removed ${expired.length} expired upload(s)`);
} catch (e) {
console.error('[CHAT_UP] Cleanup error:', e.message);
}
}
// Run once at startup, then hourly
cleanupExpiredChatUploads();
setInterval(cleanupExpiredChatUploads, 60 * 60 * 1000);
// ─── Helpers ──────────────────────────────────────────────────────────────────
function sendJson(res, data, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
// Sliding one-minute window per key id
const rateBuckets = new Map();
function rateLimited(keyId) {
const now = Date.now();
const hits = (rateBuckets.get(keyId) || []).filter(t => now - t < 60000);
const limited = hits.length >= ratePerMinute();
if (!limited) hits.push(now);
rateBuckets.set(keyId, hits);
return limited;
}
async function resolveKey(req) {
const key = req.headers['x-api-key'];
if (!key || typeof key !== 'string' || key.length > 200) return null;
const rows = await db`
SELECT id, name, max_bytes FROM upload_api_keys
WHERE key_hash = ${lib.sha256(key)} AND revoked = false
LIMIT 1
`;
return rows[0] || null;
}
export function parseExpiry(val) {
if (!val) return defaultExpiry();
const m = String(val).trim().toLowerCase().match(/^(\d+)\s*([smhdw]?)$/);
if (!m) return defaultExpiry();
const mult = { '': 1, s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[m[2]];
const secs = parseInt(m[1], 10) * mult;
if (!secs) return defaultExpiry();
return Math.min(Math.max(secs, 60), maxExpiry());
}
// Media plays in the browser; everything else (pdf, archives, flash) is served as a download
const isInlineMime = mime => /^(image|video|audio)\//.test(mime) && mime !== 'image/svg+xml';
function sanitizeName(raw) {
let name;
try { name = decodeURIComponent(String(raw || '')); } catch { name = String(raw || ''); }
name = path.basename(name).replace(/[\x00-\x1f\x7f"\\/]/g, '').trim().slice(0, 120);
return name || 'file';
}
// ─── Info ─────────────────────────────────────────────────────────────────────
const keyLimit = key => Math.min(maxBytes(), key.max_bytes ? Number(key.max_bytes) : Infinity);
export async function authKey(req, res) {
if (!isEnabled()) { sendJson(res, { success: false, msg: 'Not found' }, 404); return null; }
let key;
try { key = await resolveKey(req); } catch (e) {
console.error('[CHAT_UP] Key lookup error:', e.message);
sendJson(res, { success: false, msg: 'Server error' }, 500);
return null;
}
if (!key) sendJson(res, { success: false, msg: 'Invalid API key' }, 401);
return key;
}
// Lets clients validate files before uploading (and doubles as a key check)
export async function handleChatUploadInfo(req, res) {
const key = await authKey(req, res);
if (!key) return;
const mimes = getAllowedChatMimes();
return sendJson(res, {
success: true,
allowed_mimes: mimes,
allowed_extensions: [...new Set(mimes.map(m => cfg.mimes[m]).filter(Boolean))],
max_bytes: keyLimit(key),
default_expiry: defaultExpiry(),
max_expiry: maxExpiry()
});
}
// ─── Upload ───────────────────────────────────────────────────────────────────
export async function handleChatUpload(req, res) {
const key = await authKey(req, res);
if (!key) return;
if (rateLimited(key.id)) return sendJson(res, { success: false, msg: 'Rate limit exceeded' }, 429);
const limit = keyLimit(key);
const declared = parseInt(req.headers['content-length'] || '0', 10);
if (declared > limit) return sendJson(res, { success: false, msg: 'File too large', max_bytes: limit }, 413);
// Cheap early reject on the client-declared type; the real type is checked after writing
const allowed = getAllowedChatMimes();
const hint = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase().slice(0, 100);
if (hint && hint !== 'application/octet-stream' && !allowed.includes(hint)) {
req.resume();
return sendJson(res, { success: false, msg: `File type not allowed: ${hint}` }, 415);
}
const slug = crypto.randomBytes(9).toString('base64url');
const filePath = path.join(UPLOAD_DIR, slug);
const name = sanitizeName(req.headers['x-filename']);
// Stream to disk, counting bytes
let size = 0;
let tooLarge = false;
try {
await new Promise((resolve, reject) => {
const out = createWriteStream(filePath);
req.on('data', chunk => {
size += chunk.length;
if (size > limit && !tooLarge) {
tooLarge = true;
req.unpipe(out);
out.destroy();
req.resume();
reject(new Error('BODY_TOO_LARGE'));
}
});
req.on('error', reject);
out.on('error', reject);
out.on('finish', resolve);
req.pipe(out);
});
} catch (e) {
await fs.unlink(filePath).catch(() => {});
if (tooLarge) return sendJson(res, { success: false, msg: 'File too large', max_bytes: limit }, 413);
console.error('[CHAT_UP] Write error:', e.message);
return sendJson(res, { success: false, msg: 'Upload failed' }, 500);
}
if (!size) {
await fs.unlink(filePath).catch(() => {});
return sendJson(res, { success: false, msg: 'Empty body' }, 400);
}
const mime = await detectMime(filePath);
if (!allowed.includes(mime)) {
await fs.unlink(filePath).catch(() => {});
return sendJson(res, { success: false, msg: `File type not allowed: ${mime || 'unknown'}` }, 415);
}
const lifetime = parseExpiry(req.headers['x-upload-expiry']);
const expiresAt = new Date(Date.now() + lifetime * 1000);
const deleteToken = crypto.randomBytes(24).toString('base64url');
try {
await db`
INSERT INTO chat_uploads ${db({
slug,
key_id: key.id,
original_name: name,
mime,
mime_hint: hint,
size_bytes: size,
delete_token_hash: lib.sha256(deleteToken),
expires_at: expiresAt
})}
`;
await db`UPDATE upload_api_keys SET last_used_at = now() WHERE id = ${key.id}`;
} catch (e) {
await fs.unlink(filePath).catch(() => {});
console.error('[CHAT_UP] DB error:', e.message);
return sendJson(res, { success: false, msg: 'Server error' }, 500);
}
const base = (cfg.main?.url?.full || '').replace(/\/+$/, '');
return sendJson(res, {
success: true,
slug,
url: `${base}/cu/${slug}/${encodeURIComponent(name)}`,
name,
mime,
inline: isInlineMime(mime),
size,
expires_at: ~~(expiresAt.getTime() / 1000),
delete_token: deleteToken
});
}
// ─── Delete ───────────────────────────────────────────────────────────────────
export async function handleChatUploadDelete(req, res, slug) {
if (!isEnabled()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const token = req.headers['x-delete-token'];
if (!token) return sendJson(res, { success: false, msg: 'Delete token required' }, 401);
const rows = await db`
DELETE FROM chat_uploads
WHERE slug = ${slug} AND delete_token_hash = ${lib.sha256(String(token))}
RETURNING id
`;
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
await fs.unlink(path.join(UPLOAD_DIR, slug)).catch(() => {});
return sendJson(res, { success: true });
}
// ─── Download ─────────────────────────────────────────────────────────────────
export async function handleChatUploadServe(req, res, slug) {
const notFound = () => {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('404 - file not found.');
};
if (!isEnabled()) return notFound();
const rows = await db`
SELECT original_name, mime, expires_at FROM chat_uploads
WHERE slug = ${slug} AND expires_at > now()
LIMIT 1
`;
if (!rows.length) return notFound();
const row = rows[0];
const filePath = path.join(UPLOAD_DIR, slug);
let stat;
try { stat = await fs.stat(filePath); } catch { return notFound(); }
const inline = isInlineMime(row.mime);
const ttl = Math.max(0, ~~((new Date(row.expires_at).getTime() - Date.now()) / 1000));
const headers = {
'Content-Type': row.mime,
'Accept-Ranges': 'bytes',
'Cache-Control': `public, max-age=${Math.min(ttl, 86400)}`,
'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(row.original_name)}`,
'X-Content-Type-Options': 'nosniff',
'Content-Security-Policy': "default-src 'none'; sandbox",
'Access-Control-Allow-Origin': '*',
'Access-Control-Expose-Headers': 'Content-Length, Content-Range, Content-Disposition',
'Cross-Origin-Resource-Policy': 'cross-origin'
};
let start = 0, end = stat.size - 1, code = 200;
const range = req.headers.range && /^bytes=(\d*)-(\d*)$/.exec(req.headers.range);
if (range && stat.size > 0 && (range[1] !== '' || range[2] !== '')) {
if (range[1] === '') {
start = Math.max(0, stat.size - parseInt(range[2], 10));
} else {
start = parseInt(range[1], 10);
if (range[2] !== '') end = Math.min(parseInt(range[2], 10), end);
}
if (start > end || start >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
return res.end();
}
code = 206;
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
}
headers['Content-Length'] = String(stat.size ? end - start + 1 : 0);
res.writeHead(code, headers);
if (req.method === 'HEAD' || !stat.size) return res.end();
await new Promise(resolve => {
const stream = createReadStream(filePath, { start, end });
stream.on('error', () => { res.destroy(); resolve(); });
stream.on('end', resolve);
res.on('close', () => { stream.destroy(); resolve(); });
stream.pipe(res);
});
}
+1
View File
@@ -93,6 +93,7 @@ config.paths = {
fonts: resolvePath('public/s/fonts'), fonts: resolvePath('public/s/fonts'),
memes: resolvePath('public/memes'), memes: resolvePath('public/memes'),
e: resolvePath('public/e'), e: resolvePath('public/e'),
cu: resolvePath('public/cu'),
pending: resolvePath('pending'), pending: resolvePath('pending'),
deleted: resolvePath('deleted'), deleted: resolvePath('deleted'),
logs: resolvePath('logs'), logs: resolvePath('logs'),
+59 -2
View File
@@ -21,6 +21,8 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs";
import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs";
import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_handler.mjs";
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs"; import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
import { handleChatUpload, handleChatUploadInfo, handleChatUploadDelete, handleChatUploadServe } from "./chat_upload_handler.mjs";
import { handleChatPreview, handleChatPreviewImage } from "./chat_preview_handler.mjs";
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getSessionOwnerName, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl, getHwFingerprintEnabled } from "./inc/settings.mjs"; import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDmUnencrypted, setDmUnencrypted, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getCleanupIncludeEngaged, setCleanupIncludeEngaged, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode, getAllowCommentDeletion, setAllowCommentDeletion, getNsfpIds, setNsfpIds, getEnableExpiringUploads, getEnableItemSlugs, getEnableAnonymousAccess, getAnonPermissions, getAnonAnonymize, isAnonymizeSession, ensureAllItemsHaveSlugs, ensureAllAlbumItemsHaveSlugs, isAnonSession, canAnonDo, canUseChan, isOnaraEnabledFor, getSessionOwnerName, getAnonAllowedModes, getAnonAllowedMimes, getBrandImageUrl, setBrandImageUrl, getHwFingerprintEnabled } from "./inc/settings.mjs";
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs"; import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
import { createI18n } from "./inc/i18n.mjs"; import { createI18n } from "./inc/i18n.mjs";
@@ -680,6 +682,33 @@ process.on('uncaughtException', err => {
) )
`); `);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_sqc_scores_map ON sqc_scores(map_id, score DESC)`); await runMigration(db`CREATE INDEX IF NOT EXISTS idx_sqc_scores_map ON sqc_scores(map_id, score DESC)`);
// Chat uploads (temporary file hosting for mumh5), see migrations/add_chat_uploads.sql
await runMigration(db`
CREATE TABLE IF NOT EXISTS upload_api_keys (
id serial PRIMARY KEY,
name text NOT NULL,
key_hash text NOT NULL UNIQUE,
max_bytes bigint DEFAULT NULL,
revoked boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
last_used_at timestamp with time zone
)
`);
await runMigration(db`
CREATE TABLE IF NOT EXISTS chat_uploads (
id bigserial PRIMARY KEY,
slug text NOT NULL UNIQUE,
key_id integer REFERENCES upload_api_keys(id) ON DELETE SET NULL,
original_name text DEFAULT '' NOT NULL,
mime text NOT NULL,
mime_hint text DEFAULT '' NOT NULL,
size_bytes bigint DEFAULT 0 NOT NULL,
delete_token_hash text NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
expires_at timestamp with time zone NOT NULL
)
`);
await runMigration(db`CREATE INDEX IF NOT EXISTS idx_chat_uploads_expires_at ON chat_uploads(expires_at)`);
// Ensure the NSFL rating tag exists under cfg.nsfl_tag_id — rating changes and feed filters // Ensure the NSFL rating tag exists under cfg.nsfl_tag_id — rating changes and feed filters
// all reference that id, and tags_assign has a FK on tags.id // all reference that id, and tags_assign has a FK on tags.id
@@ -785,7 +814,7 @@ process.on('uncaughtException', err => {
res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With, X-CSRF-Token, Authorization'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With, X-CSRF-Token, Authorization, X-API-Key, X-Filename, X-Upload-Expiry, X-Delete-Token');
if (req.method === 'OPTIONS') { if (req.method === 'OPTIONS') {
res.writeHead(204).end(); res.writeHead(204).end();
@@ -1470,7 +1499,7 @@ process.on('uncaughtException', err => {
// Private Society gate — require login for all content when enabled // Private Society gate — require login for all content when enabled
if (cfg.websrv.private_society && !req.session) { if (cfg.websrv.private_society && !req.session) {
const publicPaths = /^\/(s|login|logout|register|activate|forgot-password|reset-password|banned|api\/v2\/auth|api\/v2\/upload|manifest\.json|sw\.js|robots\.txt|favicon\.(ico|png|gif)|s\/img\/duck-icon-(192|512)\.png)(\/.*)?$/; const publicPaths = /^\/(s|login|logout|register|activate|forgot-password|reset-password|banned|api\/v2\/auth|api\/v2\/upload|api\/chat\/upload|api\/chat\/preview|cu|manifest\.json|sw\.js|robots\.txt|favicon\.(ico|png|gif)|s\/img\/duck-icon-(192|512)\.png)(\/.*)?$/;
if (!publicPaths.test(req.url.pathname)) { if (!publicPaths.test(req.url.pathname)) {
// For AJAX requests, return 502 so it looks like the backend is down // For AJAX requests, return 502 so it looks like the backend is down
if (req.headers['x-requested-with'] === 'XMLHttpRequest') { if (req.headers['x-requested-with'] === 'XMLHttpRequest') {
@@ -1534,6 +1563,8 @@ process.on('uncaughtException', err => {
app.use(async (req, res) => { app.use(async (req, res) => {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return; if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
if (['/login', '/register', '/api/v2/anon/session', '/api/v2/anon/logout', '/api/v2/anon/passkey/register/begin', '/api/v2/anon/passkey/register/finish', '/api/v2/anon/passkey/auth/begin', '/api/v2/anon/passkey/auth/finish', '/api/v2/settings/passkeys/register/begin', '/api/v2/settings/passkeys/register/finish', '/api/v2/settings/passkeys/delete', '/api/v2/settings/passkeys/login/begin', '/api/v2/settings/passkeys/login/finish', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import', '/admin/brand_image/upload', '/admin/brand_image/delete'].includes(req.url.pathname)) return; if (['/login', '/register', '/api/v2/anon/session', '/api/v2/anon/logout', '/api/v2/anon/passkey/register/begin', '/api/v2/anon/passkey/register/finish', '/api/v2/anon/passkey/auth/begin', '/api/v2/anon/passkey/auth/finish', '/api/v2/settings/passkeys/register/begin', '/api/v2/settings/passkeys/register/finish', '/api/v2/settings/passkeys/delete', '/api/v2/settings/passkeys/login/begin', '/api/v2/settings/passkeys/login/finish', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/settings/uploadBanner', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload', '/api/v2/admin/sticker-packs/import', '/admin/brand_image/upload', '/admin/brand_image/delete'].includes(req.url.pathname)) return;
// Chat uploads authenticate with an upload API key, not a session
if (req.url.pathname.match(/^\/api\/chat\/upload(\/|$)/)) return;
// DM attachment upload validates CSRF internally // DM attachment upload validates CSRF internally
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return; if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
// Hall manager routes are handled by bypass middleware with their own session auth // Hall manager routes are handled by bypass middleware with their own session auth
@@ -1746,6 +1777,32 @@ process.on('uncaughtException', err => {
} }
}); });
// Bypass middleware for chat uploads (mumh5): raw-body upload, delete, public download
app.use(async (req, res) => {
const p = req.url.pathname;
const deleteMatch = p.match(/^\/api\/chat\/upload\/([A-Za-z0-9_-]{12})$/);
const serveMatch = p.match(/^\/cu\/([A-Za-z0-9_-]{12})(?:\/[^/]*)?$/);
if (req.method === 'POST' && p === '/api/chat/upload') {
await handleChatUpload(req, res);
req.url.pathname = '/handled_chat_upload_bypass';
} else if (req.method === 'GET' && p === '/api/chat/preview') {
await handleChatPreview(req, res);
req.url.pathname = '/handled_chat_preview_bypass';
} else if (req.method === 'GET' && p === '/api/chat/preview/image') {
await handleChatPreviewImage(req, res);
req.url.pathname = '/handled_chat_preview_image_bypass';
} else if (req.method === 'GET' && p === '/api/chat/upload') {
await handleChatUploadInfo(req, res);
req.url.pathname = '/handled_chat_upload_info_bypass';
} else if (req.method === 'DELETE' && deleteMatch) {
await handleChatUploadDelete(req, res, deleteMatch[1]);
req.url.pathname = '/handled_chat_upload_delete_bypass';
} else if ((req.method === 'GET' || req.method === 'HEAD') && serveMatch) {
await handleChatUploadServe(req, res, serveMatch[1]);
req.url.pathname = '/handled_chat_upload_serve_bypass';
}
});
tpl.views = "views"; tpl.views = "views";
tpl.debug = true; tpl.debug = true;
tpl.cache = false; tpl.cache = false;