diff --git a/README.md b/README.md index f1272af..2e13277 100644 --- a/README.md +++ b/README.md @@ -110,3 +110,25 @@ To advertise your `.onion` address to Tor Browser users visiting your clearnet s "onion": "http://yourgeneratedaddress.onion" } ``` + +## Chat uploads (temporary file hosting for mumh5) + +Upload-only API keys for external chat clients. Files are public, never appear on the imageboard, and expire (default 30 days, see `chat_upload_*` in `config.yaml`). + +Manage keys (the key is shown once, only its hash is stored): + +`node scripts/chat-upload-key.mjs create [max_mb]` +`node scripts/chat-upload-key.mjs list` +`node scripts/chat-upload-key.mjs revoke ` + +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/ -H "X-Delete-Token: ..."` + +Files are served at `/cu//` with Range support. Only sniffed images, video and audio are served inline; anything else is forced to download. diff --git a/config_example.json b/config_example.json index b7f95c2..cd36174 100644 --- a/config_example.json +++ b/config_example.json @@ -131,6 +131,12 @@ "dm_attachments": true, "dm_unencrypted": false, "dm_attachment_expiry_days": 90, + "chat_uploads": true, + "chat_upload_max_bytes": 104857600, + "chat_upload_expiry_days": 30, + "chat_upload_max_expiry_days": 30, + "chat_upload_rate_per_minute": 30, + "chat_link_previews": true, "halls_enabled": true, "userhalls_enabled": true, "square_clicker_enabled": true, diff --git a/config_example.yaml b/config_example.yaml index b1c745e..5a3ba9e 100644 --- a/config_example.yaml +++ b/config_example.yaml @@ -126,6 +126,19 @@ websrv: dm_attachments: true dm_unencrypted: false dm_attachment_expiry_days: 90 + # Temporary chat file hosting for external clients (mumh5), keys via scripts/chat-upload-key.mjs + chat_uploads: true + chat_upload_max_bytes: 104857600 + chat_upload_expiry_days: 30 + chat_upload_max_expiry_days: 30 + chat_upload_rate_per_minute: 30 + # Link previews for chat clients, fetched by this server (needs a chat upload key) + chat_link_previews: true + # Optional: allowed types for chat uploads, same format as allowedMimes (defaults to allowedMimes) + # chat_upload_mimes: + # - image + # - video + # - audio halls_enabled: true userhalls_enabled: true # Square Clicker: audio/video items become playable rhythm-game maps (hotkey o); false turns it off diff --git a/docker-compose.yml b/docker-compose.yml index 8029544..047303f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - ./f0ckm-data/b/:/opt/f0ckm/public/b/:Z - ./f0ckm-data/c/:/opt/f0ckm/public/c/:Z - ./f0ckm-data/e/:/opt/f0ckm/public/e/:Z + - ./f0ckm-data/cu/:/opt/f0ckm/public/cu/:Z - ./f0ckm-data/t/:/opt/f0ckm/public/t/:Z - ./f0ckm-data/deleted/:/opt/f0ckm/deleted/:Z - ./f0ckm-data/pending/:/opt/f0ckm/pending/:Z diff --git a/migrations/add_chat_uploads.sql b/migrations/add_chat_uploads.sql new file mode 100644 index 0000000..2694c9e --- /dev/null +++ b/migrations/add_chat_uploads.sql @@ -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); diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index 9594bf1..bd02b05 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -291,6 +291,8 @@ DROP TABLE IF EXISTS public.global_chat; DROP TABLE IF EXISTS public.favorites; DROP SEQUENCE IF EXISTS public.dm_attachments_id_seq; 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 TABLE IF EXISTS public.discord_queue; 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'); +-- +-- 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 -- diff --git a/public/s/js/f0ckm.js b/public/s/js/f0ckm.js index 7a821d2..29293c9 100644 --- a/public/s/js/f0ckm.js +++ b/public/s/js/f0ckm.js @@ -7373,11 +7373,14 @@ window.cancelAnimFrame = (function () { const f = input.files && input.files[0]; input.value = ''; 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'); - 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; } const input = rowEl.querySelector('input, select'); @@ -7406,8 +7409,10 @@ window.cancelAnimFrame = (function () { const row = a.schema.find(r => r.key === key); const valEl = rowEl.querySelector('[data-sqc-value]'); if (row.type === 'file') { - if (valEl) valEl.textContent = st.customSound || 'none'; - rowEl.style.opacity = st.hitSound === 'custom' || !st.customSound ? '' : '0.6'; + // file name, dimmed while the setting that uses it is not selected (activeWhen: [key, value]) + 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; } if (row.showIf) rowEl.style.display = +st[row.showIf] ? '' : 'none'; diff --git a/public/s/js/square-clicker.js b/public/s/js/square-clicker.js index 4fc973a..1448541 100644 --- a/public/s/js/square-clicker.js +++ b/public/s/js/square-clicker.js @@ -26,9 +26,14 @@ const SETTINGS_KEY = 'f0ck_sqc_settings'; const CUSTOM_SOUND_KEY = 'f0ck_sqc_custom_sound'; 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 = { - hitSound: 'tick', volume: 0.35, missSound: 1, countdownTicks: 1, - approachMs: 900, noteSize: 70, noteNumbers: 0, followMul: 1.6, leadInMs: 1500, offsetMs: 0, + hitSound: 'default', volume: 0.35, missSound: 1, countdownTicks: 1, + approachMs: 900, noteSize: 70, noteShape: 'square', noteNumbers: 0, followMul: 1.6, leadInMs: 1500, offsetMs: 0, dimColor: '#000000', dimAudio: 0.35, dimVideo: 0.15, autoPreset: 'balanced', autoDifficulty: 'normal', bpmMode: 'auto', bpm: 120, beatOffsetMs: 0, beatsPerBar: 4, @@ -40,7 +45,12 @@ autoJump: 1, autoSliderChance: 0.45, autoSliderGapMs: 750, }; 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 (_) {} }; // 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 NOTE = Math.max(30, +O.noteSize || 70); // note size, px 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 }; applySettings(); @@ -80,7 +91,7 @@ s.view.ov.addEventListener('contextmenu', (e) => e.preventDefault()); s.onKey = (e) => { 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; e.preventDefault(); e.stopPropagation(); 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(); 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); else if (e.type === 'keyup') { if (S.onKeyUp) S.onKeyUp(e); } else if (!(e.ctrlKey || e.altKey || e.metaKey)) e.preventDefault(); // keypress @@ -275,6 +288,59 @@ const l = p[p.length - 1]; 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 const drawTrack = (ctx, pts, col, alpha) => { if (!pts || pts.length < 2) return; @@ -288,7 +354,7 @@ ctx.restore(); const e = pts[pts.length - 1]; 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(); }; @@ -341,6 +407,7 @@ const Sfx = (() => { let ctx = null, noise = null; 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) const ensure = () => { if (!ctx) { @@ -382,6 +449,17 @@ 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 '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': if (customBuf) { const src = c.createBufferSource(); src.buffer = customBuf; src.playbackRate.value = m; @@ -399,6 +477,14 @@ if (!c) return; 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 const loadCustom = async () => { let url = null; @@ -425,7 +511,7 @@ }; return { metronome, - unlock() { ensure(); if (O.hitSound === 'custom') loadCustom(); }, + unlock() { ensure(); if (O.hitSound === 'custom') loadCustom(); if (O.hitSound === 'default') loadDefault(); }, loadCustom, play(kind) { switch (kind) { @@ -443,7 +529,7 @@ // ── styles (this script is only loaded when the feature is on) ───────────── const style = el('style'); 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); 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; @@ -464,8 +550,18 @@ .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 .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 canvas { position: absolute; inset: 0; width: 100%; height: 100%; } + .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; 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; 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; } @@ -840,15 +936,17 @@ const SCHEMA = [ { section: 'Sounds' }, { 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: 'missSound', label: 'Combo break sound', type: 'toggle' }, { key: 'countdownTicks', label: 'Countdown ticks', type: 'toggle' }, { section: 'Gameplay' }, { 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: '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: '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' }, @@ -891,17 +989,45 @@ { 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 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 = () => { saveSettings(); applySettings(); window.dispatchEvent(new CustomEvent('f0ck:sqc_settings_changed', { detail: settingsApi.getState() })); }; + const rememberSetting = (key) => { try { localStorage.setItem(LAST_SETTING_KEY, key); } catch (_) {} }; const settingsApi = { schema: SCHEMA, 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) { if (!(key in SQC_DEFAULTS)) return; + rememberSetting(key); O[key] = typeof SQC_DEFAULTS[key] === 'number' ? (Number(val) || 0) : String(val); settingsChanged(); if (key === 'hitSound' && val === 'custom') Sfx.loadCustom(); @@ -911,7 +1037,7 @@ async setCustomSound(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)' }; - 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 { localStorage.setItem(CUSTOM_SOUND_KEY, url); localStorage.setItem(CUSTOM_SOUND_KEY + '_name', file.name); @@ -923,7 +1049,7 @@ }, clearCustomSound() { 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(); settingsChanged(); }, @@ -1038,6 +1164,16 @@ input.click(); }; 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) => { closePanel(); 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'); panel.appendChild(body); document.body.appendChild(panel); + placePanel(); return body; }; @@ -1171,8 +1308,14 @@ ov.appendChild(cv); document.body.appendChild(ov); 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 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); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }; @@ -1186,6 +1329,7 @@ window.removeEventListener('blur', S.onBlur); window.removeEventListener('resize', S.view.size); S.audio.removeEventListener('ended', S.onEnded); + closeSettings(); S.view.ov.remove(); document.body.classList.remove('sqc-session'); const s = S; @@ -1201,6 +1345,14 @@ const view = makeOverlay(); S = Object.assign({ mode, audio, view, lastAudioT: -1, lastPerf: 0, mouse: { x: innerWidth / 2, y: innerHeight / 2 }, fx: [] }, extra); 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 = ''; + 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) document.body.classList.add('sqc-session'); document.querySelectorAll('.v0ck_hover').forEach(e => e.classList.remove('v0ck_hover')); @@ -1251,7 +1403,7 @@ ctx.globalAlpha = 1 - p; ctx.strokeStyle = f.color; ctx.lineWidth = 2; 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) { 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); @@ -1274,7 +1426,8 @@ // 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. 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; S.paused = true; if (S.leadUntil) S.leadRemaining = Math.max(0, S.leadUntil - performance.now()); @@ -1309,16 +1462,71 @@ // Buttons must not count as hits menu.addEventListener('pointerdown', (e) => e.stopPropagation()); S.view.ov.appendChild(menu); + S.view.ov.classList.add('sqc-is-paused'); S.pauseMenu = menu; + if (byUser === true) openSettings(); }; const resume = () => { if (!S || !S.paused) return; + closeSettings(); if (S.pauseMenu) { S.pauseMenu.remove(); S.pauseMenu = null; } + S.view.ov.classList.remove('sqc-is-paused'); S.paused = false; if (S.mode === 'edit') return; // the editor stays stopped; Space plays if (S.leadRemaining != null) { S.leadUntil = performance.now() + S.leadRemaining; S.leadRemaining = null; } 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 const drawResume = (ctx) => { if (!S.resumeUntil) return; @@ -1432,7 +1640,7 @@ 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.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) { 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); @@ -1627,7 +1835,7 @@ // keys (through the key guard; typing in the title field is left to the field) s.onKey = (e) => { 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; e.preventDefault(); const t = songTime(); @@ -1675,15 +1883,14 @@ 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); 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); - ctx.strokeStyle = col; ctx.lineWidth = i === s.sel ? 4 : 2; - ctx.strokeRect(px - NOTE / 2, py - NOTE / 2, NOTE, NOTE); - if (i === s.sel) { ctx.setLineDash([5, 4]); ctx.strokeRect(px - NOTE / 2 - 8, py - NOTE / 2 - 8, NOTE + 16, NOTE + 16); ctx.setLineDash([]); } + drawNoteBody(ctx, px, py, NOTE, col, i === s.sel ? 4 : 2); + ctx.strokeStyle = col; + if (i === s.sel) { ctx.lineWidth = 2; ctx.setLineDash([5, 4]); strokeShape(ctx, px, py, NOTE + 16); ctx.setLineDash([]); } ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; 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); } // 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; } // 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 } + // 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) - const playMap = (map, opts = {}) => { - const notes = (typeof map.notes === 'string' ? JSON.parse(map.notes) : map.notes) - .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 })); + // Map notes -> game notes (judging state, stacking, prepared sliders) + const toPlayNotes = (raw) => { + 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); 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', { - 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, }); if (!s) return; @@ -1902,10 +2142,7 @@ 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 // body - ctx.fillStyle = 'rgba(0,0,0,0.55)'; - 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); + drawNoteBody(ctx, p.x, p.y, NOTE, col, 3); 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.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; ctx.globalAlpha = broken ? 0.5 : 1; 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.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([]); } // 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 s2 = NOTE * sc; 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; } @@ -1951,7 +2188,7 @@ // done once every note is judged and the last effects faded 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); diff --git a/public/s/sfx/hitsound.mp3 b/public/s/sfx/hitsound.mp3 new file mode 100644 index 0000000..e56f31d Binary files /dev/null and b/public/s/sfx/hitsound.mp3 differ diff --git a/scripts/chat-upload-key.mjs b/scripts/chat-upload-key.mjs new file mode 100644 index 0000000..b44e609 --- /dev/null +++ b/scripts/chat-upload-key.mjs @@ -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 [max_mb]"); + console.error(" node scripts/chat-upload-key.mjs list"); + console.error(" node scripts/chat-upload-key.mjs revoke "); + 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()); diff --git a/src/chat_preview_handler.mjs b/src/chat_preview_handler.mjs new file mode 100644 index 0000000..e51f039 --- /dev/null +++ b/src/chat_preview_handler.mjs @@ -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 ; stop reading once it is complete + if (size > max || (stopAt && chunk.toString('latin1').toLowerCase().includes(stopAt))) res.destroy(); + }); + const done = () => { clearTimeout(timer); resolve(Buffer.concat(chunks).subarray(0, max)); }; + res.on('end', done); + res.on('close', done); + res.on('error', e => { clearTimeout(timer); chunks.length ? done() : reject(e); }); + }); +} + +// ─── Metadata parsing ───────────────────────────────────────────────────────── + +const decodeEntities = s => s + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))) + .replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, '<').replace(/>/g, '>') + .replace(/ /g, ' ').replace(/&/g, '&'); + +const clean = (s, max) => { + if (!s) return ''; + const t = decodeEntities(s).replace(/\s+/g, ' ').trim(); + return t.length > max ? t.slice(0, max - 1) + '…' : t; +}; + +export function parsePreview(html, pageUrl) { + const head = html.split(/<\/head>/i)[0]; + const meta = {}; + for (const [, attrs] of head.matchAll(/]+?)\/?>/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 = /]*>([\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, ''); + 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); + } +} diff --git a/src/chat_upload_handler.mjs b/src/chat_upload_handler.mjs new file mode 100644 index 0000000..bd613cd --- /dev/null +++ b/src/chat_upload_handler.mjs @@ -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); + }); +} diff --git a/src/inc/config.mjs b/src/inc/config.mjs index 554c573..5f4547d 100644 --- a/src/inc/config.mjs +++ b/src/inc/config.mjs @@ -93,6 +93,7 @@ config.paths = { fonts: resolvePath('public/s/fonts'), memes: resolvePath('public/memes'), e: resolvePath('public/e'), + cu: resolvePath('public/cu'), pending: resolvePath('pending'), deleted: resolvePath('deleted'), logs: resolvePath('logs'), diff --git a/src/index.mjs b/src/index.mjs index 71aad33..7d4def5 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -21,6 +21,8 @@ import { handleMetaExtract } from "./meta_extract_handler.mjs"; import { handleMetaStrip } from "./meta_strip_handler.mjs"; import { handleCommentUpload, handleCommentUploadCancel } from "./comment_upload_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 { updateHallsCache, getHalls } from "./inc/halls_cache.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)`); + // 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 // 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-Credentials', 'true'); 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') { res.writeHead(204).end(); @@ -1470,7 +1499,7 @@ process.on('uncaughtException', err => { // Private Society gate — require login for all content when enabled 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)) { // For AJAX requests, return 502 so it looks like the backend is down if (req.headers['x-requested-with'] === 'XMLHttpRequest') { @@ -1534,6 +1563,8 @@ process.on('uncaughtException', err => { app.use(async (req, res) => { 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; + // 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 if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return; // 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.debug = true; tpl.cache = false;