From d664a2f5d8d46cbf11239e528b98091ecb8e471b Mon Sep 17 00:00:00 2001 From: Ryan Date: Fri, 31 Oct 2025 17:34:25 -0400 Subject: [PATCH] release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth --- CHANGELOG.md | 84 ++ config/config.php | 16 - public/.htaccess | 71 +- public/css/styles.css | 11 +- public/css/vendor/material-icons.css | 2 +- public/css/vendor/roboto.css | 8 +- public/index.html | 142 ++-- public/js/adminPanel.js | 518 ++++++++----- public/js/appCore.js | 23 +- public/js/auth.js | 364 ++++++--- public/js/defer-css.js | 20 + public/js/fileActions.js | 5 + public/js/fileEditor.js | 148 ++-- public/js/fileListView.js | 89 ++- public/js/i18n.js | 2 +- public/js/main.js | 1071 +++++++++++++++++++++----- public/js/upload.js | 136 +++- src/controllers/AdminController.php | 95 +-- src/controllers/AuthController.php | 5 +- src/controllers/UploadController.php | 108 +-- src/models/UploadModel.php | 321 ++++---- 21 files changed, 2272 insertions(+), 967 deletions(-) create mode 100644 public/js/defer-css.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d8101e0..fa3d0c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,89 @@ # Changelog +## Changes 10/31/2025 (v1.7.3) + +release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth + +### 🎃 Highlights (advantages) 👻 🦇 + +- ⚡ Faster, cleaner boot: a lightweight **main.js** decides auth/setup before painting, avoids flicker, and wires modules exactly once. +- ♻️ Fewer duplicate actions: **request coalescer** dedupes POST/PUT/PATCH/DELETE to /api/* . +- ✅ Truthy UX: global **toast bridge** queues early toasts and normalizes misleading “not found/already exists” messages after success. +- 🔐 Smoother auth: CSRF priming/rotation + **TOTP step-up detection** across JSON & redirect paths; “Welcome back, `user`” toast once per tab. +- 🌓 Polished UI: **dark-mode persistence with system fallback**, live siteConfig title application, higher-z modals, drag auto-scroll. +- 🚀 Faster first paint & interactions: defer CodeMirror/Fuse/Resumable, promote preloaded CSS, and coalesce duplicate requests → snappier UI. +- 🧭 Admin polish: live header title preview, masked OIDC fields with **Replace** flow, and a **read-only Sponsors/Donations** section. +- 🧱 Safer & cache-smarter: opinionated .htaccess (CSP/HSTS/MIME/compression) + `?v={{APP_QVER}}` for versioned immutable assets. + +### Core bootstrap (main.js) overhaul + +- Early **toast bridge** (queues until domUtils is ready); expose `window.__FR_TOAST_FILTER__` for centralized rewrites/suppression. +- **Result guard + request coalescer** wrapping `fetch`: + - Dedupes same-origin `/api/*` mutating requests for ~800ms using a stable key (method + path + normalized body). + - Tracks “last OK” JSON (`success|status|result=ok`) to suppress false-negative error toasts after success. +- **Boot orchestrator** with hard guards: + - `__FR_FLAGS` (`booted`, `initialized`, `wired.*`, `bootPromise`, `entryStarted`) to prevent double init/leaks. + - **No-flicker login**: resolve `checkAuth()` + `setup` before showing UI; show login only when truly unauthenticated. + - **Heavy boot** for authed users: load i18n, `appCore.loadCsrfToken/initializeApp`, first file list, then light UI wiring. +- **Auth flow**: + - `primeCsrf()` + `` management; persist token in localStorage. + - **TOTP** detection via header (`X-TOTP-Required`) & JSON (`totp_required` / `TOTP_REQUIRED`); calls `openTOTPLoginModal()`. + - **Welcome toast** once per tab via `sessionStorage.__fr_welcomed`. +- **UI/UX niceties**: + - `applySiteConfig()` updates header title & login method visibility on both login & authed screens. + - Dark-mode persistence with system fallback, proper a11y labels/icons. + - Create dropdown/menu wiring with capture-phase outside-click + ESC close; modal cancel safeties. + - Lift modals above cards (z-index), **drag auto-scroll** near viewport edges. + - Dispatch legacy `DOMContentLoaded`/`load` **once** (supports older inline handlers). + - Username label refresh for existing `.user-name-label` without injecting new DOM. + +### Performance & UX changes + +- CSS/first paint: + - Preload Bootstrap & app CSS; promote at DOMContentLoaded; keep inline CSS minimal. + - Add `width/height/decoding/fetchpriority` to logo to reduce layout shift. +- Search/editor/uploads: + - **fileListView.js**: lazy-load Fuse with instant substring fallback; `warmUpSearch()` hook. + - **fileEditor.js**: lazy-load CodeMirror core/theme/modes; start plain then upgrade; guard very large files gracefully. + - **upload.js**: lazy-load Resumable; resilient init; background warm-up; smarter addFile/submit; clearer toasts. +- Toast/UX: + - Install early toast bridge; queue & normalize messages; neutral “Done.” when server returns misleading errors after success. + +### Correctness: uploads, paths, ACLs + +- **UploadController/UploadModel**: normalize folders via `ACL::normalizeFolder(rawurldecode())`; stricter segment checks; consistent base paths; safer metadata writes; proper chunk presence/merge & temp cleanup. + +### Auth hardening & resilience + +- **auth.js/main.js/appCore.js**: CSRF rotate/retry (JSON then x-www-form-urlencoded fallback); robust login handling; fewer misleading error toasts. +- **AuthController**: OIDC username fallback to `email` or `sub` when `preferred_username` missing. + +### Admin panel + +- **adminPanel.js**: + - Live header title preview (instant update without reload). + - Masked OIDC client fields with **Replace** button; saved-value hints; only send secrets when replacing. + - **New “Sponsor / Donations” section (read-only)**: + - GitHub Sponsors → `https://github.com/sponsors/error311` + - Ko-fi → `https://ko-fi.com/error311` + - Includes **Copy** and **Open** buttons; values are fixed. +- **AdminController**: boolean for `oidc.hasClientId/hasClientSecret` to drive masked inputs. + +### Security & caching (.htaccess) + +- Consolidated security headers (CSP, CORP, HSTS on HTTPS), MIME types, compression (Brotli/Deflate), TRACE disable. +- Caching rules: + - HTML/version.js: no-cache; unversioned JS/CSS: 1h; unversioned static: 7d; **versioned assets `?v=`: 1y `immutable`**. +- **config.php**: remove duplicate runtime headers (now via Apache) to avoid proxy/CDN conflicts. + +### Upgrade notes + +- No schema changes. +- Ensure Apache modules (`headers`, `rewrite`, `brotli`/`deflate`) are available for the new .htaccess rules (fallbacks included). +- Versioned assets mean users shouldn’t need a hard refresh; `?v={{APP_QVER}}` busts caches automatically. + +--- + ## Changes 10/29/2025 (v1.7.0 & v1.7.1 & v1.7.2) release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish diff --git a/config/config.php b/config/config.php index e0fa1b6..5f489bf 100644 --- a/config/config.php +++ b/config/config.php @@ -1,22 +1,6 @@ RewriteEngine On -# If you want forced HTTPS behind a proxy, keep this off here and do it at the proxy + +# --- HTTPS redirect --- +# Use ONE of these blocks. + +# A) Direct TLS on this server (enable this if Apache terminates HTTPS here) #RewriteCond %{HTTPS} off #RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] -# MIME types (fonts/SVG/ESM) +# B) Behind a reverse proxy/CDN that sets X-Forwarded-Proto +#RewriteCond %{HTTP:X-Forwarded-Proto} =http [OR] +#RewriteCond %{HTTP:X-Forwarded-Proto} ^$ +#RewriteCond %{HTTPS} !=on +#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Don't interfere with ACME/http-01 if you do your own certs +#RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/ +#RewriteRule - - [L] + +# --- MIME types (fonts/SVG/ESM) --- AddType font/woff2 .woff2 AddType font/woff .woff @@ -23,58 +37,57 @@ RewriteEngine On AddType application/javascript .mjs -# Security headers +# --- Security headers --- Header always set X-Frame-Options "SAMEORIGIN" Header always set X-XSS-Protection "1; mode=block" Header always set X-Content-Type-Options "nosniff" - # HSTS: only if HTTPS (prevents mixed local dev warnings) - Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'" Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()" Header always set X-Download-Options "noopen" Header always set Expect-CT "max-age=86400, enforce" - # Nice extra hardening (same-origin resource sharing) Header always set Cross-Origin-Resource-Policy "same-origin" Header always set X-Permitted-Cross-Domain-Policies "none" + # HSTS only when actually on HTTPS + Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'" - # CSP (modules, workers, blobs already accounted for) - Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" + # CSP (modules, blobs, workers, etc.) + Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'" -# Caching -SetEnvIfNoCase QUERY_STRING "(^|&)v=" has_version_param=1 +# --- Caching (query-string based, no env vars needed) --- - # HTML/PHP: no cache (app shell) + # HTML/PHP: no cache (only if PHP didn’t already set it) - Header set Cache-Control "no-cache, no-store, must-revalidate" - Header set Pragma "no-cache" - Header set Expires "0" + Header setifempty Cache-Control "no-cache, no-store, must-revalidate" + Header setifempty Pragma "no-cache" + Header setifempty Expires "0" - # version.js is your source-of-truth; keep it non-cacheable so dev/CI flips show up + # version.js: always non-cacheable Header set Cache-Control "no-cache, no-store, must-revalidate" Header set Pragma "no-cache" Header set Expires "0" -# Unversioned JS/CSS (dev): 1 hour - - Header set Cache-Control "public, max-age=3600, must-revalidate" env=!has_version_param - + # Unversioned JS/CSS: 1 hour + + Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/" + -# Unversioned static assets (dev): 7 days - - Header set Cache-Control "public, max-age=604800" env=!has_version_param - + # Unversioned static (images/fonts): 7 days + + Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/" + -# Versioned assets (?v=...): 1 year + immutable - - Header set Cache-Control "public, max-age=31536000, immutable" env=has_version_param - + # Versioned assets (?v=...): 1 year + immutable + + Header setifempty Cache-Control "public, max-age=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/" + + -# Compression (if modules exist) +# --- Compression --- BrotliCompressionQuality 5 AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml @@ -83,6 +96,6 @@ SetEnvIfNoCase QUERY_STRING "(^|&)v=" has_version_param=1 AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml -# Disable TRACE +# --- Disable TRACE --- RewriteCond %{REQUEST_METHOD} ^TRACE RewriteRule .* - [F] \ No newline at end of file diff --git a/public/css/styles.css b/public/css/styles.css index d501273..5ff1043 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -37,7 +37,11 @@ body { /************************************************************/ /* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */ /************************************************************/ - .header-logo .logo { height: 50px; width: auto; display: block; } + .header-logo .logo { + display:block; + max-width:100%; + height:auto; /* keep aspect ratio; HTML attrs set the intrinsic box */ + } .btn-login { margin-top: 10px; }/* Color overrides */ @@ -1598,7 +1602,7 @@ body { #removeUserModal { z-index: 5000 !important; }#customConfirmModal { - z-index: 6000 !important; + z-index: 12000 !important; }.admin-panel-content { background: #fff; color: #000; @@ -1867,4 +1871,5 @@ body { #sidebarToggleFloating.is-collapsed { background: #fafafa; border-color: #e2e2e2; - } \ No newline at end of file + } + \ No newline at end of file diff --git a/public/css/vendor/material-icons.css b/public/css/vendor/material-icons.css index 3ba292f..1c6b8c2 100644 --- a/public/css/vendor/material-icons.css +++ b/public/css/vendor/material-icons.css @@ -4,7 +4,7 @@ font-style: normal; font-weight: 400; font-display: swap; - src: url(/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2'); + src: url('/fonts/material-icons/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2?v={{APP_QVER}}') format('woff2'); } .material-icons { diff --git a/public/css/vendor/roboto.css b/public/css/vendor/roboto.css index ca9215f..5b716c6 100644 --- a/public/css/vendor/roboto.css +++ b/public/css/vendor/roboto.css @@ -4,7 +4,7 @@ font-style:normal; font-weight:400; font-display:swap; - src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2') format('woff2'); + src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2'); unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } /* Roboto Regular 400 — latin */ @@ -13,7 +13,7 @@ font-style:normal; font-weight:400; font-display:swap; - src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2') format('woff2'); + src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2'); unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } /* Roboto Medium 500 — latin-ext */ @@ -22,7 +22,7 @@ font-style:normal; font-weight:500; font-display:swap; - src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2') format('woff2'); + src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}') format('woff2'); unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; } /* Roboto Medium 500 — latin */ @@ -31,7 +31,7 @@ font-style:normal; font-weight:500; font-display:swap; - src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2') format('woff2'); + src:url('/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}') format('woff2'); unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; } diff --git a/public/index.html b/public/index.html index 615a81a..32a550b 100644 --- a/public/index.html +++ b/public/index.html @@ -5,38 +5,58 @@ FileRise + + + + + + - - - + + - - + + + - - - + + + + - - + + + - - - - + + - - + + + - + + + + + + + + + - @@ -44,7 +64,14 @@
@@ -103,7 +130,7 @@
- +
@@ -115,7 +142,7 @@
- +
@@ -133,6 +160,8 @@
+
+
@@ -215,7 +244,7 @@
@@ -233,7 +262,7 @@ + data-i18n-key="rename" data-default>Rename @@ -253,7 +282,7 @@ + data-i18n-key="delete" data-default>Delete @@ -289,7 +318,7 @@ selected files?

@@ -303,7 +332,7 @@ @@ -317,7 +346,7 @@ @@ -325,35 +354,20 @@ data-i18n-key="download_zip">Download ZIP - + @@ -374,7 +388,7 @@ placeholder="files.zip" /> @@ -412,14 +426,14 @@ placeholder="Filename" />
- +
@@ -486,7 +500,7 @@ diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 998461b..156a84f 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -10,16 +10,16 @@ const adminTitle = `${t("admin_panel")} { + const title = (val || '').trim() || 'FileRise'; + const h1 = document.querySelector('.header-title h1'); + if (h1) h1.textContent = title; + document.title = title; + window.headerTitle = val || ''; // preserve raw value user typed + try { localStorage.setItem('headerTitle', title); } catch { } + }; + + // apply current value immediately + on each keystroke + apply(input.value); + input.addEventListener('input', (e) => apply(e.target.value)); +} + +function renderMaskedInput({ id, label, hasValue, isSecret = false }) { + const type = isSecret ? 'password' : 'text'; + const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : ''; + const replaceBtn = hasValue + ? `` + : ''; + const note = hasValue + ? `Saved — leave blank to keep` + : ''; + + return ` +
+ +
+ + ${replaceBtn} +
+ ${note} +
+ `; +} + +function wireReplaceButtons(scope = document) { + scope.querySelectorAll('[data-replace-for]').forEach(btn => { + if (btn.__wired) return; + btn.__wired = true; + btn.addEventListener('click', () => { + const id = btn.getAttribute('data-replace-for'); + const inp = scope.querySelector('#' + id); + if (!inp) return; + inp.disabled = false; + inp.dataset.replace = '1'; + inp.placeholder = ''; + inp.value = ''; + btn.textContent = 'Keep saved value'; + btn.removeAttribute('data-replace-for'); + btn.addEventListener('click', () => { /* no-op after first toggle */ }, { once: true }); + }, { once: true }); + }); +} + function onShareFolderToggle(row, checked) { const manage = qs(row, 'input[data-cap="manage"]'); const viewAll = qs(row, 'input[data-cap="view"]'); @@ -52,14 +112,14 @@ function onShareFileToggle(row, checked) { const viewAll = qs(row, 'input[data-cap="view"]'); const viewOwn = qs(row, 'input[data-cap="viewOwn"]'); const hasView = !!(viewAll && viewAll.checked); - const hasOwn = !!(viewOwn && viewOwn.checked); + const hasOwn = !!(viewOwn && viewOwn.checked); if (!hasView && !hasOwn && viewOwn) { viewOwn.checked = true; } } function onWriteToggle(row, checked) { - const caps = ["create","upload","edit","rename","copy","delete","extract"]; + const caps = ["create", "upload", "edit", "rename", "copy", "delete", "extract"]; caps.forEach(c => { const box = qs(row, `input[data-cap="${c}"]`); if (box) box.checked = checked; @@ -426,20 +486,21 @@ export function openAdminPanel() {
×

${adminTitle}

- ${[ - { id: "userManagement", label: t("user_management") }, - { id: "headerSettings", label: t("header_settings") }, - { id: "loginOptions", label: t("login_options") }, - { id: "webdav", label: "WebDAV Access" }, - { id: "upload", label: t("shared_max_upload_size_bytes_title") }, - { id: "oidc", label: t("oidc_configuration") + " & TOTP" }, - { id: "shareLinks", label: t("manage_shared_links") } - ].map(sec => ` - -
- `).join("")} + ${[ + { id: "userManagement", label: t("user_management") }, + { id: "headerSettings", label: t("header_settings") }, + { id: "loginOptions", label: t("login_options") }, + { id: "webdav", label: "WebDAV Access" }, + { id: "upload", label: t("shared_max_upload_size_bytes_title") }, + { id: "oidc", label: t("oidc_configuration") + " & TOTP" }, + { id: "shareLinks", label: t("manage_shared_links") }, + { id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") } + ].map(sec => ` + +
+ `).join("")}
@@ -453,7 +514,7 @@ export function openAdminPanel() { document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel); document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel); - ["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks"] + ["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks", "sponsor"] .forEach(id => { document.getElementById(id + "Header") .addEventListener("click", () => toggleSection(id)); @@ -485,6 +546,7 @@ export function openAdminPanel() {
`; + wireHeaderTitleLive(); document.getElementById("loginOptionsContent").innerHTML = `
@@ -512,16 +574,34 @@ export function openAdminPanel() { `; + const hasId = !!(config.oidc && config.oidc.hasClientId); + const hasSecret = !!(config.oidc && config.oidc.hasClientSecret); + document.getElementById("oidcContent").innerHTML = ` -
- Note: OIDC credentials (Client ID/Secret) will show blank here after saving, but remain unchanged until you explicitly edit and save them. -
-
-
-
-
-
- `; +
+ Client ID/Secret are never shown after saving. A green note indicates a value is saved. Click “Replace” to overwrite. +
+ +
+ + +
+ + ${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })} + ${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })} + +
+ + +
+ +
+ + +
+`; + + wireReplaceButtons(document.getElementById("oidcContent")); document.getElementById("shareLinksContent").textContent = t("loading") + "…"; @@ -545,6 +625,60 @@ export function openAdminPanel() { } }); + // --- Sponsor (fixed, non-editable) --- + const SPONSOR_GH = "https://github.com/sponsors/error311"; + const SPONSOR_KOFI = "https://ko-fi.com/error311"; + + document.getElementById("sponsorContent").innerHTML = ` +
+ +
+ + + Open +
+
+ +
+ +
+ + + Open +
+
+ + ${(typeof tf === 'function' + ? tf("sponsor_note_fixed", "Please consider supporting ongoing development.") + : "Please consider supporting ongoing development.")} +`; + + // Wire copy + open (no changes tracked) + const ghInput = document.getElementById("sponsorGitHub"); + const kfInput = document.getElementById("sponsorKoFi"); + + document.getElementById("copySponsorGitHub").addEventListener("click", async () => { + try { await navigator.clipboard.writeText(ghInput.value); } catch { } + showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!"); + }); + document.getElementById("copySponsorKoFi").addEventListener("click", async () => { + try { await navigator.clipboard.writeText(kfInput.value); } catch { } + showToast(typeof tf === 'function' ? tf("copied", "Copied!") : "Copied!"); + }); + + document.getElementById("openSponsorGitHub").href = SPONSOR_GH; + document.getElementById("openSponsorKoFi").href = SPONSOR_KOFI; + const userMgmt = document.getElementById("userManagementContent"); userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick); window.__userMgmtDelegatedClick = (e) => { @@ -574,7 +708,11 @@ export function openAdminPanel() { document.getElementById("enableWebDAV").checked = config.enableWebDAV === true; document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || ""; document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || ""; - document.getElementById("oidcClientId").value = window.currentOIDCConfig?.clientId || ""; + const idEl = document.getElementById("oidcClientId"); + const secEl = document.getElementById("oidcClientSecret"); + if (!hasId) idEl.value = window.currentOIDCConfig?.clientId || ""; + if (!hasSecret) secEl.value = window.currentOIDCConfig?.clientSecret || ""; + wireReplaceButtons(document.getElementById("oidcContent")); document.getElementById("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || ""; document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || ""; document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || ''; @@ -585,57 +723,57 @@ export function openAdminPanel() { } function handleSave() { - const dFL = !!document.getElementById("disableFormLogin")?.checked; - const dBA = !!document.getElementById("disableBasicAuth")?.checked; - const dOIDC = !!document.getElementById("disableOIDCLogin")?.checked; - const aBypass = !!document.getElementById("authBypass")?.checked; - const aHeader = (document.getElementById("authHeaderName")?.value || "X-Remote-User").trim(); - const eWD = !!document.getElementById("enableWebDAV")?.checked; - const sMax = parseInt(document.getElementById("sharedMaxUploadSize")?.value || "0", 10) || 0; - const nHT = (document.getElementById("headerTitle")?.value || "").trim(); - const nOIDC = { - providerUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(), - clientId: (document.getElementById("oidcClientId")?.value || "").trim(), - clientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(), - redirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim() + const payload = { + header_title: document.getElementById("headerTitle")?.value || "", + loginOptions: { + disableFormLogin: document.getElementById("disableFormLogin").checked, + disableBasicAuth: document.getElementById("disableBasicAuth").checked, + disableOIDCLogin: document.getElementById("disableOIDCLogin").checked, + authBypass: document.getElementById("authBypass").checked, + authHeaderName: document.getElementById("authHeaderName").value.trim() || "X-Remote-User", + }, + enableWebDAV: document.getElementById("enableWebDAV").checked, + sharedMaxUploadSize: parseInt(document.getElementById("sharedMaxUploadSize").value || "0", 10) || 0, + oidc: { + providerUrl: document.getElementById("oidcProviderUrl").value.trim(), + redirectUri: document.getElementById("oidcRedirectUri").value.trim(), + // clientId/clientSecret: only include when replacing + }, + globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(), }; - const gURL = (document.getElementById("globalOtpauthUrl")?.value || "").trim(); - if ([dFL, dBA, dOIDC].filter(x => x).length === 3) { - showToast(t("at_least_one_login_method")); - return; + const idEl = document.getElementById("oidcClientId"); + const scEl = document.getElementById("oidcClientSecret"); + + if (idEl?.dataset.replace === '1' && idEl.value.trim() !== '') { + payload.oidc.clientId = idEl.value.trim(); + } + if (scEl?.dataset.replace === '1' && scEl.value.trim() !== '') { + payload.oidc.clientSecret = scEl.value.trim(); } - sendRequest("/api/admin/updateConfig.php", "POST", { - header_title: nHT, - oidc: nOIDC, - loginOptions: { - disableFormLogin: dFL, - disableBasicAuth: dBA, - disableOIDCLogin: dOIDC, - authBypass: aBypass, - authHeaderName: aHeader + fetch('/api/admin/updateConfig.php', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '') }, - enableWebDAV: eWD, - sharedMaxUploadSize: sMax, - globalOtpauthUrl: gURL - }, { "X-CSRF-Token": window.csrfToken }) - .then(res => { - if (res.success) { - showToast(t("settings_updated_successfully"), "success"); - captureInitialAdminConfig(); - closeAdminPanel(); - loadAdminConfigFunc(); - } else { - showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error"); - } - }).catch(() => {/*noop*/ }); + body: JSON.stringify(payload) + }) + .then(r => r.json()) + .then(j => { + if (j.error) { showToast('Error: ' + j.error); return; } + showToast('Settings saved.'); + closeAdminPanel(); + }) + .catch(() => showToast('Save failed.')); } export async function closeAdminPanel() { if (hasUnsavedChanges()) { - const ok = await showCustomConfirmModal(t("unsaved_changes_confirm")); - if (!ok) return; + //const ok = await showCustomConfirmModal(t("unsaved_changes_confirm")); + //if (!ok) return; } const m = document.getElementById("adminPanelModal"); if (m) m.style.display = "none"; @@ -645,29 +783,29 @@ export async function closeAdminPanel() { New: Folder Access (ACL) UI =========================== */ - let __allFoldersCache = null; +let __allFoldersCache = null; - async function getAllFolders(force = false) { - if (!force && __allFoldersCache) return __allFoldersCache.slice(); - - const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), { - credentials: 'include', - cache: 'no-store', - headers: { 'Cache-Control': 'no-store' } - }); - const data = await safeJson(res).catch(() => []); - const list = Array.isArray(data) - ? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean) - : []; - - const hidden = new Set(['profile_pics', 'trash']); - const cleaned = list - .filter(f => f && !hidden.has(f.toLowerCase())) - .sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b))); - - __allFoldersCache = cleaned; - return cleaned.slice(); - } +async function getAllFolders(force = false) { + if (!force && __allFoldersCache) return __allFoldersCache.slice(); + + const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), { + credentials: 'include', + cache: 'no-store', + headers: { 'Cache-Control': 'no-store' } + }); + const data = await safeJson(res).catch(() => []); + const list = Array.isArray(data) + ? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean) + : []; + + const hidden = new Set(['profile_pics', 'trash']); + const cleaned = list + .filter(f => f && !hidden.has(f.toLowerCase())) + .sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b))); + + __allFoldersCache = cleaned; + return cleaned.slice(); +} async function getUserGrants(username) { const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, { @@ -683,7 +821,7 @@ function renderFolderGrantsUI(username, container, folders, grants) { // toolbar const toolbar = document.createElement('div'); toolbar.className = 'folder-access-toolbar'; -toolbar.innerHTML = ` + toolbar.innerHTML = ` @@ -717,8 +855,8 @@ toolbar.innerHTML = ` const headerHtml = `
-
- ${tf('folder','Folder')} +
+ ${tf('folder', 'Folder')}
${tf('view_all', 'View (all)')} @@ -802,7 +940,7 @@ toolbar.innerHTML = ` } function refreshInheritance() { - const rows = qsa(list, '.folder-access-row').sort((a,b)=> (a.dataset.folder||'').length - (b.dataset.folder||'').length); + const rows = qsa(list, '.folder-access-row').sort((a, b) => (a.dataset.folder || '').length - (b.dataset.folder || '').length); const managedPrefixes = new Set(); rows.forEach(row => { const folder = row.dataset.folder || ""; @@ -813,13 +951,13 @@ toolbar.innerHTML = ` if (p && folder !== p && folder.startsWith(p + '/')) { inheritedFrom = p; break; } } if (inheritedFrom) { - const v = qs(row,'input[data-cap="view"]'); - const w = qs(row,'input[data-cap="write"]'); - const vo= qs(row,'input[data-cap="viewOwn"]'); + const v = qs(row, 'input[data-cap="view"]'); + const w = qs(row, 'input[data-cap="write"]'); + const vo = qs(row, 'input[data-cap="viewOwn"]'); if (v) v.checked = true; if (w) w.checked = true; if (vo) { vo.checked = false; vo.disabled = true; } - ['create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder'] + ['create', 'upload', 'edit', 'rename', 'copy', 'delete', 'extract', 'shareFile', 'shareFolder'] .forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; }); setRowDisabled(row, true); const tag = row.querySelector('.inherited-tag'); @@ -828,8 +966,8 @@ toolbar.innerHTML = ` setRowDisabled(row, false); } enforceShareFolderRule(row); - const cbView = qs(row,'input[data-cap="view"]'); - const cbViewOwn = qs(row,'input[data-cap="viewOwn"]'); + const cbView = qs(row, 'input[data-cap="view"]'); + const cbViewOwn = qs(row, 'input[data-cap="viewOwn"]'); if (cbView && cbViewOwn) { if (cbView.checked) { cbViewOwn.checked = false; @@ -847,8 +985,8 @@ toolbar.innerHTML = ` if (!checked && (which === 'view' || which === 'viewOwn')) { qsa(row, 'input[type="checkbox"]').forEach(cb => cb.checked = false); } - const cbView = qs(row,'input[data-cap="view"]'); - const cbVO = qs(row,'input[data-cap="viewOwn"]'); + const cbView = qs(row, 'input[data-cap="view"]'); + const cbVO = qs(row, 'input[data-cap="viewOwn"]'); if (cbView && cbVO) { if (cbView.checked) { cbVO.checked = false; @@ -863,19 +1001,19 @@ toolbar.innerHTML = ` } function wireRow(row) { - const cbView = row.querySelector('input[data-cap="view"]'); + const cbView = row.querySelector('input[data-cap="view"]'); const cbViewOwn = row.querySelector('input[data-cap="viewOwn"]'); - const cbWrite = row.querySelector('input[data-cap="write"]'); - const cbManage = row.querySelector('input[data-cap="manage"]'); - const cbCreate = row.querySelector('input[data-cap="create"]'); - const cbUpload = row.querySelector('input[data-cap="upload"]'); - const cbEdit = row.querySelector('input[data-cap="edit"]'); - const cbRename = row.querySelector('input[data-cap="rename"]'); - const cbCopy = row.querySelector('input[data-cap="copy"]'); - const cbMove = row.querySelector('input[data-cap="move"]'); - const cbDelete = row.querySelector('input[data-cap="delete"]'); + const cbWrite = row.querySelector('input[data-cap="write"]'); + const cbManage = row.querySelector('input[data-cap="manage"]'); + const cbCreate = row.querySelector('input[data-cap="create"]'); + const cbUpload = row.querySelector('input[data-cap="upload"]'); + const cbEdit = row.querySelector('input[data-cap="edit"]'); + const cbRename = row.querySelector('input[data-cap="rename"]'); + const cbCopy = row.querySelector('input[data-cap="copy"]'); + const cbMove = row.querySelector('input[data-cap="move"]'); + const cbDelete = row.querySelector('input[data-cap="delete"]'); const cbExtract = row.querySelector('input[data-cap="extract"]'); - const cbShareF = row.querySelector('input[data-cap="shareFile"]'); + const cbShareF = row.querySelector('input[data-cap="shareFile"]'); const cbShareFo = row.querySelector('input[data-cap="shareFolder"]'); const granular = [cbCreate, cbUpload, cbEdit, cbRename, cbCopy, cbMove, cbDelete, cbExtract]; @@ -885,7 +1023,7 @@ toolbar.innerHTML = ` if (cbView) cbView.checked = true; if (cbWrite) cbWrite.checked = true; granular.forEach(cb => { if (cb) cb.checked = true; }); - if (cbShareF) cbShareF.checked = true; + if (cbShareF) cbShareF.checked = true; if (cbShareFo && !cbShareFo.disabled) cbShareFo.checked = true; } }; @@ -919,7 +1057,7 @@ toolbar.innerHTML = ` const w = r.querySelector('input[data-cap="write"]'); const vo = r.querySelector('input[data-cap="viewOwn"]'); const boxes = [ - 'create','upload','edit','rename','copy','delete','extract','shareFile','shareFolder' + 'create', 'upload', 'edit', 'rename', 'copy', 'delete', 'extract', 'shareFile', 'shareFolder' ].map(c => r.querySelector(`input[data-cap="${c}"]`)); if (m) m.checked = checked; if (v) v.checked = checked; @@ -932,7 +1070,7 @@ toolbar.innerHTML = ` }; if (cbManage) cbManage.addEventListener('change', () => { applyManage(); onShareFile(); cascadeManage(cbManage.checked); }); - if (cbWrite) cbWrite.addEventListener('change', applyWrite); + if (cbWrite) cbWrite.addEventListener('change', applyWrite); granular.forEach(cb => { if (cb) cb.addEventListener('change', () => { syncWriteFromGranular(); }); }); if (cbView) cbView.addEventListener('change', () => { setFromViewChange(row, 'view', cbView.checked); refreshInheritance(); }); if (cbViewOwn) cbViewOwn.addEventListener('change', () => { setFromViewChange(row, 'viewOwn', cbViewOwn.checked); refreshInheritance(); }); @@ -1004,18 +1142,18 @@ function collectGrantsFrom(container) { const folder = row.dataset.folder || row.getAttribute('data-folder'); if (!folder) return; const g = { - view: get(row, 'input[data-cap="view"]'), - viewOwn: get(row, 'input[data-cap="viewOwn"]'), - manage: get(row, 'input[data-cap="manage"]'), - create: get(row, 'input[data-cap="create"]'), - upload: get(row, 'input[data-cap="upload"]'), - edit: get(row, 'input[data-cap="edit"]'), - rename: get(row, 'input[data-cap="rename"]'), - copy: get(row, 'input[data-cap="copy"]'), - move: get(row, 'input[data-cap="move"]'), - delete: get(row, 'input[data-cap="delete"]'), - extract: get(row, 'input[data-cap="extract"]'), - shareFile: get(row, 'input[data-cap="shareFile"]'), + view: get(row, 'input[data-cap="view"]'), + viewOwn: get(row, 'input[data-cap="viewOwn"]'), + manage: get(row, 'input[data-cap="manage"]'), + create: get(row, 'input[data-cap="create"]'), + upload: get(row, 'input[data-cap="upload"]'), + edit: get(row, 'input[data-cap="edit"]'), + rename: get(row, 'input[data-cap="rename"]'), + copy: get(row, 'input[data-cap="copy"]'), + move: get(row, 'input[data-cap="move"]'), + delete: get(row, 'input[data-cap="delete"]'), + extract: get(row, 'input[data-cap="extract"]'), + shareFile: get(row, 'input[data-cap="shareFile"]'), shareFolder: get(row, 'input[data-cap="shareFolder"]') }; g.share = !!(g.shareFile || g.shareFolder); @@ -1074,16 +1212,16 @@ export function openUserPermissionsModal() { }); document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => { const rows = userPermissionsModal.querySelectorAll(".user-permission-row"); -const changes = []; -rows.forEach(row => { - if (row.getAttribute("data-admin") === "1") return; // skip admins - const username = String(row.getAttribute("data-username") || "").trim(); - if (!username) return; - const grantsBox = row.querySelector(".folder-grants-box"); - if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return; - const grants = collectGrantsFrom(grantsBox); - changes.push({ user: username, grants }); -}); + const changes = []; + rows.forEach(row => { + if (row.getAttribute("data-admin") === "1") return; // skip admins + const username = String(row.getAttribute("data-username") || "").trim(); + if (!username) return; + const grantsBox = row.querySelector(".folder-grants-box"); + if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return; + const grants = collectGrantsFrom(grantsBox); + changes.push({ user: username, grants }); + }); try { if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; } await sendRequest("/api/admin/acl/saveGrants.php", "POST", @@ -1284,70 +1422,70 @@ async function loadUserPermissionsList() { const folders = await getAllFolders(true); listContainer.innerHTML = ""; -users.forEach(user => { - const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin"; + users.forEach(user => { + const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin"; - const row = document.createElement("div"); - row.classList.add("user-permission-row"); - row.setAttribute("data-username", user.username); - if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins - row.style.padding = "6px 0"; + const row = document.createElement("div"); + row.classList.add("user-permission-row"); + row.setAttribute("data-username", user.username); + if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins + row.style.padding = "6px 0"; - row.innerHTML = ` + row.innerHTML = ` `; - const header = row.querySelector(".user-perm-header"); - const details = row.querySelector(".user-perm-details"); - const caret = row.querySelector(".perm-caret"); - const grantsBox = row.querySelector(".folder-grants-box"); + const header = row.querySelector(".user-perm-header"); + const details = row.querySelector(".user-perm-details"); + const caret = row.querySelector(".perm-caret"); + const grantsBox = row.querySelector(".folder-grants-box"); - async function ensureLoaded() { - if (grantsBox.dataset.loaded === "1") return; - try { - let grants; - if (isAdmin) { - // synthesize full access - const ordered = ["root", ...folders.filter(f => f !== "root")]; - grants = buildFullGrantsForAllFolders(ordered); - renderFolderGrantsUI(user.username, grantsBox, ordered, grants); - // disable all inputs - grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true); - } else { - const userGrants = await getUserGrants(user.username); - renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants); + async function ensureLoaded() { + if (grantsBox.dataset.loaded === "1") return; + try { + let grants; + if (isAdmin) { + // synthesize full access + const ordered = ["root", ...folders.filter(f => f !== "root")]; + grants = buildFullGrantsForAllFolders(ordered); + renderFolderGrantsUI(user.username, grantsBox, ordered, grants); + // disable all inputs + grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true); + } else { + const userGrants = await getUserGrants(user.username); + renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants); + } + grantsBox.dataset.loaded = "1"; + } catch (e) { + console.error(e); + grantsBox.innerHTML = `
${tf("error_loading_user_grants", "Error loading user grants")}
`; + } } - grantsBox.dataset.loaded = "1"; - } catch (e) { - console.error(e); - grantsBox.innerHTML = `
${tf("error_loading_user_grants", "Error loading user grants")}
`; - } - } - function toggleOpen() { - const willShow = details.style.display === "none"; - details.style.display = willShow ? "block" : "none"; - header.setAttribute("aria-expanded", willShow ? "true" : "false"); - caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; - if (willShow) ensureLoaded(); - } + function toggleOpen() { + const willShow = details.style.display === "none"; + details.style.display = willShow ? "block" : "none"; + header.setAttribute("aria-expanded", willShow ? "true" : "false"); + caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)"; + if (willShow) ensureLoaded(); + } - header.addEventListener("click", toggleOpen); - header.addEventListener("keydown", e => { - if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } - }); + header.addEventListener("click", toggleOpen); + header.addEventListener("keydown", e => { + if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); } + }); - listContainer.appendChild(row); -}); + listContainer.appendChild(row); + }); } catch (err) { console.error(err); listContainer.innerHTML = "

" + t("error_loading_users") + "

"; diff --git a/public/js/appCore.js b/public/js/appCore.js index 20511be..3332f34 100644 --- a/public/js/appCore.js +++ b/public/js/appCore.js @@ -86,7 +86,7 @@ export function initializeApp() { // Hook DnD relay from fileList area into upload area const fileListArea = document.getElementById('fileListContainer'); - const uploadArea = document.getElementById('uploadDropArea'); + const uploadArea = document.getElementById('uploadDropArea'); if (fileListArea && uploadArea) { fileListArea.addEventListener('dragover', e => { e.preventDefault(); @@ -136,13 +136,30 @@ export function initializeApp() { LOGOUT (shared) ========================= */ export function triggerLogout() { + const clearWelcomeFlags = () => { + try { + // one-per-tab toast guard + sessionStorage.removeItem('__fr_welcomed'); + // if you also used the per-user (all-tabs) guard, clear that too: + const u = localStorage.getItem('username') || ''; + if (u) localStorage.removeItem(`__fr_welcomed_${u}`); + } catch { } + }; + _nativeFetch("/api/auth/logout.php", { method: "POST", credentials: "include", headers: { "X-CSRF-Token": getCsrfToken() } }) - .then(() => window.location.reload(true)) - .catch(() => { /* no-op */ }); + .then(() => { + clearWelcomeFlags(); + window.location.reload(true); + }) + .catch(() => { + // even if the request fails, clear the flags so the next login can toast + clearWelcomeFlags(); + window.location.reload(true); + }); } /* ========================= diff --git a/public/js/auth.js b/public/js/auth.js index 71319df..4de006b 100644 --- a/public/js/auth.js +++ b/public/js/auth.js @@ -31,6 +31,49 @@ const currentOIDCConfig = { }; window.currentOIDCConfig = currentOIDCConfig; + + +(function installToastFilter() { + const isDemoHost = location.hostname.toLowerCase() === 'demo.filerise.net'; + + window.__FR_TOAST_FILTER__ = function (msgKeyOrText) { + // Suppress the nag while doing TOTP step-up + if (window.pendingTOTP && (msgKeyOrText === 'please_log_in_to_continue' || + /please log in/i.test(String(msgKeyOrText)))) { + return null; // suppress + } + + // Demo host + if (isDemoHost && (msgKeyOrText === 'please_log_in_to_continue' || + /please log in/i.test(String(msgKeyOrText)))) { + return "Demo site — use:\nUsername: demo\nPassword: demo"; + } + + // Try to translate keys; pass through plain text + try { + const maybe = t(msgKeyOrText); + if (typeof maybe === 'string' && maybe !== msgKeyOrText) return maybe; + } catch { } + return msgKeyOrText; + }; +})(); + +function queueWelcomeToast(name) { + const uname = String(name || '').trim().slice(0, 80); + if (!uname) return; + // show immediately (if we don’t reload instantly) + try { + window.dispatchEvent(new CustomEvent('filerise:toast', { + detail: { message: `Welcome back, ${uname}!`, duration: 2000 } + })); + } catch { } + + // and persist for after-reload (flushed by main.js on boot) + try { + sessionStorage.setItem('welcomeMessage', `Welcome back, ${uname}!`); + } catch { } +} + /* ----------------- TOTP & Toast Overrides ----------------- */ // detect if we’re in a pending‑TOTP state window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1'; @@ -72,45 +115,51 @@ const originalFetch = window.fetch; * @param {object} options * @returns {Promise} */ + export async function fetchWithCsrf(url, options = {}) { - // 1) Merge in credentials + header - options = { - credentials: 'include', - ...options, - }; + const original = window.fetch.bind(window); + const wantJson = (options.headers && /json/i.test(options.headers['Content-Type'] || '')) || typeof options.body === 'string' && options.body.trim().startsWith('{'); + + options = { credentials: 'include', ...options }; options.headers = { - ...(options.headers || {}), - 'X-CSRF-Token': window.csrfToken, + 'Accept': 'application/json', + ...(options.headers || {}) }; - - // 2) First attempt - let res = await originalFetch(url, options); - - // 3) If we got a 403, try to refresh token & retry - if (res.status === 403) { - // 3a) See if the server gave us a new token header - let newToken = res.headers.get('X-CSRF-Token'); - // 3b) Otherwise fall back to the /api/auth/token endpoint - if (!newToken) { - const tokRes = await originalFetch('/api/auth/token.php', { credentials: 'include' }); - if (tokRes.ok) { - const body = await tokRes.json(); - newToken = body.csrf_token; - } - } - if (newToken) { - // 3c) Update global + meta - window.csrfToken = newToken; - const meta = document.querySelector('meta[name="csrf-token"]'); - if (meta) meta.content = newToken; - - // 3d) Retry the original request with the new token - options.headers['X-CSRF-Token'] = newToken; - res = await originalFetch(url, options); - } + if (window.csrfToken) { + options.headers['X-CSRF-Token'] = window.csrfToken; } - // 4) Return the real Response—no body peeking here! + async function retryWithFreshCsrf(asFormFallback = false) { + const tokRes = await original('/api/auth/token.php', { credentials: 'include' }); + if (tokRes.ok) { + const body = await tokRes.json().catch(() => ({})); + if (body?.csrf_token) { + window.csrfToken = body.csrf_token; + const meta = document.querySelector('meta[name="csrf-token"]'); + if (meta) meta.content = body.csrf_token; + options.headers['X-CSRF-Token'] = body.csrf_token; + } + } + if (asFormFallback && wantJson) { + // convert JSON body into x-www-form-urlencoded + const orig = options.body && typeof options.body === 'string' ? JSON.parse(options.body) : {}; + options.body = toFormBody(orig); + options.headers['Content-Type'] = 'application/x-www-form-urlencoded'; + } + return original(url, options); + } + + let res = await original(url, options); + + // If API doesn’t like JSON or token is stale + if (res.status === 400 || res.status === 403 || res.status === 415) { + // 1) retry with fresh CSRF keeping same encoding + res = await retryWithFreshCsrf(false); + if (!res.ok && wantJson) { + // 2) retry again as form-encoded + res = await retryWithFreshCsrf(true); + } + } return res; } @@ -191,13 +240,13 @@ export function loadAdminConfigFunc() { document.title = headerTitle; const lo = config.loginOptions || {}; - localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin)); - localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth)); - localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin)); - localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise"); + localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin)); + localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth)); + localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin)); + localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise"); // These may be absent for non-admins; default them - localStorage.setItem("authBypass", String(!!lo.authBypass)); - localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User"); + localStorage.setItem("authBypass", String(!!lo.authBypass)); + localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User"); updateLoginOptionsUIFromStorage(); @@ -253,14 +302,14 @@ export async function updateAuthenticatedUI(data) { if (loading) loading.remove(); // 2) Show main UI - document.querySelector('.main-wrapper').style.display = ''; - document.getElementById('loginForm').style.display = 'none'; + document.querySelector('.main-wrapper').style.display = ''; + document.getElementById('loginForm').style.display = 'none'; toggleVisibility("loginForm", false); toggleVisibility("mainOperations", true); toggleVisibility("uploadFileForm", true); toggleVisibility("fileListContainer", true); - attachEnterKeyListener("removeUserModal", "deleteUserBtn"); - attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn"); + attachEnterKeyListener("removeUserModal", "deleteUserBtn"); + attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn"); document.querySelector(".header-buttons").style.visibility = "visible"; // 3) Persist auth flags (unchanged) @@ -271,9 +320,9 @@ export async function updateAuthenticatedUI(data) { localStorage.setItem("username", data.username); } if (typeof data.folderOnly !== "undefined") { - localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); - localStorage.setItem("readOnly", data.readOnly ? "true" : "false"); - localStorage.setItem("disableUpload",data.disableUpload? "true" : "false"); + localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false"); + localStorage.setItem("readOnly", data.readOnly ? "true" : "false"); + localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false"); } // 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage @@ -282,7 +331,7 @@ export async function updateAuthenticatedUI(data) { // 5) Build / update header buttons const headerButtons = document.querySelector(".header-buttons"); - const firstButton = headerButtons.firstElementChild; + const firstButton = headerButtons.firstElementChild; // a) restore-from-trash for admins if (data.isAdmin) { @@ -290,8 +339,8 @@ export async function updateAuthenticatedUI(data) { if (!r) { r = document.createElement("button"); r.id = "restoreFilesBtn"; - r.classList.add("btn","btn-warning"); - r.setAttribute("data-i18n-title","trash_restore_delete"); + r.classList.add("btn", "btn-warning"); + r.setAttribute("data-i18n-title", "trash_restore_delete"); r.innerHTML = 'restore_from_trash'; if (firstButton) insertAfter(r, firstButton); else headerButtons.appendChild(r); @@ -308,8 +357,8 @@ export async function updateAuthenticatedUI(data) { if (!a) { a = document.createElement("button"); a.id = "adminPanelBtn"; - a.classList.add("btn","btn-info"); - a.setAttribute("data-i18n-title","admin_panel"); + a.classList.add("btn", "btn-info"); + a.setAttribute("data-i18n-title", "admin_panel"); a.innerHTML = 'admin_panel_settings'; insertAfter(a, document.getElementById("restoreFilesBtn")); a.addEventListener("click", openAdminPanel); @@ -330,19 +379,19 @@ export async function updateAuthenticatedUI(data) { : `account_circle`; // fallback username if missing - const usernameText = data.username - || localStorage.getItem("username") + const usernameText = data.username + || localStorage.getItem("username") || ""; if (!dd) { dd = document.createElement("div"); - dd.id = "userDropdown"; + dd.id = "userDropdown"; dd.classList.add("user-dropdown"); // toggle button const toggle = document.createElement("button"); - toggle.id = "userDropdownToggle"; - toggle.classList.add("btn","btn-user"); + toggle.id = "userDropdownToggle"; + toggle.classList.add("btn", "btn-user"); toggle.setAttribute("title", t("user_settings")); toggle.innerHTML = ` ${avatarHTML} @@ -464,6 +513,14 @@ function checkAuthentication(showLoginToast = true) { } updateAuthenticatedUI(data); return data; + + // at the end of updateAuthenticatedUI(data) + if (!window.__FR_FLAGS?.initialized && typeof initializeApp === 'function') { + initializeApp(); + window.__FR_FLAGS.initialized = true; + } + if (typeof applyTranslations === 'function') applyTranslations(); + if (typeof updateLoginOptionsUIFromStorage === 'function') updateLoginOptionsUIFromStorage(); } else { const overlay = document.getElementById('loadingOverlay'); if (overlay) overlay.remove(); @@ -484,53 +541,162 @@ function checkAuthentication(showLoginToast = true) { } /* ----------------- Authentication Submission ----------------- */ +async function primeCsrfStrict() { + const r = await fetch('/api/auth/token.php', { credentials: 'include' }); + const j = await r.json().catch(() => ({})); + if (!r.ok || !j.csrf_token) throw new Error('CSRF missing'); + window.csrfToken = j.csrf_token; + const m = document.querySelector('meta[name="csrf-token"]'); + if (m) m.content = j.csrf_token; +} + +function toFormBody(obj) { + const p = new URLSearchParams(); + for (const [k, v] of Object.entries(obj || {})) p.set(k, v == null ? '' : String(v)); + return p.toString(); +} + +async function safeJson(res) { + const ct = res.headers.get('content-type') || ''; + if (!/application\/json/i.test(ct)) return null; + try { return await res.clone().json(); } catch { return null; } +} + +async function sniffTOTP(res, bodyMaybe) { + if (res.headers.get('X-TOTP-Required') === '1') return true; + if (res.redirected && /[?&]totp_required=1\b/.test(res.url)) return true; + const body = bodyMaybe ?? await safeJson(res); + if (body && (body.totp_required || body.error === 'TOTP_REQUIRED')) return true; + try { + const txt = await res.clone().text(); + if (/\btotp_required\s*=\s*1\b/i.test(txt)) return true; + } catch { } + return false; +} + +async function isAuthedNow() { + try { + const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' }); + const j = await r.json().catch(() => ({})); + return !!j.authenticated; + } catch { return false; } +} + +function rafTick(times = 2) { + return new Promise(res => { + const step = () => { if (--times <= 0) res(); else requestAnimationFrame(step); }; + requestAnimationFrame(step); + }); +} + +async function fetchAuthSnapshot() { + try { + const r = await fetch('/api/auth/checkAuth.php', { credentials: 'include' }); + return await r.json(); + } catch { return {}; } +} + +async function syncPermissionsToLocalStorage() { + try { + const r = await fetch('/api/getUserPermissions.php', { credentials: 'include' }); + const perm = await r.json(); + if (perm && typeof perm === 'object') { + localStorage.setItem('folderOnly', perm.folderOnly ? 'true' : 'false'); + localStorage.setItem('readOnly', perm.readOnly ? 'true' : 'false'); + localStorage.setItem('disableUpload', perm.disableUpload ? 'true' : 'false'); + } + } catch { /* non-fatal */ } +} + +// ——— main ——— +let __loginInFlight = false; + async function submitLogin(data) { - setLastLoginData(data); - window.__lastLoginData = data; + if (__loginInFlight) return; + __loginInFlight = true; + + const payload = { + username: String(data.username || '').trim(), + password: String(data.password || '').trim(), + remember_me: data.remember_me ? 1 : 0 + }; + + setLastLoginData(payload); + window.__lastLoginData = payload; try { - // ─── 1) Get CSRF for the initial auth call ─── - let res = await fetch("/api/auth/token.php", { credentials: "include" }); - if (!res.ok) throw new Error("Could not fetch CSRF token"); - window.csrfToken = (await res.json()).csrf_token; + await primeCsrfStrict(); - // ─── 2) Send credentials ─── - const response = await sendRequest( - "/api/auth/auth.php", - "POST", - data, - { "X-CSRF-Token": window.csrfToken } - ); + // Attempt #1 — JSON + let res = await fetchWithCsrf('/api/auth/auth.php', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(payload) + }); + let body = await safeJson(res); - // ─── 3a) Full login (no TOTP) ─── - if (response.success || response.status === "ok") { - sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!"); - // … fetch permissions & reload … + // TOTP requested? + if (await sniffTOTP(res, body)) { + try { await primeCsrfStrict(); } catch { } + window.pendingTOTP = true; try { - const perm = await sendRequest("/api/getUserPermissions.php", "GET"); - if (perm && typeof perm === "object") { - localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false"); - localStorage.setItem("readOnly", perm.readOnly ? "true" : "false"); - localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false"); - } + const auth = await import('/js/auth.js?v={{APP_QVER}}'); + if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal(); } catch { } - return window.location.reload(); + return; } - // ─── 3b) TOTP required ─── - if (response.totp_required) { - // **Refresh** CSRF before the TOTP verify call - res = await fetch("/api/auth/token.php", { credentials: "include" }); - if (res.ok) { - window.csrfToken = (await res.json()).csrf_token; - } - // now open the modal—any totp_verify fetch from here on will use the new token - return openTOTPLoginModal(); + // Full success (no TOTP) + if (body && (body.success || body.status === 'ok' || body.authenticated)) { + + await syncPermissionsToLocalStorage(); + return afterLogin(); } - // ─── 3c) Too many attempts ─── - if (response.error && response.error.includes("Too many failed login attempts")) { - showToast(response.error); + // Cookie set but non-JSON body — double check session + if (!body && await isAuthedNow()) { + + await syncPermissionsToLocalStorage(); + + return afterLogin(); + } + + // Attempt #2 — form fallback + res = await fetchWithCsrf('/api/auth/auth.php', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, + body: toFormBody(payload) + }); + body = await safeJson(res); + + if (await sniffTOTP(res, body)) { + try { await primeCsrfStrict(); } catch { } + window.pendingTOTP = true; + try { + const auth = await import('/js/auth.js?v={{APP_QVER}}'); + if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal(); + } catch { } + return; + } + + if (body && (body.success || body.status === 'ok' || body.authenticated)) { + await syncPermissionsToLocalStorage(); + + return afterLogin(); + } + + if (!body && await isAuthedNow()) { + + await syncPermissionsToLocalStorage(); + + return afterLogin(); + } + + // Rate limit still respected + if (body?.error && /Too many failed login attempts/i.test(body.error)) { + showToast(body.error); const btn = document.querySelector("#authForm button[type='submit']"); if (btn) { btn.disabled = true; @@ -542,12 +708,12 @@ async function submitLogin(data) { return; } - // ─── 3d) Other failures ─── - showToast("Login failed: " + (response.error || "Unknown error")); + showToast('Login failed' + (body?.error ? `: ${body.error}` : '')); - } catch (err) { - const msg = err.message || err.error || "Unknown error"; - showToast(`Login failed: ${msg}`); + } catch (e) { + showToast('Login failed: ' + (e.message || 'Unknown error')); + } finally { + __loginInFlight = false; } } @@ -763,4 +929,4 @@ document.addEventListener("DOMContentLoaded", function () { } }); -export { initAuth, checkAuthentication }; \ No newline at end of file +export { initAuth, checkAuthentication, openTOTPLoginModal }; \ No newline at end of file diff --git a/public/js/defer-css.js b/public/js/defer-css.js new file mode 100644 index 0000000..a4131e3 --- /dev/null +++ b/public/js/defer-css.js @@ -0,0 +1,20 @@ +// Promote any preloaded styles to real stylesheets without inline handlers (CSP-safe) +document.addEventListener('DOMContentLoaded', () => { + // Promote any preloaded core CSS + document.querySelectorAll('link[rel="preload"][as="style"][href]').forEach(link => { + const href = link.getAttribute('href'); + if ([...document.querySelectorAll('link[rel="stylesheet"]')] + .some(s => s.getAttribute('href') === href)) return; + const sheet = document.createElement('link'); + sheet.rel = 'stylesheet'; + sheet.href = href; + document.head.appendChild(sheet); + }); + + + // Optionally load non-critical icon/extra font CSS after first paint: + const extra = document.createElement('link'); + extra.rel = 'stylesheet'; + extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}'; + document.head.appendChild(extra); +}); \ No newline at end of file diff --git a/public/js/fileActions.js b/public/js/fileActions.js index 0a43e73..9ee5195 100644 --- a/public/js/fileActions.js +++ b/public/js/fileActions.js @@ -31,6 +31,7 @@ document.addEventListener("DOMContentLoaded", function () { const confirmDelete = document.getElementById("confirmDeleteFiles"); if (confirmDelete) { + confirmDelete.setAttribute("data-default", ""); confirmDelete.addEventListener("click", function () { fetch("/api/file/deleteFiles.php", { method: "POST", @@ -316,6 +317,7 @@ document.addEventListener("DOMContentLoaded", () => { // 2) Confirm button kicks off the zip+download if (confirmZipBtn) { + confirmZipBtn.setAttribute("data-default", ""); confirmZipBtn.addEventListener("click", async () => { // a) Validate ZIP filename let zipName = document.getElementById("zipFileNameInput").value.trim(); @@ -478,6 +480,7 @@ document.addEventListener("DOMContentLoaded", function () { } const confirmCopy = document.getElementById("confirmCopyFiles"); if (confirmCopy) { + confirmCopy.setAttribute("data-default", ""); confirmCopy.addEventListener("click", function () { const targetFolder = document.getElementById("copyTargetFolder").value; if (!targetFolder) { @@ -529,6 +532,7 @@ document.addEventListener("DOMContentLoaded", function () { } const confirmMove = document.getElementById("confirmMoveFiles"); if (confirmMove) { + confirmMove.setAttribute("data-default", ""); confirmMove.addEventListener("click", function () { const targetFolder = document.getElementById("moveTargetFolder").value; if (!targetFolder) { @@ -598,6 +602,7 @@ document.addEventListener("DOMContentLoaded", () => { const submitBtn = document.getElementById("submitRenameFile"); if (submitBtn) { + submitBtn.setAttribute("data-default", ""); submitBtn.addEventListener("click", function () { const newName = document.getElementById("newFileName").value.trim(); if (!newName || newName === window.fileToRename) { diff --git a/public/js/fileEditor.js b/public/js/fileEditor.js index 805b6e6..12225e9 100644 --- a/public/js/fileEditor.js +++ b/public/js/fileEditor.js @@ -7,9 +7,17 @@ import { t } from './i18n.js?v={{APP_QVER}}'; const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing -// Lazy-load CodeMirror modes on demand -//const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/"; -const CM_LOCAL = "/vendor/codemirror/5.65.5/"; +// ==== CodeMirror lazy loader =============================================== +const CM_BASE = "/vendor/codemirror/5.65.5/"; + +// Stamp-friendly helpers (the stamper will replace {{APP_QVER}}) +const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`; + +const CORE = { + js: coreUrl("codemirror.min.js"), + css: coreUrl("codemirror.min.css"), + themeCss: coreUrl("theme/material-darker.min.css"), +}; // Which mode file to load for a given name/mime const MODE_URL = { @@ -40,6 +48,13 @@ const MODE_URL = { "text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}" }; +// Mode dependency graph +const MODE_DEPS = { + "htmlmixed": ["xml", "javascript", "css"], + "application/x-httpd-php": ["htmlmixed", "text/x-csrc"], // php overlays + clike bits + "markdown": ["xml"] +}; + // Map any mime/alias to the key we use in MODE_URL function normalizeModeName(modeOption) { const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name); @@ -49,62 +64,78 @@ function normalizeModeName(modeOption) { return name; } -const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever +const _loadedScripts = new Set(); +const _loadedCss = new Set(); +let _corePromise = null; function loadScriptOnce(url) { return new Promise((resolve, reject) => { - const ver = (window.APP_VERSION ?? 'dev').replace(/^v/, ''); // "v1.6.9" -> "1.6.9" - const withQS = url; //+ '?v=' + ver; - - const key = `cm:${withQS}`; - let s = document.querySelector(`script[data-key="${key}"]`); - if (s) { - if (s.dataset.loaded === "1") return resolve(); - s.addEventListener("load", resolve); - s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`))); - return; - } - s = document.createElement("script"); - s.src = withQS; + if (_loadedScripts.has(url)) return resolve(); + const s = document.createElement("script"); + s.src = url; s.async = true; - s.dataset.key = key; - s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); }); - s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`))); + s.onload = () => { _loadedScripts.add(url); resolve(); }; + s.onerror = () => reject(new Error(`Load failed: ${url}`)); document.head.appendChild(s); }); } +function loadCssOnce(href) { + return new Promise((resolve, reject) => { + if (_loadedCss.has(href)) return resolve(); + const l = document.createElement("link"); + l.rel = "stylesheet"; + l.href = href; + l.onload = () => { _loadedCss.add(href); resolve(); }; + l.onerror = () => reject(new Error(`Load failed: ${href}`)); + document.head.appendChild(l); + }); +} + +async function ensureCore() { + if (_corePromise) return _corePromise; + _corePromise = (async () => { + // load CSS first to avoid FOUC + await loadCssOnce(CORE.css); + await loadCssOnce(CORE.themeCss); + if (!window.CodeMirror) { + await loadScriptOnce(CORE.js); + } + })(); + return _corePromise; +} + +async function loadSingleMode(name) { + const rel = MODE_URL[name]; + if (!rel) return; + // prepend base if needed + const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel)); + await loadScriptOnce(url); +} + +function isModeRegistered(name) { + return !!( + (window.CodeMirror?.modes && window.CodeMirror.modes[name]) || + (window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]) + ); +} async function ensureModeLoaded(modeOption) { - if (!window.CodeMirror) return; - + await ensureCore(); const name = normalizeModeName(modeOption); if (!name) return; - - const isRegistered = () => - (window.CodeMirror?.modes && window.CodeMirror.modes[name]) || - (window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]); - - if (isRegistered()) return; - - const url = MODE_URL[name]; - if (!url) return; // unknown -> stay in text/plain - - // Dependencies - if (name === "htmlmixed") { - await Promise.all([ - ensureModeLoaded("xml"), - ensureModeLoaded("css"), - ensureModeLoaded("javascript") - ]); + if (isModeRegistered(name)) return; + const deps = MODE_DEPS[name] || []; + for (const d of deps) { + if (!isModeRegistered(d)) await loadSingleMode(d); } - if (name === "application/x-httpd-php") { - await ensureModeLoaded("htmlmixed"); - } - - await loadScriptOnce(CM_LOCAL + url); + await loadSingleMode(name); } +// Public helper for callers (we keep your existing function name in use): +const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever +// ==== /CodeMirror lazy loader =============================================== + function getModeForFile(fileName) { const dot = fileName.lastIndexOf("."); const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : ""; @@ -215,7 +246,7 @@ export function editFile(fileName, folder) {
`; @@ -246,20 +277,20 @@ export function editFile(fileName, folder) { const theme = isDarkMode ? "material-darker" : "default"; const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName); - // Helper to check whether a mode is currently registered - const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name); - const isModeRegistered = () => - (window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) || - (window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]); - - // Start mode loading (don’t block closing) - const modePromise = ensureModeLoaded(desiredMode); + // Start core+mode loading (don’t block closing) + const modePromise = (async () => { + await ensureCore(); // load CM core + CSS + if (!forcePlainText) { + await ensureModeLoaded(desiredMode); // then load the needed mode + deps + } + })(); // Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS)); Promise.race([modePromise, timeout]).then(() => { if (canceled) return; + if (!window.CodeMirror) { // Core not present: keep plain