release(v1.7.3): lightweight boot pipeline, dramatically faster first paint, deduped /api writes, sturdier uploads/auth
This commit is contained in:
84
CHANGELOG.md
84
CHANGELOG.md
@@ -1,5 +1,89 @@
|
|||||||
# Changelog
|
# 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()` + `<meta name="csrf-token">` 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)
|
## 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
|
release(v1.7.0): asset cache-busting pipeline, public siteConfig cache, JS core split, and caching/security polish
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
// config.php
|
// config.php
|
||||||
|
|
||||||
// Prevent caching
|
|
||||||
header("Cache-Control: no-cache, must-revalidate");
|
|
||||||
header("Pragma: no-cache");
|
|
||||||
header("Expires: Sat, 26 Jul 1997 05:00:00 GMT");
|
|
||||||
header("Expires: 0");
|
|
||||||
|
|
||||||
// Security headers
|
|
||||||
header('X-Content-Type-Options: nosniff');
|
|
||||||
header("X-Frame-Options: SAMEORIGIN");
|
|
||||||
header("Referrer-Policy: no-referrer-when-downgrade");
|
|
||||||
header("Permissions-Policy: geolocation=(), microphone=(), camera=()");
|
|
||||||
header("X-XSS-Protection: 1; mode=block");
|
|
||||||
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
|
|
||||||
header("Strict-Transport-Security: max-age=31536000; includeSubDomains; preload");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define constants
|
// Define constants
|
||||||
define('PROJECT_ROOT', dirname(__DIR__));
|
define('PROJECT_ROOT', dirname(__DIR__));
|
||||||
define('UPLOAD_DIR', '/var/www/uploads/');
|
define('UPLOAD_DIR', '/var/www/uploads/');
|
||||||
|
|||||||
@@ -11,11 +11,25 @@ DirectoryIndex index.html
|
|||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
RewriteEngine On
|
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
|
#RewriteCond %{HTTPS} off
|
||||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
|
#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) ---
|
||||||
<IfModule mod_mime.c>
|
<IfModule mod_mime.c>
|
||||||
AddType font/woff2 .woff2
|
AddType font/woff2 .woff2
|
||||||
AddType font/woff .woff
|
AddType font/woff .woff
|
||||||
@@ -23,58 +37,57 @@ RewriteEngine On
|
|||||||
AddType application/javascript .mjs
|
AddType application/javascript .mjs
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# Security headers
|
# --- Security headers ---
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
Header always set X-Frame-Options "SAMEORIGIN"
|
Header always set X-Frame-Options "SAMEORIGIN"
|
||||||
Header always set X-XSS-Protection "1; mode=block"
|
Header always set X-XSS-Protection "1; mode=block"
|
||||||
Header always set X-Content-Type-Options "nosniff"
|
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 Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
Header always set X-Download-Options "noopen"
|
Header always set X-Download-Options "noopen"
|
||||||
Header always set Expect-CT "max-age=86400, enforce"
|
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 Cross-Origin-Resource-Policy "same-origin"
|
||||||
Header always set X-Permitted-Cross-Domain-Policies "none"
|
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)
|
# CSP (modules, blobs, workers, etc.)
|
||||||
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'"
|
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'"
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# Caching
|
# --- Caching (query-string based, no env vars needed) ---
|
||||||
SetEnvIfNoCase QUERY_STRING "(^|&)v=" has_version_param=1
|
|
||||||
<IfModule mod_headers.c>
|
<IfModule mod_headers.c>
|
||||||
# HTML/PHP: no cache (app shell)
|
# HTML/PHP: no cache (only if PHP didn’t already set it)
|
||||||
<FilesMatch "\.(html?|php)$">
|
<FilesMatch "\.(html?|php)$">
|
||||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
Header setifempty Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
Header set Pragma "no-cache"
|
Header setifempty Pragma "no-cache"
|
||||||
Header set Expires "0"
|
Header setifempty Expires "0"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# version.js is your source-of-truth; keep it non-cacheable so dev/CI flips show up
|
# version.js: always non-cacheable
|
||||||
<FilesMatch "^js/version\.js$">
|
<FilesMatch "^js/version\.js$">
|
||||||
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
Header set Pragma "no-cache"
|
Header set Pragma "no-cache"
|
||||||
Header set Expires "0"
|
Header set Expires "0"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Unversioned JS/CSS (dev): 1 hour
|
# Unversioned JS/CSS: 1 hour
|
||||||
<FilesMatch "\.(?:m?js|css)$">
|
<FilesMatch "\.(?:m?js|css)$">
|
||||||
Header set Cache-Control "public, max-age=3600, must-revalidate" env=!has_version_param
|
Header set Cache-Control "public, max-age=3600, must-revalidate" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Unversioned static assets (dev): 7 days
|
# Unversioned static (images/fonts): 7 days
|
||||||
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
<FilesMatch "\.(?:png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||||
Header set Cache-Control "public, max-age=604800" env=!has_version_param
|
Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
|
||||||
# Versioned assets (?v=...): 1 year + immutable
|
# Versioned assets (?v=...): 1 year + immutable
|
||||||
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
<FilesMatch "\.(?:m?js|css|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)$">
|
||||||
Header set Cache-Control "public, max-age=31536000, immutable" env=has_version_param
|
Header setifempty Cache-Control "public, max-age=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/"
|
||||||
</FilesMatch>
|
</FilesMatch>
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
# Compression (if modules exist)
|
# --- Compression ---
|
||||||
<IfModule mod_brotli.c>
|
<IfModule mod_brotli.c>
|
||||||
BrotliCompressionQuality 5
|
BrotliCompressionQuality 5
|
||||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
|
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
|
AddOutputFilterByType DEFLATE text/html text/css application/javascript application/json image/svg+xml
|
||||||
</IfModule>
|
</IfModule>
|
||||||
|
|
||||||
# Disable TRACE
|
# --- Disable TRACE ---
|
||||||
RewriteCond %{REQUEST_METHOD} ^TRACE
|
RewriteCond %{REQUEST_METHOD} ^TRACE
|
||||||
RewriteRule .* - [F]
|
RewriteRule .* - [F]
|
||||||
@@ -37,7 +37,11 @@ body {
|
|||||||
/************************************************************/
|
/************************************************************/
|
||||||
/* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */
|
/* 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 {
|
.btn-login {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}/* Color overrides */
|
}/* Color overrides */
|
||||||
@@ -1598,7 +1602,7 @@ body {
|
|||||||
#removeUserModal {
|
#removeUserModal {
|
||||||
z-index: 5000 !important;
|
z-index: 5000 !important;
|
||||||
}#customConfirmModal {
|
}#customConfirmModal {
|
||||||
z-index: 6000 !important;
|
z-index: 12000 !important;
|
||||||
}.admin-panel-content {
|
}.admin-panel-content {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
color: #000;
|
color: #000;
|
||||||
@@ -1868,3 +1872,4 @@ body {
|
|||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
border-color: #e2e2e2;
|
border-color: #e2e2e2;
|
||||||
}
|
}
|
||||||
|
|
||||||
2
public/css/vendor/material-icons.css
vendored
2
public/css/vendor/material-icons.css
vendored
@@ -4,7 +4,7 @@
|
|||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-display: swap;
|
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 {
|
.material-icons {
|
||||||
|
|||||||
8
public/css/vendor/roboto.css
vendored
8
public/css/vendor/roboto.css
vendored
@@ -4,7 +4,7 @@
|
|||||||
font-style:normal;
|
font-style:normal;
|
||||||
font-weight:400;
|
font-weight:400;
|
||||||
font-display:swap;
|
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;
|
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 */
|
/* Roboto Regular 400 — latin */
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
font-style:normal;
|
font-style:normal;
|
||||||
font-weight:400;
|
font-weight:400;
|
||||||
font-display:swap;
|
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;
|
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 */
|
/* Roboto Medium 500 — latin-ext */
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
font-style:normal;
|
font-style:normal;
|
||||||
font-weight:500;
|
font-weight:500;
|
||||||
font-display:swap;
|
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;
|
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 */
|
/* Roboto Medium 500 — latin */
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
font-style:normal;
|
font-style:normal;
|
||||||
font-weight:500;
|
font-weight:500;
|
||||||
font-display:swap;
|
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;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,38 +5,58 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>FileRise</title>
|
<title>FileRise</title>
|
||||||
|
|
||||||
|
<!-- Icons -->
|
||||||
<link rel="icon" type="image/png" href="/assets/logo.png">
|
<link rel="icon" type="image/png" href="/assets/logo.png">
|
||||||
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
|
||||||
|
|
||||||
|
<!-- App meta -->
|
||||||
|
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
|
||||||
<meta name="csrf-token" content="">
|
<meta name="csrf-token" content="">
|
||||||
<meta name="share-url" content="">
|
<meta name="share-url" content="">
|
||||||
|
<meta name="theme-color" content="#0b5ed7">
|
||||||
|
|
||||||
<style>.main-wrapper{display:none}#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}</style>
|
<!-- Minimal critical CSS only (keeps CSP clean, no inline JS) -->
|
||||||
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
<style>
|
||||||
<link rel="stylesheet" href="/css/vendor/material-icons.css?v={{APP_QVER}}">
|
.main-wrapper{display:none}
|
||||||
|
#loadingOverlay{position:fixed;inset:0;background:var(--bg-color,#fff);z-index:9999;display:flex;align-items:center;justify-content:center}
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- Bootstrap CSS (local) -->
|
<!-- CSS: preload, then promote via tiny external JS (no inline onload) -->
|
||||||
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
<link rel="preload" as="style" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
|
<link rel="preload" as="style" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
<!-- CodeMirror CSS (local) -->
|
<!-- Fonts: preload only those used above the fold -->
|
||||||
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/codemirror.min.css?v={{APP_QVER}}">
|
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3KUBHMdazTgWw.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="stylesheet" href="/vendor/codemirror/5.65.5/theme/material-darker.min.css?v={{APP_QVER}}">
|
<link rel="preload" href="/fonts/roboto/KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMa3yUBHMdazQ.woff2?v={{APP_QVER}}" as="font" type="font/woff2" crossorigin>
|
||||||
|
<!-- Do NOT preload material icons unless needed above the fold -->
|
||||||
|
|
||||||
<!-- app CSS -->
|
<!-- Non-blocking stylesheet promotion (external to satisfy CSP) -->
|
||||||
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
<script src="/js/defer-css.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
<!-- Libraries (JS) -->
|
|
||||||
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}"></script>
|
|
||||||
<script src="/vendor/fuse/6.6.2/fuse.min.js?v={{APP_QVER}}"></script>
|
|
||||||
<script src="/vendor/resumable/1.1.0/resumable.min.js?v={{APP_QVER}}"></script>
|
|
||||||
|
|
||||||
<!-- CodeMirror core FIRST -->
|
<!-- Base CSS as a fallback if JS is disabled -->
|
||||||
<script src="/vendor/codemirror/5.65.5/codemirror.min.js?v={{APP_QVER}}"></script>
|
<noscript>
|
||||||
|
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
|
||||||
|
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
|
||||||
|
</noscript>
|
||||||
|
|
||||||
<script src="/js/version.js?v={{APP_QVER}}"></script>
|
<!-- Preload font CSS (non-blocking) -->
|
||||||
|
<link rel="preload" as="style" href="/css/vendor/roboto.css?v={{APP_QVER}}">
|
||||||
|
<link rel="preload" as="style" href="/css/vendor/material-icons.css?v={{APP_QVER}}">
|
||||||
|
|
||||||
|
<!-- Vendor JS (keep defer; they’re not modules) -->
|
||||||
|
<script src="/vendor/dompurify/2.4.0/purify.min.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
|
<!-- IMPORTANT: Remove CodeMirror here; lazy-load it inside your editor route/module. -->
|
||||||
|
|
||||||
|
<!-- Version marker (non-blocking) -->
|
||||||
|
<script src="/js/version.js?v={{APP_QVER}}" defer></script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- App entry: start fetching early, execute after parse -->
|
||||||
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}">
|
<link rel="modulepreload" href="/js/main.js?v={{APP_QVER}}">
|
||||||
<script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
<script type="module" src="/js/main.js?v={{APP_QVER}}"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -44,7 +64,14 @@
|
|||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<a href="index.html">
|
<a href="index.html">
|
||||||
<div class="header-logo">
|
<div class="header-logo">
|
||||||
<img src="/assets/logo.svg?v={{APP_QVER}}" alt="FileRise" class="logo" />
|
<img
|
||||||
|
src="/assets/logo.svg?v={{APP_QVER}}"
|
||||||
|
alt="FileRise"
|
||||||
|
class="logo"
|
||||||
|
width="50" height="50"
|
||||||
|
decoding="async"
|
||||||
|
fetchpriority="low"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +130,7 @@
|
|||||||
<!-- Custom Toast Container -->
|
<!-- Custom Toast Container -->
|
||||||
<div id="customToast"></div>
|
<div id="customToast"></div>
|
||||||
<div id="hiddenCardsContainer" style="display:none;"></div>
|
<div id="hiddenCardsContainer" style="display:none;"></div>
|
||||||
|
<main id="main">
|
||||||
<div class="row mt-4" id="loginForm">
|
<div class="row mt-4" id="loginForm">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<form id="authForm" method="post">
|
<form id="authForm" method="post">
|
||||||
@@ -115,7 +142,7 @@
|
|||||||
<label for="loginPassword" data-i18n-key="password">Password:</label>
|
<label for="loginPassword" data-i18n-key="password">Password:</label>
|
||||||
<input type="password" class="form-control" id="loginPassword" name="password" required />
|
<input type="password" class="form-control" id="loginPassword" name="password" required />
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
|
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login" data-default>Login</button>
|
||||||
<div class="form-group remember-me-container">
|
<div class="form-group remember-me-container">
|
||||||
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
|
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
|
||||||
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
|
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
|
||||||
@@ -133,6 +160,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
<!-- Main Wrapper: Hidden by default; remove "display: none;" after login -->
|
||||||
<div class="main-wrapper">
|
<div class="main-wrapper">
|
||||||
@@ -215,7 +244,7 @@
|
|||||||
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||||
<button id="cancelMoveFolder" class="btn btn-secondary"
|
<button id="cancelMoveFolder" class="btn btn-secondary"
|
||||||
data-i18n-key="cancel">Cancel</button>
|
data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move">Move</button>
|
<button id="confirmMoveFolder" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +262,7 @@
|
|||||||
<button id="cancelRenameFolder" class="btn btn-secondary"
|
<button id="cancelRenameFolder" class="btn btn-secondary"
|
||||||
data-i18n-key="cancel">Cancel</button>
|
data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="submitRenameFolder" class="btn btn-primary"
|
<button id="submitRenameFolder" class="btn btn-primary"
|
||||||
data-i18n-key="rename">Rename</button>
|
data-i18n-key="rename" data-default>Rename</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +282,7 @@
|
|||||||
<button id="cancelDeleteFolder" class="btn btn-secondary"
|
<button id="cancelDeleteFolder" class="btn btn-secondary"
|
||||||
data-i18n-key="cancel">Cancel</button>
|
data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmDeleteFolder" class="btn btn-danger"
|
<button id="confirmDeleteFolder" class="btn btn-danger"
|
||||||
data-i18n-key="delete">Delete</button>
|
data-i18n-key="delete" data-default>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -289,7 +318,7 @@
|
|||||||
selected files?</p>
|
selected files?</p>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelDeleteFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete">Delete</button>
|
<button id="confirmDeleteFiles" class="btn btn-danger" data-i18n-key="delete" data-default>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,7 +332,7 @@
|
|||||||
<select id="copyTargetFolder" class="form-control modal-input"></select>
|
<select id="copyTargetFolder" class="form-control modal-input"></select>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelCopyFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy">Copy</button>
|
<button id="confirmCopyFiles" class="btn btn-primary" data-i18n-key="copy" data-default>Copy</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,7 +346,7 @@
|
|||||||
<select id="moveTargetFolder" class="form-control modal-input"></select>
|
<select id="moveTargetFolder" class="form-control modal-input"></select>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelMoveFiles" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move">Move</button>
|
<button id="confirmMoveFiles" class="btn btn-primary" data-i18n-key="move" data-default>Move</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -325,35 +354,20 @@
|
|||||||
data-i18n-key="download_zip">Download ZIP</button>
|
data-i18n-key="download_zip">Download ZIP</button>
|
||||||
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
|
<button id="extractZipBtn" class="btn action-btn btn-sm btn-info" style="display: none;" disabled
|
||||||
data-i18n-key="extract_zip_button">Extract Zip</button>
|
data-i18n-key="extract_zip_button">Extract Zip</button>
|
||||||
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
<div id="createDropdown" class="dropdown-container" style="position:relative; display:inline-block;">
|
||||||
<button id="createBtn" class="btn action-btn" style="display: none;" data-i18n-key="create">
|
<button id="createBtn" class="btn action-btn" type="button" style="display:none;" aria-haspopup="true" aria-expanded="false">
|
||||||
${t('create')} <span class="material-icons"
|
<span data-i18n-key="create">Create</span>
|
||||||
style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
<span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
|
||||||
</button>
|
</button>
|
||||||
<ul id="createMenu" class="dropdown-menu" style="
|
<ul id="createMenu" class="dropdown-menu" style="display:none; position:absolute; top:100%; left:0; margin:4px 0 0; padding:0; list-style:none; background:#fff; border:1px solid #ccc; box-shadow:0 2px 6px rgba(0,0,0,0.2); z-index:10010; min-width:160px;">
|
||||||
display: none;
|
<li id="createFileOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
position: absolute;
|
<span data-i18n-key="create_file">Create file</span>
|
||||||
top: 100%;
|
</li>
|
||||||
left: 0;
|
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
|
||||||
margin: 4px 0 0;
|
<span data-i18n-key="create_folder">Create folder</span>
|
||||||
padding: 0;
|
</li>
|
||||||
list-style: none;
|
</ul>
|
||||||
background: #fff;
|
</div>
|
||||||
border: 1px solid #ccc;
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
min-width: 140px;
|
|
||||||
">
|
|
||||||
<li id="createFileOption" class="dropdown-item" data-i18n-key="create_file"
|
|
||||||
style="padding:8px 12px; cursor:pointer;">
|
|
||||||
${t('create_file')}
|
|
||||||
</li>
|
|
||||||
<li id="createFolderOption" class="dropdown-item" data-i18n-key="create_folder"
|
|
||||||
style="padding:8px 12px; cursor:pointer;">
|
|
||||||
${t('create_folder')}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<!-- Create File Modal -->
|
<!-- Create File Modal -->
|
||||||
<div id="createFileModal" class="modal" style="display:none;">
|
<div id="createFileModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -362,7 +376,7 @@
|
|||||||
data-i18n-placeholder="newfile_placeholder" />
|
data-i18n-placeholder="newfile_placeholder" />
|
||||||
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
<div class="modal-footer" style="margin-top:1rem; text-align:right;">
|
||||||
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelCreateFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
|
<button id="confirmCreateFile" class="btn btn-primary" data-i18n-key="create" data-default>Create</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -374,7 +388,7 @@
|
|||||||
placeholder="files.zip" />
|
placeholder="files.zip" />
|
||||||
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
<div class="modal-footer" style="margin-top:15px; text-align:right;">
|
||||||
<button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelDownloadZip" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download">Download</button>
|
<button id="confirmDownloadZip" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -412,14 +426,14 @@
|
|||||||
placeholder="Filename" />
|
placeholder="Filename" />
|
||||||
<div style="margin-top: 15px; text-align: right;">
|
<div style="margin-top: 15px; text-align: right;">
|
||||||
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelDownloadFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download">Download</button>
|
<button id="confirmSingleDownloadButton" class="btn btn-primary" data-i18n-key="download" data-default>Download</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
<!-- Change Password, Add User, Remove User, Rename File, and Custom Confirm Modals (unchanged) -->
|
||||||
<div id="changePasswordModal" class="modal" style="display:none;">
|
<div id="changePasswordModal" class="modal" style="display:none;">
|
||||||
<div class="modal-content" style="max-width:400px; margin:auto;">
|
<div class="modal-content" style="text-align: center; padding: 20px;">
|
||||||
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
<span id="closeChangePasswordModal" class="editor-close-btn">×</span>
|
||||||
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
<h3 data-i18n-key="change_password_title">Change Password</h3>
|
||||||
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
<input type="password" id="oldPassword" class="form-control" data-i18n-placeholder="old_password"
|
||||||
@@ -428,7 +442,7 @@
|
|||||||
placeholder="New Password" style="width:100%; margin: 5px 0;" />
|
placeholder="New Password" style="width:100%; margin: 5px 0;" />
|
||||||
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
|
<input type="password" id="confirmPassword" class="form-control" data-i18n-placeholder="confirm_new_password"
|
||||||
placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
|
placeholder="Confirm New Password" style="width:100%; margin: 5px 0;" />
|
||||||
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;">Save</button>
|
<button id="saveNewPasswordBtn" class="btn btn-primary" data-i18n-key="save" style="width:100%;" data-default>Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="addUserModal" class="modal" style="display:none;">
|
<div id="addUserModal" class="modal" style="display:none;">
|
||||||
@@ -453,7 +467,7 @@
|
|||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<!-- Save becomes type="submit" -->
|
<!-- Save becomes type="submit" -->
|
||||||
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">
|
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user" data-default>
|
||||||
Save User
|
Save User
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -478,7 +492,7 @@
|
|||||||
placeholder="Enter new file name" style="margin-top:10px;" />
|
placeholder="Enter new file name" style="margin-top:10px;" />
|
||||||
<div style="margin-top:15px; text-align:right;">
|
<div style="margin-top:15px; text-align:right;">
|
||||||
<button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
<button id="cancelRenameFile" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button>
|
||||||
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename">Rename</button>
|
<button id="submitRenameFile" class="btn btn-primary" data-i18n-key="rename" data-default>Rename</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -486,7 +500,7 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<p id="confirmMessage"></p>
|
<p id="confirmMessage"></p>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes">Yes</button>
|
<button id="confirmYesBtn" class="btn btn-primary" data-i18n-key="yes" data-default>Yes</button>
|
||||||
<button id="confirmNoBtn" class="btn btn-secondary" data-i18n-key="no">No</button>
|
<button id="confirmNoBtn" class="btn btn-secondary" data-i18n-key="no">No</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,16 +10,16 @@ const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;
|
|||||||
|
|
||||||
function buildFullGrantsForAllFolders(folders) {
|
function buildFullGrantsForAllFolders(folders) {
|
||||||
const allTrue = {
|
const allTrue = {
|
||||||
view:true, viewOwn:false, manage:true, create:true, upload:true, edit:true,
|
view: true, viewOwn: false, manage: true, create: true, upload: true, edit: true,
|
||||||
rename:true, copy:true, move:true, delete:true, extract:true,
|
rename: true, copy: true, move: true, delete: true, extract: true,
|
||||||
shareFile:true, shareFolder:true, share:true
|
shareFile: true, shareFolder: true, share: true
|
||||||
};
|
};
|
||||||
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
|
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === BEGIN: Folder Access helpers (merged + improved) === */
|
/* === BEGIN: Folder Access helpers (merged + improved) === */
|
||||||
function qs(scope, sel){ return (scope||document).querySelector(sel); }
|
function qs(scope, sel) { return (scope || document).querySelector(sel); }
|
||||||
function qsa(scope, sel){ return Array.from((scope||document).querySelectorAll(sel)); }
|
function qsa(scope, sel) { return Array.from((scope || document).querySelectorAll(sel)); }
|
||||||
|
|
||||||
function enforceShareFolderRule(row) {
|
function enforceShareFolderRule(row) {
|
||||||
const manage = qs(row, 'input[data-cap="manage"]');
|
const manage = qs(row, 'input[data-cap="manage"]');
|
||||||
@@ -37,6 +37,66 @@ function enforceShareFolderRule(row) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireHeaderTitleLive() {
|
||||||
|
const input = document.getElementById('headerTitle');
|
||||||
|
if (!input || input.__live) return;
|
||||||
|
input.__live = true;
|
||||||
|
|
||||||
|
const apply = (val) => {
|
||||||
|
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
|
||||||
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
|
||||||
|
: '';
|
||||||
|
const note = hasValue
|
||||||
|
? `<small class="text-success" style="margin-left:4px;">Saved — leave blank to keep</small>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="${id}">${label}:</label>
|
||||||
|
<div style="display:flex; gap:8px; align-items:center;">
|
||||||
|
<input type="${type}" id="${id}" class="form-control" ${disabled} />
|
||||||
|
${replaceBtn}
|
||||||
|
</div>
|
||||||
|
${note}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
function onShareFolderToggle(row, checked) {
|
||||||
const manage = qs(row, 'input[data-cap="manage"]');
|
const manage = qs(row, 'input[data-cap="manage"]');
|
||||||
const viewAll = qs(row, 'input[data-cap="view"]');
|
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 viewAll = qs(row, 'input[data-cap="view"]');
|
||||||
const viewOwn = qs(row, 'input[data-cap="viewOwn"]');
|
const viewOwn = qs(row, 'input[data-cap="viewOwn"]');
|
||||||
const hasView = !!(viewAll && viewAll.checked);
|
const hasView = !!(viewAll && viewAll.checked);
|
||||||
const hasOwn = !!(viewOwn && viewOwn.checked);
|
const hasOwn = !!(viewOwn && viewOwn.checked);
|
||||||
if (!hasView && !hasOwn && viewOwn) {
|
if (!hasView && !hasOwn && viewOwn) {
|
||||||
viewOwn.checked = true;
|
viewOwn.checked = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onWriteToggle(row, checked) {
|
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 => {
|
caps.forEach(c => {
|
||||||
const box = qs(row, `input[data-cap="${c}"]`);
|
const box = qs(row, `input[data-cap="${c}"]`);
|
||||||
if (box) box.checked = checked;
|
if (box) box.checked = checked;
|
||||||
@@ -426,20 +486,21 @@ export function openAdminPanel() {
|
|||||||
<div class="editor-close-btn" id="closeAdminPanel">×</div>
|
<div class="editor-close-btn" id="closeAdminPanel">×</div>
|
||||||
<h3>${adminTitle}</h3>
|
<h3>${adminTitle}</h3>
|
||||||
<form id="adminPanelForm">
|
<form id="adminPanelForm">
|
||||||
${[
|
${[
|
||||||
{ id: "userManagement", label: t("user_management") },
|
{ id: "userManagement", label: t("user_management") },
|
||||||
{ id: "headerSettings", label: t("header_settings") },
|
{ id: "headerSettings", label: t("header_settings") },
|
||||||
{ id: "loginOptions", label: t("login_options") },
|
{ id: "loginOptions", label: t("login_options") },
|
||||||
{ id: "webdav", label: "WebDAV Access" },
|
{ id: "webdav", label: "WebDAV Access" },
|
||||||
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
|
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
|
||||||
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
|
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
|
||||||
{ id: "shareLinks", label: t("manage_shared_links") }
|
{ id: "shareLinks", label: t("manage_shared_links") },
|
||||||
].map(sec => `
|
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") }
|
||||||
<div id="${sec.id}Header" class="section-header collapsed">
|
].map(sec => `
|
||||||
${sec.label} <i class="material-icons">expand_more</i>
|
<div id="${sec.id}Header" class="section-header collapsed">
|
||||||
</div>
|
${sec.label} <i class="material-icons">expand_more</i>
|
||||||
<div id="${sec.id}Content" class="section-content"></div>
|
</div>
|
||||||
`).join("")}
|
<div id="${sec.id}Content" class="section-content"></div>
|
||||||
|
`).join("")}
|
||||||
|
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">${t("cancel")}</button>
|
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">${t("cancel")}</button>
|
||||||
@@ -453,7 +514,7 @@ export function openAdminPanel() {
|
|||||||
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
||||||
document.getElementById("cancelAdminSettings").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 => {
|
.forEach(id => {
|
||||||
document.getElementById(id + "Header")
|
document.getElementById(id + "Header")
|
||||||
.addEventListener("click", () => toggleSection(id));
|
.addEventListener("click", () => toggleSection(id));
|
||||||
@@ -485,6 +546,7 @@ export function openAdminPanel() {
|
|||||||
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
|
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
wireHeaderTitleLive();
|
||||||
|
|
||||||
document.getElementById("loginOptionsContent").innerHTML = `
|
document.getElementById("loginOptionsContent").innerHTML = `
|
||||||
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
|
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
|
||||||
@@ -512,16 +574,34 @@ export function openAdminPanel() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const hasId = !!(config.oidc && config.oidc.hasClientId);
|
||||||
|
const hasSecret = !!(config.oidc && config.oidc.hasClientSecret);
|
||||||
|
|
||||||
document.getElementById("oidcContent").innerHTML = `
|
document.getElementById("oidcContent").innerHTML = `
|
||||||
<div class="form-text text-muted" style="margin-top:8px;">
|
<div class="form-text text-muted" style="margin-top:8px;">
|
||||||
<small>Note: OIDC credentials (Client ID/Secret) will show blank here after saving, but remain unchanged until you explicitly edit and save them.</small>
|
<small>Client ID/Secret are never shown after saving. A green note indicates a value is saved. Click “Replace” to overwrite.</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group"><label for="oidcProviderUrl">${t("oidc_provider_url")}:</label><input type="text" id="oidcProviderUrl" class="form-control" value="${window.currentOIDCConfig?.providerUrl || ""}" /></div>
|
|
||||||
<div class="form-group"><label for="oidcClientId">${t("oidc_client_id")}:</label><input type="text" id="oidcClientId" class="form-control" value="${window.currentOIDCConfig?.clientId || ""}" /></div>
|
<div class="form-group">
|
||||||
<div class="form-group"><label for="oidcClientSecret">${t("oidc_client_secret")}:</label><input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig?.clientSecret || ""}" /></div>
|
<label for="oidcProviderUrl">${t("oidc_provider_url")}:</label>
|
||||||
<div class="form-group"><label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label><input type="text" id="oidcRedirectUri" class="form-control" value="${window.currentOIDCConfig?.redirectUri || ""}" /></div>
|
<input type="text" id="oidcProviderUrl" class="form-control" value="${(window.currentOIDCConfig?.providerUrl || "")}" />
|
||||||
<div class="form-group"><label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label><input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig?.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" /></div>
|
</div>
|
||||||
`;
|
|
||||||
|
${renderMaskedInput({ id: "oidcClientId", label: t("oidc_client_id"), hasValue: hasId })}
|
||||||
|
${renderMaskedInput({ id: "oidcClientSecret", label: t("oidc_client_secret"), hasValue: hasSecret, isSecret: true })}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="oidcRedirectUri">${t("oidc_redirect_uri")}:</label>
|
||||||
|
<input type="text" id="oidcRedirectUri" class="form-control" value="${(window.currentOIDCConfig?.redirectUri || "")}" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="globalOtpauthUrl">${t("global_otpauth_url")}:</label>
|
||||||
|
<input type="text" id="globalOtpauthUrl" class="form-control" value="${window.currentOIDCConfig?.globalOtpauthUrl || 'otpauth://totp/{label}?secret={secret}&issuer=FileRise'}" />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
wireReplaceButtons(document.getElementById("oidcContent"));
|
||||||
|
|
||||||
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
|
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 = `
|
||||||
|
<div class="form-group" style="margin-bottom:12px;">
|
||||||
|
<label for="sponsorGitHub">${(typeof tf === 'function' ? tf("github_sponsors_url", "GitHub Sponsors URL") : "GitHub Sponsors URL")}:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="url"
|
||||||
|
id="sponsorGitHub"
|
||||||
|
class="form-control"
|
||||||
|
value="${SPONSOR_GH}"
|
||||||
|
readonly
|
||||||
|
data-ignore-dirty="1" />
|
||||||
|
<button type="button" id="copySponsorGitHub" class="btn btn-outline-primary">Copy</button>
|
||||||
|
<a class="btn btn-outline-secondary" id="openSponsorGitHub" target="_blank" rel="noopener">Open</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom:12px;">
|
||||||
|
<label for="sponsorKoFi">${(typeof tf === 'function' ? tf("ko_fi_url", "Ko-fi URL") : "Ko-fi URL")}:</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="url"
|
||||||
|
id="sponsorKoFi"
|
||||||
|
class="form-control"
|
||||||
|
value="${SPONSOR_KOFI}"
|
||||||
|
readonly
|
||||||
|
data-ignore-dirty="1" />
|
||||||
|
<button type="button" id="copySponsorKoFi" class="btn btn-outline-primary">Copy</button>
|
||||||
|
<a class="btn btn-outline-secondary" id="openSponsorKoFi" target="_blank" rel="noopener">Open</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<small class="text-muted">${(typeof tf === 'function'
|
||||||
|
? tf("sponsor_note_fixed", "Please consider supporting ongoing development.")
|
||||||
|
: "Please consider supporting ongoing development.")}</small>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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");
|
const userMgmt = document.getElementById("userManagementContent");
|
||||||
userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick);
|
userMgmt?.removeEventListener("click", window.__userMgmtDelegatedClick);
|
||||||
window.__userMgmtDelegatedClick = (e) => {
|
window.__userMgmtDelegatedClick = (e) => {
|
||||||
@@ -574,7 +708,11 @@ export function openAdminPanel() {
|
|||||||
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
|
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
|
||||||
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
|
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
|
||||||
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig?.providerUrl || "";
|
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("oidcClientSecret").value = window.currentOIDCConfig?.clientSecret || "";
|
||||||
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
|
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig?.redirectUri || "";
|
||||||
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
|
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig?.globalOtpauthUrl || '';
|
||||||
@@ -585,57 +723,57 @@ export function openAdminPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
const dFL = !!document.getElementById("disableFormLogin")?.checked;
|
const payload = {
|
||||||
const dBA = !!document.getElementById("disableBasicAuth")?.checked;
|
header_title: document.getElementById("headerTitle")?.value || "",
|
||||||
const dOIDC = !!document.getElementById("disableOIDCLogin")?.checked;
|
loginOptions: {
|
||||||
const aBypass = !!document.getElementById("authBypass")?.checked;
|
disableFormLogin: document.getElementById("disableFormLogin").checked,
|
||||||
const aHeader = (document.getElementById("authHeaderName")?.value || "X-Remote-User").trim();
|
disableBasicAuth: document.getElementById("disableBasicAuth").checked,
|
||||||
const eWD = !!document.getElementById("enableWebDAV")?.checked;
|
disableOIDCLogin: document.getElementById("disableOIDCLogin").checked,
|
||||||
const sMax = parseInt(document.getElementById("sharedMaxUploadSize")?.value || "0", 10) || 0;
|
authBypass: document.getElementById("authBypass").checked,
|
||||||
const nHT = (document.getElementById("headerTitle")?.value || "").trim();
|
authHeaderName: document.getElementById("authHeaderName").value.trim() || "X-Remote-User",
|
||||||
const nOIDC = {
|
},
|
||||||
providerUrl: (document.getElementById("oidcProviderUrl")?.value || "").trim(),
|
enableWebDAV: document.getElementById("enableWebDAV").checked,
|
||||||
clientId: (document.getElementById("oidcClientId")?.value || "").trim(),
|
sharedMaxUploadSize: parseInt(document.getElementById("sharedMaxUploadSize").value || "0", 10) || 0,
|
||||||
clientSecret: (document.getElementById("oidcClientSecret")?.value || "").trim(),
|
oidc: {
|
||||||
redirectUri: (document.getElementById("oidcRedirectUri")?.value || "").trim()
|
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) {
|
const idEl = document.getElementById("oidcClientId");
|
||||||
showToast(t("at_least_one_login_method"));
|
const scEl = document.getElementById("oidcClientSecret");
|
||||||
return;
|
|
||||||
|
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", {
|
fetch('/api/admin/updateConfig.php', {
|
||||||
header_title: nHT,
|
method: 'POST',
|
||||||
oidc: nOIDC,
|
credentials: 'include',
|
||||||
loginOptions: {
|
headers: {
|
||||||
disableFormLogin: dFL,
|
'Content-Type': 'application/json',
|
||||||
disableBasicAuth: dBA,
|
'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '')
|
||||||
disableOIDCLogin: dOIDC,
|
|
||||||
authBypass: aBypass,
|
|
||||||
authHeaderName: aHeader
|
|
||||||
},
|
},
|
||||||
enableWebDAV: eWD,
|
body: JSON.stringify(payload)
|
||||||
sharedMaxUploadSize: sMax,
|
})
|
||||||
globalOtpauthUrl: gURL
|
.then(r => r.json())
|
||||||
}, { "X-CSRF-Token": window.csrfToken })
|
.then(j => {
|
||||||
.then(res => {
|
if (j.error) { showToast('Error: ' + j.error); return; }
|
||||||
if (res.success) {
|
showToast('Settings saved.');
|
||||||
showToast(t("settings_updated_successfully"), "success");
|
closeAdminPanel();
|
||||||
captureInitialAdminConfig();
|
})
|
||||||
closeAdminPanel();
|
.catch(() => showToast('Save failed.'));
|
||||||
loadAdminConfigFunc();
|
|
||||||
} else {
|
|
||||||
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
|
|
||||||
}
|
|
||||||
}).catch(() => {/*noop*/ });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function closeAdminPanel() {
|
export async function closeAdminPanel() {
|
||||||
if (hasUnsavedChanges()) {
|
if (hasUnsavedChanges()) {
|
||||||
const ok = await showCustomConfirmModal(t("unsaved_changes_confirm"));
|
//const ok = await showCustomConfirmModal(t("unsaved_changes_confirm"));
|
||||||
if (!ok) return;
|
//if (!ok) return;
|
||||||
}
|
}
|
||||||
const m = document.getElementById("adminPanelModal");
|
const m = document.getElementById("adminPanelModal");
|
||||||
if (m) m.style.display = "none";
|
if (m) m.style.display = "none";
|
||||||
@@ -645,29 +783,29 @@ export async function closeAdminPanel() {
|
|||||||
New: Folder Access (ACL) UI
|
New: Folder Access (ACL) UI
|
||||||
=========================== */
|
=========================== */
|
||||||
|
|
||||||
let __allFoldersCache = null;
|
let __allFoldersCache = null;
|
||||||
|
|
||||||
async function getAllFolders(force = false) {
|
async function getAllFolders(force = false) {
|
||||||
if (!force && __allFoldersCache) return __allFoldersCache.slice();
|
if (!force && __allFoldersCache) return __allFoldersCache.slice();
|
||||||
|
|
||||||
const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), {
|
const res = await fetch('/api/folder/getFolderList.php?ts=' + Date.now(), {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
headers: { 'Cache-Control': 'no-store' }
|
headers: { 'Cache-Control': 'no-store' }
|
||||||
});
|
});
|
||||||
const data = await safeJson(res).catch(() => []);
|
const data = await safeJson(res).catch(() => []);
|
||||||
const list = Array.isArray(data)
|
const list = Array.isArray(data)
|
||||||
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
|
? data.map(x => (typeof x === 'string' ? x : x.folder)).filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const hidden = new Set(['profile_pics', 'trash']);
|
const hidden = new Set(['profile_pics', 'trash']);
|
||||||
const cleaned = list
|
const cleaned = list
|
||||||
.filter(f => f && !hidden.has(f.toLowerCase()))
|
.filter(f => f && !hidden.has(f.toLowerCase()))
|
||||||
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
|
.sort((a, b) => (a === 'root' ? -1 : b === 'root' ? 1 : a.localeCompare(b)));
|
||||||
|
|
||||||
__allFoldersCache = cleaned;
|
__allFoldersCache = cleaned;
|
||||||
return cleaned.slice();
|
return cleaned.slice();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserGrants(username) {
|
async function getUserGrants(username) {
|
||||||
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
|
const res = await fetch(`/api/admin/acl/getGrants.php?user=${encodeURIComponent(username)}`, {
|
||||||
@@ -683,7 +821,7 @@ function renderFolderGrantsUI(username, container, folders, grants) {
|
|||||||
// toolbar
|
// toolbar
|
||||||
const toolbar = document.createElement('div');
|
const toolbar = document.createElement('div');
|
||||||
toolbar.className = 'folder-access-toolbar';
|
toolbar.className = 'folder-access-toolbar';
|
||||||
toolbar.innerHTML = `
|
toolbar.innerHTML = `
|
||||||
<input type="text" class="form-control" style="max-width:220px;"
|
<input type="text" class="form-control" style="max-width:220px;"
|
||||||
placeholder="${tf('search_folders', 'Search folders')}" />
|
placeholder="${tf('search_folders', 'Search folders')}" />
|
||||||
|
|
||||||
@@ -717,8 +855,8 @@ toolbar.innerHTML = `
|
|||||||
|
|
||||||
const headerHtml = `
|
const headerHtml = `
|
||||||
<div class="folder-access-header">
|
<div class="folder-access-header">
|
||||||
<div class="folder-cell" title="${tf('folder_help','Folder path within FileRise')}">
|
<div class="folder-cell" title="${tf('folder_help', 'Folder path within FileRise')}">
|
||||||
${tf('folder','Folder')}
|
${tf('folder', 'Folder')}
|
||||||
</div>
|
</div>
|
||||||
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
<div class="perm-col" title="${tf('view_all_help', 'See all files in this folder (everyone’s files)')}">
|
||||||
${tf('view_all', 'View (all)')}
|
${tf('view_all', 'View (all)')}
|
||||||
@@ -802,7 +940,7 @@ toolbar.innerHTML = `
|
|||||||
}
|
}
|
||||||
|
|
||||||
function refreshInheritance() {
|
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();
|
const managedPrefixes = new Set();
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
const folder = row.dataset.folder || "";
|
const folder = row.dataset.folder || "";
|
||||||
@@ -813,13 +951,13 @@ toolbar.innerHTML = `
|
|||||||
if (p && folder !== p && folder.startsWith(p + '/')) { inheritedFrom = p; break; }
|
if (p && folder !== p && folder.startsWith(p + '/')) { inheritedFrom = p; break; }
|
||||||
}
|
}
|
||||||
if (inheritedFrom) {
|
if (inheritedFrom) {
|
||||||
const v = qs(row,'input[data-cap="view"]');
|
const v = qs(row, 'input[data-cap="view"]');
|
||||||
const w = qs(row,'input[data-cap="write"]');
|
const w = qs(row, 'input[data-cap="write"]');
|
||||||
const vo= qs(row,'input[data-cap="viewOwn"]');
|
const vo = qs(row, 'input[data-cap="viewOwn"]');
|
||||||
if (v) v.checked = true;
|
if (v) v.checked = true;
|
||||||
if (w) w.checked = true;
|
if (w) w.checked = true;
|
||||||
if (vo) { vo.checked = false; vo.disabled = 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; });
|
.forEach(c => { const cb = qs(row, `input[data-cap="${c}"]`); if (cb) cb.checked = true; });
|
||||||
setRowDisabled(row, true);
|
setRowDisabled(row, true);
|
||||||
const tag = row.querySelector('.inherited-tag');
|
const tag = row.querySelector('.inherited-tag');
|
||||||
@@ -828,8 +966,8 @@ toolbar.innerHTML = `
|
|||||||
setRowDisabled(row, false);
|
setRowDisabled(row, false);
|
||||||
}
|
}
|
||||||
enforceShareFolderRule(row);
|
enforceShareFolderRule(row);
|
||||||
const cbView = qs(row,'input[data-cap="view"]');
|
const cbView = qs(row, 'input[data-cap="view"]');
|
||||||
const cbViewOwn = qs(row,'input[data-cap="viewOwn"]');
|
const cbViewOwn = qs(row, 'input[data-cap="viewOwn"]');
|
||||||
if (cbView && cbViewOwn) {
|
if (cbView && cbViewOwn) {
|
||||||
if (cbView.checked) {
|
if (cbView.checked) {
|
||||||
cbViewOwn.checked = false;
|
cbViewOwn.checked = false;
|
||||||
@@ -847,8 +985,8 @@ toolbar.innerHTML = `
|
|||||||
if (!checked && (which === 'view' || which === 'viewOwn')) {
|
if (!checked && (which === 'view' || which === 'viewOwn')) {
|
||||||
qsa(row, 'input[type="checkbox"]').forEach(cb => cb.checked = false);
|
qsa(row, 'input[type="checkbox"]').forEach(cb => cb.checked = false);
|
||||||
}
|
}
|
||||||
const cbView = qs(row,'input[data-cap="view"]');
|
const cbView = qs(row, 'input[data-cap="view"]');
|
||||||
const cbVO = qs(row,'input[data-cap="viewOwn"]');
|
const cbVO = qs(row, 'input[data-cap="viewOwn"]');
|
||||||
if (cbView && cbVO) {
|
if (cbView && cbVO) {
|
||||||
if (cbView.checked) {
|
if (cbView.checked) {
|
||||||
cbVO.checked = false;
|
cbVO.checked = false;
|
||||||
@@ -863,19 +1001,19 @@ toolbar.innerHTML = `
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wireRow(row) {
|
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 cbViewOwn = row.querySelector('input[data-cap="viewOwn"]');
|
||||||
const cbWrite = row.querySelector('input[data-cap="write"]');
|
const cbWrite = row.querySelector('input[data-cap="write"]');
|
||||||
const cbManage = row.querySelector('input[data-cap="manage"]');
|
const cbManage = row.querySelector('input[data-cap="manage"]');
|
||||||
const cbCreate = row.querySelector('input[data-cap="create"]');
|
const cbCreate = row.querySelector('input[data-cap="create"]');
|
||||||
const cbUpload = row.querySelector('input[data-cap="upload"]');
|
const cbUpload = row.querySelector('input[data-cap="upload"]');
|
||||||
const cbEdit = row.querySelector('input[data-cap="edit"]');
|
const cbEdit = row.querySelector('input[data-cap="edit"]');
|
||||||
const cbRename = row.querySelector('input[data-cap="rename"]');
|
const cbRename = row.querySelector('input[data-cap="rename"]');
|
||||||
const cbCopy = row.querySelector('input[data-cap="copy"]');
|
const cbCopy = row.querySelector('input[data-cap="copy"]');
|
||||||
const cbMove = row.querySelector('input[data-cap="move"]');
|
const cbMove = row.querySelector('input[data-cap="move"]');
|
||||||
const cbDelete = row.querySelector('input[data-cap="delete"]');
|
const cbDelete = row.querySelector('input[data-cap="delete"]');
|
||||||
const cbExtract = row.querySelector('input[data-cap="extract"]');
|
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 cbShareFo = row.querySelector('input[data-cap="shareFolder"]');
|
||||||
|
|
||||||
const granular = [cbCreate, cbUpload, cbEdit, cbRename, cbCopy, cbMove, cbDelete, cbExtract];
|
const granular = [cbCreate, cbUpload, cbEdit, cbRename, cbCopy, cbMove, cbDelete, cbExtract];
|
||||||
@@ -885,7 +1023,7 @@ toolbar.innerHTML = `
|
|||||||
if (cbView) cbView.checked = true;
|
if (cbView) cbView.checked = true;
|
||||||
if (cbWrite) cbWrite.checked = true;
|
if (cbWrite) cbWrite.checked = true;
|
||||||
granular.forEach(cb => { if (cb) cb.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;
|
if (cbShareFo && !cbShareFo.disabled) cbShareFo.checked = true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -919,7 +1057,7 @@ toolbar.innerHTML = `
|
|||||||
const w = r.querySelector('input[data-cap="write"]');
|
const w = r.querySelector('input[data-cap="write"]');
|
||||||
const vo = r.querySelector('input[data-cap="viewOwn"]');
|
const vo = r.querySelector('input[data-cap="viewOwn"]');
|
||||||
const boxes = [
|
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}"]`));
|
].map(c => r.querySelector(`input[data-cap="${c}"]`));
|
||||||
if (m) m.checked = checked;
|
if (m) m.checked = checked;
|
||||||
if (v) v.checked = checked;
|
if (v) v.checked = checked;
|
||||||
@@ -932,7 +1070,7 @@ toolbar.innerHTML = `
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (cbManage) cbManage.addEventListener('change', () => { applyManage(); onShareFile(); cascadeManage(cbManage.checked); });
|
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(); }); });
|
granular.forEach(cb => { if (cb) cb.addEventListener('change', () => { syncWriteFromGranular(); }); });
|
||||||
if (cbView) cbView.addEventListener('change', () => { setFromViewChange(row, 'view', cbView.checked); refreshInheritance(); });
|
if (cbView) cbView.addEventListener('change', () => { setFromViewChange(row, 'view', cbView.checked); refreshInheritance(); });
|
||||||
if (cbViewOwn) cbViewOwn.addEventListener('change', () => { setFromViewChange(row, 'viewOwn', cbViewOwn.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');
|
const folder = row.dataset.folder || row.getAttribute('data-folder');
|
||||||
if (!folder) return;
|
if (!folder) return;
|
||||||
const g = {
|
const g = {
|
||||||
view: get(row, 'input[data-cap="view"]'),
|
view: get(row, 'input[data-cap="view"]'),
|
||||||
viewOwn: get(row, 'input[data-cap="viewOwn"]'),
|
viewOwn: get(row, 'input[data-cap="viewOwn"]'),
|
||||||
manage: get(row, 'input[data-cap="manage"]'),
|
manage: get(row, 'input[data-cap="manage"]'),
|
||||||
create: get(row, 'input[data-cap="create"]'),
|
create: get(row, 'input[data-cap="create"]'),
|
||||||
upload: get(row, 'input[data-cap="upload"]'),
|
upload: get(row, 'input[data-cap="upload"]'),
|
||||||
edit: get(row, 'input[data-cap="edit"]'),
|
edit: get(row, 'input[data-cap="edit"]'),
|
||||||
rename: get(row, 'input[data-cap="rename"]'),
|
rename: get(row, 'input[data-cap="rename"]'),
|
||||||
copy: get(row, 'input[data-cap="copy"]'),
|
copy: get(row, 'input[data-cap="copy"]'),
|
||||||
move: get(row, 'input[data-cap="move"]'),
|
move: get(row, 'input[data-cap="move"]'),
|
||||||
delete: get(row, 'input[data-cap="delete"]'),
|
delete: get(row, 'input[data-cap="delete"]'),
|
||||||
extract: get(row, 'input[data-cap="extract"]'),
|
extract: get(row, 'input[data-cap="extract"]'),
|
||||||
shareFile: get(row, 'input[data-cap="shareFile"]'),
|
shareFile: get(row, 'input[data-cap="shareFile"]'),
|
||||||
shareFolder: get(row, 'input[data-cap="shareFolder"]')
|
shareFolder: get(row, 'input[data-cap="shareFolder"]')
|
||||||
};
|
};
|
||||||
g.share = !!(g.shareFile || g.shareFolder);
|
g.share = !!(g.shareFile || g.shareFolder);
|
||||||
@@ -1074,16 +1212,16 @@ export function openUserPermissionsModal() {
|
|||||||
});
|
});
|
||||||
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
document.getElementById("saveUserPermissionsBtn").addEventListener("click", async () => {
|
||||||
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
|
||||||
const changes = [];
|
const changes = [];
|
||||||
rows.forEach(row => {
|
rows.forEach(row => {
|
||||||
if (row.getAttribute("data-admin") === "1") return; // skip admins
|
if (row.getAttribute("data-admin") === "1") return; // skip admins
|
||||||
const username = String(row.getAttribute("data-username") || "").trim();
|
const username = String(row.getAttribute("data-username") || "").trim();
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
const grantsBox = row.querySelector(".folder-grants-box");
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
|
if (!grantsBox || grantsBox.getAttribute('data-loaded') !== '1') return;
|
||||||
const grants = collectGrantsFrom(grantsBox);
|
const grants = collectGrantsFrom(grantsBox);
|
||||||
changes.push({ user: username, grants });
|
changes.push({ user: username, grants });
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; }
|
if (changes.length === 0) { showToast(tf("nothing_to_save", "Nothing to save")); return; }
|
||||||
await sendRequest("/api/admin/acl/saveGrants.php", "POST",
|
await sendRequest("/api/admin/acl/saveGrants.php", "POST",
|
||||||
@@ -1284,70 +1422,70 @@ async function loadUserPermissionsList() {
|
|||||||
const folders = await getAllFolders(true);
|
const folders = await getAllFolders(true);
|
||||||
|
|
||||||
listContainer.innerHTML = "";
|
listContainer.innerHTML = "";
|
||||||
users.forEach(user => {
|
users.forEach(user => {
|
||||||
const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin";
|
const isAdmin = (user.role && String(user.role) === "1") || String(user.username).toLowerCase() === "admin";
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.classList.add("user-permission-row");
|
row.classList.add("user-permission-row");
|
||||||
row.setAttribute("data-username", user.username);
|
row.setAttribute("data-username", user.username);
|
||||||
if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins
|
if (isAdmin) row.setAttribute("data-admin", "1"); // mark admins
|
||||||
row.style.padding = "6px 0";
|
row.style.padding = "6px 0";
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
|
<div class="user-perm-header" tabindex="0" role="button" aria-expanded="false"
|
||||||
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
|
style="display:flex;align-items:center;gap:8px;cursor:pointer;padding:6px 8px;border-radius:6px;">
|
||||||
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
|
<span class="perm-caret" style="display:inline-block; transform: rotate(-90deg); transition: transform 120ms ease;">▸</span>
|
||||||
<strong>${user.username}</strong>
|
<strong>${user.username}</strong>
|
||||||
${isAdmin ? `<span class="muted" style="margin-left:auto;">Admin (full access)</span>`
|
${isAdmin ? `<span class="muted" style="margin-left:auto;">Admin (full access)</span>`
|
||||||
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
|
: `<span class="muted" style="margin-left:auto;">${tf('click_to_edit', 'Click to edit')}</span>`}
|
||||||
</div>
|
</div>
|
||||||
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
|
<div class="user-perm-details" style="display:none; margin:8px 0 12px;">
|
||||||
<div class="folder-grants-box" data-loaded="0"></div>
|
<div class="folder-grants-box" data-loaded="0"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const header = row.querySelector(".user-perm-header");
|
const header = row.querySelector(".user-perm-header");
|
||||||
const details = row.querySelector(".user-perm-details");
|
const details = row.querySelector(".user-perm-details");
|
||||||
const caret = row.querySelector(".perm-caret");
|
const caret = row.querySelector(".perm-caret");
|
||||||
const grantsBox = row.querySelector(".folder-grants-box");
|
const grantsBox = row.querySelector(".folder-grants-box");
|
||||||
|
|
||||||
async function ensureLoaded() {
|
async function ensureLoaded() {
|
||||||
if (grantsBox.dataset.loaded === "1") return;
|
if (grantsBox.dataset.loaded === "1") return;
|
||||||
try {
|
try {
|
||||||
let grants;
|
let grants;
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
// synthesize full access
|
// synthesize full access
|
||||||
const ordered = ["root", ...folders.filter(f => f !== "root")];
|
const ordered = ["root", ...folders.filter(f => f !== "root")];
|
||||||
grants = buildFullGrantsForAllFolders(ordered);
|
grants = buildFullGrantsForAllFolders(ordered);
|
||||||
renderFolderGrantsUI(user.username, grantsBox, ordered, grants);
|
renderFolderGrantsUI(user.username, grantsBox, ordered, grants);
|
||||||
// disable all inputs
|
// disable all inputs
|
||||||
grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true);
|
grantsBox.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.disabled = true);
|
||||||
} else {
|
} else {
|
||||||
const userGrants = await getUserGrants(user.username);
|
const userGrants = await getUserGrants(user.username);
|
||||||
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants);
|
renderFolderGrantsUI(user.username, grantsBox, ["root", ...folders.filter(f => f !== "root")], userGrants);
|
||||||
|
}
|
||||||
|
grantsBox.dataset.loaded = "1";
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
grantsBox.dataset.loaded = "1";
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
grantsBox.innerHTML = `<div class="muted">${tf("error_loading_user_grants", "Error loading user grants")}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleOpen() {
|
function toggleOpen() {
|
||||||
const willShow = details.style.display === "none";
|
const willShow = details.style.display === "none";
|
||||||
details.style.display = willShow ? "block" : "none";
|
details.style.display = willShow ? "block" : "none";
|
||||||
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
header.setAttribute("aria-expanded", willShow ? "true" : "false");
|
||||||
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
caret.style.transform = willShow ? "rotate(0deg)" : "rotate(-90deg)";
|
||||||
if (willShow) ensureLoaded();
|
if (willShow) ensureLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
header.addEventListener("click", toggleOpen);
|
header.addEventListener("click", toggleOpen);
|
||||||
header.addEventListener("keydown", e => {
|
header.addEventListener("keydown", e => {
|
||||||
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleOpen(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
listContainer.appendChild(row);
|
listContainer.appendChild(row);
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export function initializeApp() {
|
|||||||
|
|
||||||
// Hook DnD relay from fileList area into upload area
|
// Hook DnD relay from fileList area into upload area
|
||||||
const fileListArea = document.getElementById('fileListContainer');
|
const fileListArea = document.getElementById('fileListContainer');
|
||||||
const uploadArea = document.getElementById('uploadDropArea');
|
const uploadArea = document.getElementById('uploadDropArea');
|
||||||
if (fileListArea && uploadArea) {
|
if (fileListArea && uploadArea) {
|
||||||
fileListArea.addEventListener('dragover', e => {
|
fileListArea.addEventListener('dragover', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -136,13 +136,30 @@ export function initializeApp() {
|
|||||||
LOGOUT (shared)
|
LOGOUT (shared)
|
||||||
========================= */
|
========================= */
|
||||||
export function triggerLogout() {
|
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", {
|
_nativeFetch("/api/auth/logout.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
headers: { "X-CSRF-Token": getCsrfToken() }
|
headers: { "X-CSRF-Token": getCsrfToken() }
|
||||||
})
|
})
|
||||||
.then(() => window.location.reload(true))
|
.then(() => {
|
||||||
.catch(() => { /* no-op */ });
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =========================
|
/* =========================
|
||||||
|
|||||||
@@ -31,6 +31,49 @@ const currentOIDCConfig = {
|
|||||||
};
|
};
|
||||||
window.currentOIDCConfig = 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 ----------------- */
|
/* ----------------- TOTP & Toast Overrides ----------------- */
|
||||||
// detect if we’re in a pending‑TOTP state
|
// detect if we’re in a pending‑TOTP state
|
||||||
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
window.pendingTOTP = new URLSearchParams(window.location.search).get('totp_required') === '1';
|
||||||
@@ -72,45 +115,51 @@ const originalFetch = window.fetch;
|
|||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @returns {Promise<Response>}
|
* @returns {Promise<Response>}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function fetchWithCsrf(url, options = {}) {
|
export async function fetchWithCsrf(url, options = {}) {
|
||||||
// 1) Merge in credentials + header
|
const original = window.fetch.bind(window);
|
||||||
options = {
|
const wantJson = (options.headers && /json/i.test(options.headers['Content-Type'] || '')) || typeof options.body === 'string' && options.body.trim().startsWith('{');
|
||||||
credentials: 'include',
|
|
||||||
...options,
|
options = { credentials: 'include', ...options };
|
||||||
};
|
|
||||||
options.headers = {
|
options.headers = {
|
||||||
...(options.headers || {}),
|
'Accept': 'application/json',
|
||||||
'X-CSRF-Token': window.csrfToken,
|
...(options.headers || {})
|
||||||
};
|
};
|
||||||
|
if (window.csrfToken) {
|
||||||
// 2) First attempt
|
options.headers['X-CSRF-Token'] = window.csrfToken;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,13 +240,13 @@ export function loadAdminConfigFunc() {
|
|||||||
|
|
||||||
document.title = headerTitle;
|
document.title = headerTitle;
|
||||||
const lo = config.loginOptions || {};
|
const lo = config.loginOptions || {};
|
||||||
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
|
localStorage.setItem("disableFormLogin", String(!!lo.disableFormLogin));
|
||||||
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
|
localStorage.setItem("disableBasicAuth", String(!!lo.disableBasicAuth));
|
||||||
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
|
localStorage.setItem("disableOIDCLogin", String(!!lo.disableOIDCLogin));
|
||||||
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
localStorage.setItem("globalOtpauthUrl", config.globalOtpauthUrl || "otpauth://totp/{label}?secret={secret}&issuer=FileRise");
|
||||||
// These may be absent for non-admins; default them
|
// These may be absent for non-admins; default them
|
||||||
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
localStorage.setItem("authBypass", String(!!lo.authBypass));
|
||||||
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
localStorage.setItem("authHeaderName", lo.authHeaderName || "X-Remote-User");
|
||||||
|
|
||||||
updateLoginOptionsUIFromStorage();
|
updateLoginOptionsUIFromStorage();
|
||||||
|
|
||||||
@@ -253,14 +302,14 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
if (loading) loading.remove();
|
if (loading) loading.remove();
|
||||||
|
|
||||||
// 2) Show main UI
|
// 2) Show main UI
|
||||||
document.querySelector('.main-wrapper').style.display = '';
|
document.querySelector('.main-wrapper').style.display = '';
|
||||||
document.getElementById('loginForm').style.display = 'none';
|
document.getElementById('loginForm').style.display = 'none';
|
||||||
toggleVisibility("loginForm", false);
|
toggleVisibility("loginForm", false);
|
||||||
toggleVisibility("mainOperations", true);
|
toggleVisibility("mainOperations", true);
|
||||||
toggleVisibility("uploadFileForm", true);
|
toggleVisibility("uploadFileForm", true);
|
||||||
toggleVisibility("fileListContainer", true);
|
toggleVisibility("fileListContainer", true);
|
||||||
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
attachEnterKeyListener("removeUserModal", "deleteUserBtn");
|
||||||
attachEnterKeyListener("changePasswordModal","saveNewPasswordBtn");
|
attachEnterKeyListener("changePasswordModal", "saveNewPasswordBtn");
|
||||||
document.querySelector(".header-buttons").style.visibility = "visible";
|
document.querySelector(".header-buttons").style.visibility = "visible";
|
||||||
|
|
||||||
// 3) Persist auth flags (unchanged)
|
// 3) Persist auth flags (unchanged)
|
||||||
@@ -271,9 +320,9 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
localStorage.setItem("username", data.username);
|
localStorage.setItem("username", data.username);
|
||||||
}
|
}
|
||||||
if (typeof data.folderOnly !== "undefined") {
|
if (typeof data.folderOnly !== "undefined") {
|
||||||
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
localStorage.setItem("folderOnly", data.folderOnly ? "true" : "false");
|
||||||
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
localStorage.setItem("readOnly", data.readOnly ? "true" : "false");
|
||||||
localStorage.setItem("disableUpload",data.disableUpload? "true" : "false");
|
localStorage.setItem("disableUpload", data.disableUpload ? "true" : "false");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
// 4) Fetch up-to-date profile picture — ALWAYS overwrite localStorage
|
||||||
@@ -282,7 +331,7 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
|
|
||||||
// 5) Build / update header buttons
|
// 5) Build / update header buttons
|
||||||
const headerButtons = document.querySelector(".header-buttons");
|
const headerButtons = document.querySelector(".header-buttons");
|
||||||
const firstButton = headerButtons.firstElementChild;
|
const firstButton = headerButtons.firstElementChild;
|
||||||
|
|
||||||
// a) restore-from-trash for admins
|
// a) restore-from-trash for admins
|
||||||
if (data.isAdmin) {
|
if (data.isAdmin) {
|
||||||
@@ -290,8 +339,8 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
if (!r) {
|
if (!r) {
|
||||||
r = document.createElement("button");
|
r = document.createElement("button");
|
||||||
r.id = "restoreFilesBtn";
|
r.id = "restoreFilesBtn";
|
||||||
r.classList.add("btn","btn-warning");
|
r.classList.add("btn", "btn-warning");
|
||||||
r.setAttribute("data-i18n-title","trash_restore_delete");
|
r.setAttribute("data-i18n-title", "trash_restore_delete");
|
||||||
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
|
||||||
if (firstButton) insertAfter(r, firstButton);
|
if (firstButton) insertAfter(r, firstButton);
|
||||||
else headerButtons.appendChild(r);
|
else headerButtons.appendChild(r);
|
||||||
@@ -308,8 +357,8 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
if (!a) {
|
if (!a) {
|
||||||
a = document.createElement("button");
|
a = document.createElement("button");
|
||||||
a.id = "adminPanelBtn";
|
a.id = "adminPanelBtn";
|
||||||
a.classList.add("btn","btn-info");
|
a.classList.add("btn", "btn-info");
|
||||||
a.setAttribute("data-i18n-title","admin_panel");
|
a.setAttribute("data-i18n-title", "admin_panel");
|
||||||
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
|
||||||
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
insertAfter(a, document.getElementById("restoreFilesBtn"));
|
||||||
a.addEventListener("click", openAdminPanel);
|
a.addEventListener("click", openAdminPanel);
|
||||||
@@ -336,13 +385,13 @@ export async function updateAuthenticatedUI(data) {
|
|||||||
|
|
||||||
if (!dd) {
|
if (!dd) {
|
||||||
dd = document.createElement("div");
|
dd = document.createElement("div");
|
||||||
dd.id = "userDropdown";
|
dd.id = "userDropdown";
|
||||||
dd.classList.add("user-dropdown");
|
dd.classList.add("user-dropdown");
|
||||||
|
|
||||||
// toggle button
|
// toggle button
|
||||||
const toggle = document.createElement("button");
|
const toggle = document.createElement("button");
|
||||||
toggle.id = "userDropdownToggle";
|
toggle.id = "userDropdownToggle";
|
||||||
toggle.classList.add("btn","btn-user");
|
toggle.classList.add("btn", "btn-user");
|
||||||
toggle.setAttribute("title", t("user_settings"));
|
toggle.setAttribute("title", t("user_settings"));
|
||||||
toggle.innerHTML = `
|
toggle.innerHTML = `
|
||||||
${avatarHTML}
|
${avatarHTML}
|
||||||
@@ -464,6 +513,14 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
}
|
}
|
||||||
updateAuthenticatedUI(data);
|
updateAuthenticatedUI(data);
|
||||||
return 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 {
|
} else {
|
||||||
const overlay = document.getElementById('loadingOverlay');
|
const overlay = document.getElementById('loadingOverlay');
|
||||||
if (overlay) overlay.remove();
|
if (overlay) overlay.remove();
|
||||||
@@ -484,53 +541,162 @@ function checkAuthentication(showLoginToast = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------- Authentication Submission ----------------- */
|
/* ----------------- 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) {
|
async function submitLogin(data) {
|
||||||
setLastLoginData(data);
|
if (__loginInFlight) return;
|
||||||
window.__lastLoginData = data;
|
__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 {
|
try {
|
||||||
// ─── 1) Get CSRF for the initial auth call ───
|
await primeCsrfStrict();
|
||||||
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;
|
|
||||||
|
|
||||||
// ─── 2) Send credentials ───
|
// Attempt #1 — JSON
|
||||||
const response = await sendRequest(
|
let res = await fetchWithCsrf('/api/auth/auth.php', {
|
||||||
"/api/auth/auth.php",
|
method: 'POST',
|
||||||
"POST",
|
credentials: 'include',
|
||||||
data,
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
{ "X-CSRF-Token": window.csrfToken }
|
body: JSON.stringify(payload)
|
||||||
);
|
});
|
||||||
|
let body = await safeJson(res);
|
||||||
|
|
||||||
// ─── 3a) Full login (no TOTP) ───
|
// TOTP requested?
|
||||||
if (response.success || response.status === "ok") {
|
if (await sniffTOTP(res, body)) {
|
||||||
sessionStorage.setItem("welcomeMessage", "Welcome back, " + data.username + "!");
|
try { await primeCsrfStrict(); } catch { }
|
||||||
// … fetch permissions & reload …
|
window.pendingTOTP = true;
|
||||||
try {
|
try {
|
||||||
const perm = await sendRequest("/api/getUserPermissions.php", "GET");
|
const auth = await import('/js/auth.js?v={{APP_QVER}}');
|
||||||
if (perm && typeof perm === "object") {
|
if (typeof auth.openTOTPLoginModal === 'function') auth.openTOTPLoginModal();
|
||||||
localStorage.setItem("folderOnly", perm.folderOnly ? "true" : "false");
|
|
||||||
localStorage.setItem("readOnly", perm.readOnly ? "true" : "false");
|
|
||||||
localStorage.setItem("disableUpload", perm.disableUpload ? "true" : "false");
|
|
||||||
}
|
|
||||||
} catch { }
|
} catch { }
|
||||||
return window.location.reload();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 3b) TOTP required ───
|
// Full success (no TOTP)
|
||||||
if (response.totp_required) {
|
if (body && (body.success || body.status === 'ok' || body.authenticated)) {
|
||||||
// **Refresh** CSRF before the TOTP verify call
|
|
||||||
res = await fetch("/api/auth/token.php", { credentials: "include" });
|
await syncPermissionsToLocalStorage();
|
||||||
if (res.ok) {
|
return afterLogin();
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 3c) Too many attempts ───
|
// Cookie set but non-JSON body — double check session
|
||||||
if (response.error && response.error.includes("Too many failed login attempts")) {
|
if (!body && await isAuthedNow()) {
|
||||||
showToast(response.error);
|
|
||||||
|
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']");
|
const btn = document.querySelector("#authForm button[type='submit']");
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -542,12 +708,12 @@ async function submitLogin(data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 3d) Other failures ───
|
showToast('Login failed' + (body?.error ? `: ${body.error}` : ''));
|
||||||
showToast("Login failed: " + (response.error || "Unknown error"));
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (e) {
|
||||||
const msg = err.message || err.error || "Unknown error";
|
showToast('Login failed: ' + (e.message || 'Unknown error'));
|
||||||
showToast(`Login failed: ${msg}`);
|
} finally {
|
||||||
|
__loginInFlight = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -763,4 +929,4 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export { initAuth, checkAuthentication };
|
export { initAuth, checkAuthentication, openTOTPLoginModal };
|
||||||
20
public/js/defer-css.js
Normal file
20
public/js/defer-css.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -31,6 +31,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
|
|
||||||
const confirmDelete = document.getElementById("confirmDeleteFiles");
|
const confirmDelete = document.getElementById("confirmDeleteFiles");
|
||||||
if (confirmDelete) {
|
if (confirmDelete) {
|
||||||
|
confirmDelete.setAttribute("data-default", "");
|
||||||
confirmDelete.addEventListener("click", function () {
|
confirmDelete.addEventListener("click", function () {
|
||||||
fetch("/api/file/deleteFiles.php", {
|
fetch("/api/file/deleteFiles.php", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -316,6 +317,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
// 2) Confirm button kicks off the zip+download
|
// 2) Confirm button kicks off the zip+download
|
||||||
if (confirmZipBtn) {
|
if (confirmZipBtn) {
|
||||||
|
confirmZipBtn.setAttribute("data-default", "");
|
||||||
confirmZipBtn.addEventListener("click", async () => {
|
confirmZipBtn.addEventListener("click", async () => {
|
||||||
// a) Validate ZIP filename
|
// a) Validate ZIP filename
|
||||||
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
let zipName = document.getElementById("zipFileNameInput").value.trim();
|
||||||
@@ -478,6 +480,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
const confirmCopy = document.getElementById("confirmCopyFiles");
|
const confirmCopy = document.getElementById("confirmCopyFiles");
|
||||||
if (confirmCopy) {
|
if (confirmCopy) {
|
||||||
|
confirmCopy.setAttribute("data-default", "");
|
||||||
confirmCopy.addEventListener("click", function () {
|
confirmCopy.addEventListener("click", function () {
|
||||||
const targetFolder = document.getElementById("copyTargetFolder").value;
|
const targetFolder = document.getElementById("copyTargetFolder").value;
|
||||||
if (!targetFolder) {
|
if (!targetFolder) {
|
||||||
@@ -529,6 +532,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
|||||||
}
|
}
|
||||||
const confirmMove = document.getElementById("confirmMoveFiles");
|
const confirmMove = document.getElementById("confirmMoveFiles");
|
||||||
if (confirmMove) {
|
if (confirmMove) {
|
||||||
|
confirmMove.setAttribute("data-default", "");
|
||||||
confirmMove.addEventListener("click", function () {
|
confirmMove.addEventListener("click", function () {
|
||||||
const targetFolder = document.getElementById("moveTargetFolder").value;
|
const targetFolder = document.getElementById("moveTargetFolder").value;
|
||||||
if (!targetFolder) {
|
if (!targetFolder) {
|
||||||
@@ -598,6 +602,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
const submitBtn = document.getElementById("submitRenameFile");
|
const submitBtn = document.getElementById("submitRenameFile");
|
||||||
if (submitBtn) {
|
if (submitBtn) {
|
||||||
|
submitBtn.setAttribute("data-default", "");
|
||||||
submitBtn.addEventListener("click", function () {
|
submitBtn.addEventListener("click", function () {
|
||||||
const newName = document.getElementById("newFileName").value.trim();
|
const newName = document.getElementById("newFileName").value.trim();
|
||||||
if (!newName || newName === window.fileToRename) {
|
if (!newName || newName === window.fileToRename) {
|
||||||
|
|||||||
@@ -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_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
|
||||||
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
|
||||||
|
|
||||||
// Lazy-load CodeMirror modes on demand
|
// ==== CodeMirror lazy loader ===============================================
|
||||||
//const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
|
const CM_BASE = "/vendor/codemirror/5.65.5/";
|
||||||
const CM_LOCAL = "/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
|
// Which mode file to load for a given name/mime
|
||||||
const MODE_URL = {
|
const MODE_URL = {
|
||||||
@@ -40,6 +48,13 @@ const MODE_URL = {
|
|||||||
"text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}"
|
"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
|
// Map any mime/alias to the key we use in MODE_URL
|
||||||
function normalizeModeName(modeOption) {
|
function normalizeModeName(modeOption) {
|
||||||
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
|
||||||
@@ -49,62 +64,78 @@ function normalizeModeName(modeOption) {
|
|||||||
return name;
|
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) {
|
function loadScriptOnce(url) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ver = (window.APP_VERSION ?? 'dev').replace(/^v/, ''); // "v1.6.9" -> "1.6.9"
|
if (_loadedScripts.has(url)) return resolve();
|
||||||
const withQS = url; //+ '?v=' + ver;
|
const s = document.createElement("script");
|
||||||
|
s.src = url;
|
||||||
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;
|
|
||||||
s.async = true;
|
s.async = true;
|
||||||
s.dataset.key = key;
|
s.onload = () => { _loadedScripts.add(url); resolve(); };
|
||||||
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
|
s.onerror = () => reject(new Error(`Load failed: ${url}`));
|
||||||
s.addEventListener("error", () => reject(new Error(`Load failed: ${withQS}`)));
|
|
||||||
document.head.appendChild(s);
|
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) {
|
async function ensureModeLoaded(modeOption) {
|
||||||
if (!window.CodeMirror) return;
|
await ensureCore();
|
||||||
|
|
||||||
const name = normalizeModeName(modeOption);
|
const name = normalizeModeName(modeOption);
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
|
if (isModeRegistered(name)) return;
|
||||||
const isRegistered = () =>
|
const deps = MODE_DEPS[name] || [];
|
||||||
(window.CodeMirror?.modes && window.CodeMirror.modes[name]) ||
|
for (const d of deps) {
|
||||||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[name]);
|
if (!isModeRegistered(d)) await loadSingleMode(d);
|
||||||
|
|
||||||
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 (name === "application/x-httpd-php") {
|
await loadSingleMode(name);
|
||||||
await ensureModeLoaded("htmlmixed");
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadScriptOnce(CM_LOCAL + url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function getModeForFile(fileName) {
|
||||||
const dot = fileName.lastIndexOf(".");
|
const dot = fileName.lastIndexOf(".");
|
||||||
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
|
||||||
@@ -215,7 +246,7 @@ export function editFile(fileName, folder) {
|
|||||||
</div>
|
</div>
|
||||||
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
<textarea id="fileEditor" class="editor-textarea">${escapeHTML(content)}</textarea>
|
||||||
<div class="editor-footer">
|
<div class="editor-footer">
|
||||||
<button id="saveBtn" class="btn btn-primary" disabled>${t("save")}</button>
|
<button id="saveBtn" class="btn btn-primary" data-default disabled>${t("save")} </button>
|
||||||
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
<button id="closeBtn" class="btn btn-secondary">${t("close")}</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -246,20 +277,20 @@ export function editFile(fileName, folder) {
|
|||||||
const theme = isDarkMode ? "material-darker" : "default";
|
const theme = isDarkMode ? "material-darker" : "default";
|
||||||
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
const desiredMode = forcePlainText ? "text/plain" : getModeForFile(fileName);
|
||||||
|
|
||||||
// Helper to check whether a mode is currently registered
|
// Start core+mode loading (don’t block closing)
|
||||||
const modeName = typeof desiredMode === "string" ? desiredMode : (desiredMode && desiredMode.name);
|
const modePromise = (async () => {
|
||||||
const isModeRegistered = () =>
|
await ensureCore(); // load CM core + CSS
|
||||||
(window.CodeMirror?.modes && window.CodeMirror.modes[modeName]) ||
|
if (!forcePlainText) {
|
||||||
(window.CodeMirror?.mimeModes && window.CodeMirror.mimeModes[modeName]);
|
await ensureModeLoaded(desiredMode); // then load the needed mode + deps
|
||||||
|
}
|
||||||
// Start mode loading (don’t block closing)
|
})();
|
||||||
const modePromise = ensureModeLoaded(desiredMode);
|
|
||||||
|
|
||||||
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
|
// Wait up to MODE_LOAD_TIMEOUT_MS; then proceed with whatever is available
|
||||||
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
|
const timeout = new Promise((res) => setTimeout(res, MODE_LOAD_TIMEOUT_MS));
|
||||||
|
|
||||||
Promise.race([modePromise, timeout]).then(() => {
|
Promise.race([modePromise, timeout]).then(() => {
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
|
|
||||||
if (!window.CodeMirror) {
|
if (!window.CodeMirror) {
|
||||||
// Core not present: keep plain <textarea>; enable Save and bail gracefully
|
// Core not present: keep plain <textarea>; enable Save and bail gracefully
|
||||||
document.getElementById("saveBtn").disabled = false;
|
document.getElementById("saveBtn").disabled = false;
|
||||||
@@ -267,7 +298,9 @@ export function editFile(fileName, folder) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialMode = (forcePlainText || !isModeRegistered()) ? "text/plain" : desiredMode;
|
const normName = normalizeModeName(desiredMode) || "text/plain";
|
||||||
|
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
|
||||||
|
|
||||||
const cmOptions = {
|
const cmOptions = {
|
||||||
lineNumbers: !forcePlainText,
|
lineNumbers: !forcePlainText,
|
||||||
mode: initialMode,
|
mode: initialMode,
|
||||||
@@ -319,8 +352,11 @@ export function editFile(fileName, folder) {
|
|||||||
|
|
||||||
// If we started in plain text due to timeout, flip to the real mode once it arrives
|
// If we started in plain text due to timeout, flip to the real mode once it arrives
|
||||||
modePromise.then(() => {
|
modePromise.then(() => {
|
||||||
if (!canceled && !forcePlainText && isModeRegistered()) {
|
if (!canceled && !forcePlainText) {
|
||||||
editor.setOption("mode", desiredMode);
|
const nn = normalizeModeName(desiredMode);
|
||||||
|
if (nn && isModeRegistered(nn)) {
|
||||||
|
editor.setOption("mode", desiredMode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// If the mode truly fails to load, we just stay in plain text
|
// If the mode truly fails to load, we just stay in plain text
|
||||||
|
|||||||
@@ -205,30 +205,85 @@ function wireSelectAll(fileListContent) {
|
|||||||
/**
|
/**
|
||||||
* Fuse.js fuzzy search helper
|
* Fuse.js fuzzy search helper
|
||||||
*/
|
*/
|
||||||
function searchFiles(searchTerm) {
|
// --- Lazy Fuse loader (drop-in, CSP-safe, no inline) ---
|
||||||
if (!searchTerm) return fileData;
|
const FUSE_SRC = '/vendor/fuse/6.6.2/fuse.min.js?v={{APP_QVER}}';
|
||||||
|
let _fuseLoadingPromise = null;
|
||||||
|
|
||||||
let keys = [
|
function loadScriptOnce(src) {
|
||||||
{ name: 'name', weight: 0.1 },
|
// cache by src so we don't append multiple <script> tags
|
||||||
{ name: 'uploader', weight: 0.1 },
|
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
|
||||||
{ name: 'tags.name', weight: 0.1 }
|
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
|
||||||
];
|
const p = new Promise((resolve, reject) => {
|
||||||
if (window.advancedSearchEnabled) {
|
const s = document.createElement('script');
|
||||||
keys.push({ name: 'content', weight: 0.7 });
|
s.src = src;
|
||||||
}
|
s.async = true;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
loadScriptOnce._cache.set(src, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lazyLoadFuse() {
|
||||||
|
if (window.Fuse) return Promise.resolve(window.Fuse);
|
||||||
|
if (!_fuseLoadingPromise) {
|
||||||
|
_fuseLoadingPromise = loadScriptOnce(FUSE_SRC).then(() => window.Fuse);
|
||||||
|
}
|
||||||
|
return _fuseLoadingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Optional) warm-up call you can trigger from main.js after first render:
|
||||||
|
// import { warmUpSearch } from './fileListView.js?v={{APP_QVER}}';
|
||||||
|
// warmUpSearch();
|
||||||
|
// This just starts fetching Fuse in the background.
|
||||||
|
export function warmUpSearch() {
|
||||||
|
lazyLoadFuse().catch(() => {/* ignore; we’ll fall back */});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy + backward-compatible search
|
||||||
|
function searchFiles(searchTerm) {
|
||||||
|
if (!searchTerm) return fileData;
|
||||||
|
|
||||||
|
// kick off Fuse load in the background, but don't await
|
||||||
|
lazyLoadFuse().catch(() => { /* ignore */ });
|
||||||
|
|
||||||
|
// keys config (matches your original)
|
||||||
|
const fuseKeys = [
|
||||||
|
{ name: 'name', weight: 0.1 },
|
||||||
|
{ name: 'uploader', weight: 0.1 },
|
||||||
|
{ name: 'tags.name', weight: 0.1 }
|
||||||
|
];
|
||||||
|
if (window.advancedSearchEnabled) {
|
||||||
|
fuseKeys.push({ name: 'content', weight: 0.7 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Fuse is present, use it right away (synchronous API)
|
||||||
|
if (window.Fuse) {
|
||||||
const options = {
|
const options = {
|
||||||
keys: keys,
|
keys: fuseKeys,
|
||||||
threshold: 0.4,
|
threshold: 0.4,
|
||||||
minMatchCharLength: 2,
|
minMatchCharLength: 2,
|
||||||
ignoreLocation: true
|
ignoreLocation: true
|
||||||
};
|
};
|
||||||
|
const fuse = new window.Fuse(fileData, options);
|
||||||
const fuse = new Fuse(fileData, options);
|
const results = fuse.search(searchTerm);
|
||||||
let results = fuse.search(searchTerm);
|
return results.map(r => r.item);
|
||||||
return results.map(result => result.item);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback (first keystrokes before Fuse finishes loading):
|
||||||
|
// simple case-insensitive substring match on the same fields
|
||||||
|
const q = String(searchTerm).toLowerCase();
|
||||||
|
const hay = (v) => (v == null ? '' : String(v)).toLowerCase();
|
||||||
|
return fileData.filter(item => {
|
||||||
|
if (hay(item.name).includes(q)) return true;
|
||||||
|
if (hay(item.uploader).includes(q)) return true;
|
||||||
|
if (Array.isArray(item.tags) && item.tags.some(t => hay(t?.name).includes(q))) return true;
|
||||||
|
if (window.advancedSearchEnabled && hay(item.content).includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* View mode toggle
|
* View mode toggle
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ const translations = {
|
|||||||
"login_options": "Login Options",
|
"login_options": "Login Options",
|
||||||
"disable_login_form": "Disable Login Form",
|
"disable_login_form": "Disable Login Form",
|
||||||
"disable_basic_http_auth": "Disable Basic HTTP Auth",
|
"disable_basic_http_auth": "Disable Basic HTTP Auth",
|
||||||
"disable_oidc_login": "Disable OIDC Login",
|
"disable_oidc_login": "Disable OIDC Login (OIDC Config Required to enable)",
|
||||||
"save_settings": "Save Settings",
|
"save_settings": "Save Settings",
|
||||||
"at_least_one_login_method": "At least one login method must remain enabled.",
|
"at_least_one_login_method": "At least one login method must remain enabled.",
|
||||||
"settings_updated_successfully": "Settings updated successfully.",
|
"settings_updated_successfully": "Settings updated successfully.",
|
||||||
|
|||||||
1071
public/js/main.js
1071
public/js/main.js
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,38 @@ function traverseFileTreePromise(item, path = "") {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Lazy loader for Resumable.js (no CSP inline, cached, safe) ---
|
||||||
|
const RESUMABLE_SRC = '/vendor/resumable/1.1.0/resumable.min.js?v={{APP_QVER}}';
|
||||||
|
let _resumableLoadPromise = null;
|
||||||
|
|
||||||
|
function loadScriptOnce(src) {
|
||||||
|
if (loadScriptOnce._cache?.has(src)) return loadScriptOnce._cache.get(src);
|
||||||
|
loadScriptOnce._cache = loadScriptOnce._cache || new Map();
|
||||||
|
const p = new Promise((resolve, reject) => {
|
||||||
|
const s = document.createElement('script');
|
||||||
|
s.src = src;
|
||||||
|
s.async = true;
|
||||||
|
s.onload = resolve;
|
||||||
|
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
loadScriptOnce._cache.set(src, p);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lazyLoadResumable() {
|
||||||
|
if (window.Resumable) return Promise.resolve(window.Resumable);
|
||||||
|
if (!_resumableLoadPromise) {
|
||||||
|
_resumableLoadPromise = loadScriptOnce(RESUMABLE_SRC).then(() => window.Resumable);
|
||||||
|
}
|
||||||
|
return _resumableLoadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: let main.js prefetch it in the background
|
||||||
|
export function warmUpResumable() {
|
||||||
|
lazyLoadResumable().catch(() => {/* ignore warm-up failure */});
|
||||||
|
}
|
||||||
|
|
||||||
// Recursively retrieve files from DataTransfer items.
|
// Recursively retrieve files from DataTransfer items.
|
||||||
function getFilesFromDataTransferItems(items) {
|
function getFilesFromDataTransferItems(items) {
|
||||||
const promises = [];
|
const promises = [];
|
||||||
@@ -401,36 +433,49 @@ function processFiles(filesInput) {
|
|||||||
Resumable.js Integration for File Picker Uploads
|
Resumable.js Integration for File Picker Uploads
|
||||||
(Only files chosen via file input use Resumable; folder uploads use original code.)
|
(Only files chosen via file input use Resumable; folder uploads use original code.)
|
||||||
----------------------------------------------------- */
|
----------------------------------------------------- */
|
||||||
const useResumable = true; // Enable resumable for file picker uploads
|
const useResumable = true;
|
||||||
let resumableInstance;
|
let resumableInstance = null;
|
||||||
function initResumableUpload() {
|
let _pendingPickedFiles = []; // files picked before library/instance ready
|
||||||
resumableInstance = new Resumable({
|
let _resumableReady = false;
|
||||||
target: "/api/upload/upload.php",
|
|
||||||
chunkSize: 1.5 * 1024 * 1024,
|
// Make init async-safe; it resolves when Resumable is constructed
|
||||||
simultaneousUploads: 3,
|
async function initResumableUpload() {
|
||||||
forceChunkSize: true,
|
if (resumableInstance) return;
|
||||||
testChunks: false,
|
// Load the library if needed
|
||||||
withCredentials: true,
|
const ResumableCtor = await lazyLoadResumable().catch(err => {
|
||||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
console.error('Failed to load Resumable.js:', err);
|
||||||
query: () => ({
|
return null;
|
||||||
folder: window.currentFolder || "root",
|
|
||||||
upload_token: window.csrfToken
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
if (!ResumableCtor) return;
|
||||||
|
|
||||||
|
// Construct the instance once
|
||||||
|
if (!resumableInstance) {
|
||||||
|
resumableInstance = new ResumableCtor({
|
||||||
|
target: "/api/upload/upload.php",
|
||||||
|
chunkSize: 1.5 * 1024 * 1024,
|
||||||
|
simultaneousUploads: 3,
|
||||||
|
forceChunkSize: true,
|
||||||
|
testChunks: false,
|
||||||
|
withCredentials: true,
|
||||||
|
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||||
|
query: () => ({
|
||||||
|
folder: window.currentFolder || "root",
|
||||||
|
upload_token: window.csrfToken
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// keep query fresh when folder changes (call this from your folder nav code)
|
// keep query fresh when folder changes (call this from your folder nav code)
|
||||||
function updateResumableQuery() {
|
function updateResumableQuery() {
|
||||||
if (!resumableInstance) return;
|
if (!resumableInstance) return;
|
||||||
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
|
||||||
// if you're not using a function for query, do:
|
|
||||||
resumableInstance.opts.query.folder = window.currentFolder || 'root';
|
resumableInstance.opts.query.folder = window.currentFolder || 'root';
|
||||||
resumableInstance.opts.query.upload_token = window.csrfToken;
|
resumableInstance.opts.query.upload_token = window.csrfToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
// Assign Resumable to file input for file picker uploads.
|
|
||||||
resumableInstance.assignBrowse(fileInput);
|
|
||||||
fileInput.addEventListener("change", function () {
|
fileInput.addEventListener("change", function () {
|
||||||
for (let i = 0; i < fileInput.files.length; i++) {
|
for (let i = 0; i < fileInput.files.length; i++) {
|
||||||
resumableInstance.addFile(fileInput.files[i]);
|
resumableInstance.addFile(fileInput.files[i]);
|
||||||
@@ -587,13 +632,24 @@ function initResumableUpload() {
|
|||||||
showToast("Some files failed to upload. Please check the list.");
|
showToast("Some files failed to upload. Please check the list.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_resumableReady = true;
|
||||||
|
if (_pendingPickedFiles.length) {
|
||||||
|
updateResumableQuery();
|
||||||
|
for (const f of _pendingPickedFiles) resumableInstance.addFile(f);
|
||||||
|
_pendingPickedFiles = [];
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------
|
/* -----------------------------------------------------
|
||||||
XHR-based submitFiles for Drag–and–Drop (Folder) Uploads
|
XHR-based submitFiles for Drag–and–Drop (Folder) Uploads
|
||||||
----------------------------------------------------- */
|
----------------------------------------------------- */
|
||||||
function submitFiles(allFiles) {
|
function submitFiles(allFiles) {
|
||||||
const folderToUse = window.currentFolder || "root";
|
const folderToUse = (() => {
|
||||||
|
const f = window.currentFolder || "root";
|
||||||
|
try { return decodeURIComponent(f); } catch { return f; }
|
||||||
|
})();
|
||||||
const progressContainer = document.getElementById("uploadProgressContainer");
|
const progressContainer = document.getElementById("uploadProgressContainer");
|
||||||
const fileInput = document.getElementById("file");
|
const fileInput = document.getElementById("file");
|
||||||
|
|
||||||
@@ -857,32 +913,48 @@ function initUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.addEventListener("change", function () {
|
fileInput.addEventListener("change", async function () {
|
||||||
|
const files = Array.from(fileInput.files || []);
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
if (useResumable) {
|
if (useResumable) {
|
||||||
// For file picker, if resumable is enabled, let it handle the files.
|
// Ensure the lib/instance exists
|
||||||
for (let i = 0; i < fileInput.files.length; i++) {
|
if (!_resumableReady) await initResumableUpload();
|
||||||
resumableInstance.addFile(fileInput.files[i]);
|
if (resumableInstance) {
|
||||||
|
for (const f of files) resumableInstance.addFile(f);
|
||||||
|
} else {
|
||||||
|
// If still not ready (load error), fall back to your XHR path
|
||||||
|
processFiles(files);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
processFiles(fileInput.files);
|
processFiles(files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uploadForm) {
|
if (uploadForm) {
|
||||||
uploadForm.addEventListener("submit", function (e) {
|
uploadForm.addEventListener("submit", async function (e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
|
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
|
||||||
if (!files || files.length === 0) {
|
if (!files || !files.length) {
|
||||||
showToast("No files selected.");
|
showToast("No files selected.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// If files come from file picker (no relative path), use Resumable.
|
|
||||||
if (useResumable && (!files[0].customRelativePath || files[0].customRelativePath === "")) {
|
// Resumable path (only for picked files, not folder uploads)
|
||||||
// Ensure current folder is updated.
|
const first = files[0];
|
||||||
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
|
||||||
resumableInstance.upload();
|
if (useResumable && !isFolderish) {
|
||||||
showToast("Resumable upload started...");
|
if (!_resumableReady) await initResumableUpload();
|
||||||
|
if (resumableInstance) {
|
||||||
|
// ensure folder/token fresh
|
||||||
|
resumableInstance.opts.query.folder = window.currentFolder || "root";
|
||||||
|
resumableInstance.upload();
|
||||||
|
showToast("Resumable upload started...");
|
||||||
|
} else {
|
||||||
|
// fallback
|
||||||
|
submitFiles(files);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
submitFiles(files);
|
submitFiles(files);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,55 +7,60 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
|||||||
class AdminController
|
class AdminController
|
||||||
{
|
{
|
||||||
public function getConfig(): void
|
public function getConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
// Load raw config (no disclosure yet)
|
$config = AdminModel::getConfig();
|
||||||
$config = AdminModel::getConfig();
|
if (isset($config['error'])) {
|
||||||
if (isset($config['error'])) {
|
http_response_code(500);
|
||||||
http_response_code(500);
|
header('Cache-Control: no-store');
|
||||||
echo json_encode(['error' => $config['error']]);
|
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
exit;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal, safe subset for all callers (unauth users and regular users)
|
// Whitelisted public subset only
|
||||||
$public = [
|
$public = [
|
||||||
'header_title' => $config['header_title'] ?? 'FileRise',
|
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
|
||||||
'loginOptions' => [
|
'loginOptions' => [
|
||||||
// expose only what the login page / header needs
|
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||||
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||||
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||||
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
],
|
||||||
],
|
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
|
||||||
'globalOtpauthUrl' => $config['globalOtpauthUrl'] ?? '',
|
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||||
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||||
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
'oidc' => [
|
||||||
|
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||||
'oidc' => [
|
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
// never include clientId/clientSecret
|
||||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
],
|
||||||
// never expose clientId / clientSecret
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
|
||||||
|
|
||||||
if ($isAdmin) {
|
|
||||||
// Add admin-only fields (used by Admin Panel UI)
|
|
||||||
$adminExtra = [
|
|
||||||
'loginOptions' => array_merge($public['loginOptions'], [
|
|
||||||
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
|
||||||
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
|
||||||
]),
|
|
||||||
];
|
];
|
||||||
echo json_encode(array_merge($public, $adminExtra));
|
|
||||||
|
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||||
|
|
||||||
|
if ($isAdmin) {
|
||||||
|
// admin-only extras: presence flags + proxy options
|
||||||
|
$adminExtra = [
|
||||||
|
'loginOptions' => array_merge($public['loginOptions'], [
|
||||||
|
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
||||||
|
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
||||||
|
]),
|
||||||
|
'oidc' => array_merge($public['oidc'], [
|
||||||
|
'hasClientId' => !empty($config['oidc']['clientId']),
|
||||||
|
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
header('Cache-Control: no-store'); // don’t cache admin config
|
||||||
|
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-admins / unauthenticated: only the public subset
|
||||||
|
header('Cache-Control: no-store');
|
||||||
|
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-admins / unauthenticated: only the public subset
|
|
||||||
echo json_encode($public);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateConfig(): void
|
public function updateConfig(): void
|
||||||
{
|
{
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -70,7 +70,10 @@ class AuthController
|
|||||||
if ($oidcAction === 'callback') {
|
if ($oidcAction === 'callback') {
|
||||||
try {
|
try {
|
||||||
$oidc->authenticate();
|
$oidc->authenticate();
|
||||||
$username = $oidc->requestUserInfo('preferred_username');
|
$username =
|
||||||
|
$oidc->requestUserInfo('preferred_username')
|
||||||
|
?: $oidc->requestUserInfo('email')
|
||||||
|
?: $oidc->requestUserInfo('sub');
|
||||||
|
|
||||||
// check if this user has a TOTP secret
|
// check if this user has a TOTP secret
|
||||||
$totp_secret = null;
|
$totp_secret = null;
|
||||||
|
|||||||
@@ -52,57 +52,69 @@ class UploadController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- 3) Folder-level WRITE permission (ACL) ----
|
// ---- 3) Folder-level WRITE permission (ACL) ----
|
||||||
// Always require client to send the folder; fall back to GET if needed.
|
// Always require client to send the folder; fall back to GET if needed.
|
||||||
$folderParam = isset($_POST['folder']) ? (string)$_POST['folder'] : (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
|
$folderParam = isset($_POST['folder'])
|
||||||
$targetFolder = ACL::normalizeFolder($folderParam);
|
? (string)$_POST['folder']
|
||||||
|
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
|
||||||
|
|
||||||
// Admins bypass folder canWrite checks
|
// Decode %xx (e.g., "test%20folder") then normalize
|
||||||
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
$folderParam = rawurldecode($folderParam);
|
||||||
http_response_code(403);
|
$targetFolder = ACL::normalizeFolder($folderParam);
|
||||||
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 4) Delegate to model (actual file/chunk processing) ----
|
// Admins bypass folder canWrite checks
|
||||||
// (Optionally re-check in UploadModel before finalizing.)
|
$username = (string)($_SESSION['username'] ?? '');
|
||||||
$result = UploadModel::handleUpload($_POST, $_FILES);
|
$userPerms = loadUserPermissions($username) ?: [];
|
||||||
|
$isAdmin = ACL::isAdmin($userPerms);
|
||||||
|
|
||||||
// ---- 5) Response ----
|
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
|
||||||
if (isset($result['error'])) {
|
http_response_code(403);
|
||||||
http_response_code(400);
|
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
|
||||||
echo json_encode($result);
|
return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isset($result['status'])) {
|
|
||||||
// e.g., {"status":"chunk uploaded"}
|
|
||||||
echo json_encode($result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
echo json_encode([
|
|
||||||
'success' => 'File uploaded successfully',
|
|
||||||
'newFilename' => $result['newFilename'] ?? null
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 4) Delegate to model (force the sanitized folder) ----
|
||||||
|
$_POST['folder'] = $targetFolder; // in case model reads superglobal
|
||||||
|
$post = $_POST;
|
||||||
|
$post['folder'] = $targetFolder;
|
||||||
|
|
||||||
|
$result = UploadModel::handleUpload($post, $_FILES);
|
||||||
|
|
||||||
|
// ---- 5) Response (unchanged) ----
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode($result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isset($result['status'])) {
|
||||||
|
echo json_encode($result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode([
|
||||||
|
'success' => 'File uploaded successfully',
|
||||||
|
'newFilename' => $result['newFilename'] ?? null
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function removeChunks(): void {
|
public function removeChunks(): void {
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
|
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
|
||||||
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
|
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($_POST['folder'])) {
|
|
||||||
http_response_code(400);
|
|
||||||
echo json_encode(['error' => 'No folder specified']);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$folder = (string)$_POST['folder'];
|
|
||||||
$result = UploadModel::removeChunks($folder);
|
|
||||||
echo json_encode($result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isset($_POST['folder'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'No folder specified']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$folderRaw = (string)$_POST['folder'];
|
||||||
|
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
|
||||||
|
|
||||||
|
echo json_encode(UploadModel::removeChunks($folder));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -6,64 +6,68 @@ require_once PROJECT_ROOT . '/config/config.php';
|
|||||||
class UploadModel {
|
class UploadModel {
|
||||||
|
|
||||||
private static function sanitizeFolder(string $folder): string {
|
private static function sanitizeFolder(string $folder): string {
|
||||||
$folder = trim($folder);
|
// decode "%20", normalise slashes & trim via ACL helper
|
||||||
if ($folder === '' || strtolower($folder) === 'root') return '';
|
$f = ACL::normalizeFolder(rawurldecode($folder));
|
||||||
// no traversal
|
|
||||||
if (strpos($folder, '..') !== false) return '';
|
// model uses '' to represent root
|
||||||
// only safe chars + forward slashes
|
if ($f === 'root') return '';
|
||||||
if (!preg_match('/^[A-Za-z0-9_\-\/]+$/', $folder)) return '';
|
|
||||||
// normalize: strip leading slashes
|
// forbid dot segments / empty parts
|
||||||
return ltrim($folder, '/');
|
foreach (explode('/', $f) as $seg) {
|
||||||
|
if ($seg === '' || $seg === '.' || $seg === '..') {
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// allow spaces & unicode via your global regex
|
||||||
|
// (REGEX_FOLDER_NAME validates a path "seg(/seg)*")
|
||||||
|
if (!preg_match(REGEX_FOLDER_NAME, $f)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $f; // safe, normalised, with spaces allowed
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles file uploads – supports both chunked uploads and full (non-chunked) uploads.
|
|
||||||
*
|
|
||||||
* @param array $post The $_POST array.
|
|
||||||
* @param array $files The $_FILES array.
|
|
||||||
* @return array Returns an associative array with "success" on success or "error" on failure.
|
|
||||||
*/
|
|
||||||
public static function handleUpload(array $post, array $files): array {
|
public static function handleUpload(array $post, array $files): array {
|
||||||
// If this is a GET request for testing chunk existence.
|
// --- GET resumable test (make folder handling consistent)
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
|
||||||
$chunkNumber = intval($post['resumableChunkNumber']);
|
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
|
||||||
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
|
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
|
||||||
$folder = isset($post['folder']) ? trim($post['folder']) : 'root';
|
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folder !== 'root') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder . DIRECTORY_SEPARATOR;
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
}
|
}
|
||||||
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
|
|
||||||
|
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
|
||||||
$chunkFile = $tempDir . $chunkNumber;
|
$chunkFile = $tempDir . $chunkNumber;
|
||||||
return ["status" => file_exists($chunkFile) ? "found" : "not found"];
|
return ["status" => file_exists($chunkFile) ? "found" : "not found"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle chunked uploads.
|
// --- CHUNKED ---
|
||||||
if (isset($post['resumableChunkNumber'])) {
|
if (isset($post['resumableChunkNumber'])) {
|
||||||
$chunkNumber = intval($post['resumableChunkNumber']);
|
$chunkNumber = (int)$post['resumableChunkNumber'];
|
||||||
$totalChunks = intval($post['resumableTotalChunks']);
|
$totalChunks = (int)$post['resumableTotalChunks'];
|
||||||
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
|
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
|
||||||
$resumableFilename = urldecode(basename($post['resumableFilename']));
|
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
|
||||||
|
|
||||||
// Validate file name.
|
|
||||||
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
|
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
|
||||||
return ["error" => "Invalid file name: $resumableFilename"];
|
return ["error" => "Invalid file name: $resumableFilename"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$folderRaw = $post['folder'] ?? 'root';
|
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
||||||
$folderSan = self::sanitizeFolder((string)$folderRaw);
|
|
||||||
|
|
||||||
|
|
||||||
if (empty($files['file']) || !isset($files['file']['name'])) {
|
if (empty($files['file']) || !isset($files['file']['name'])) {
|
||||||
return ["error" => "No files received"];
|
return ["error" => "No files received"];
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
if ($folderSan !== '') {
|
if ($folderSan !== '') {
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
}
|
}
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
return ["error" => "Failed to create upload directory"];
|
return ["error" => "Failed to create upload directory"];
|
||||||
}
|
}
|
||||||
@@ -79,159 +83,130 @@ class UploadModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$chunkFile = $tempDir . $chunkNumber;
|
$chunkFile = $tempDir . $chunkNumber;
|
||||||
$tmpName = $files['file']['tmp_name'] ?? null;
|
$tmpName = $files['file']['tmp_name'] ?? null;
|
||||||
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
|
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
|
||||||
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
|
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if all chunks are present.
|
// all chunks present?
|
||||||
$allChunksPresent = true;
|
|
||||||
for ($i = 1; $i <= $totalChunks; $i++) {
|
for ($i = 1; $i <= $totalChunks; $i++) {
|
||||||
if (!file_exists($tempDir . $i)) {
|
if (!file_exists($tempDir . $i)) {
|
||||||
$allChunksPresent = false;
|
return ["status" => "chunk uploaded"];
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!$allChunksPresent) {
|
|
||||||
return ["status" => "chunk uploaded"];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge chunks.
|
// merge
|
||||||
$targetPath = $baseUploadDir . $resumableFilename;
|
$targetPath = $baseUploadDir . $resumableFilename;
|
||||||
if (!$out = fopen($targetPath, "wb")) {
|
if (!$out = fopen($targetPath, "wb")) {
|
||||||
return ["error" => "Failed to open target file for writing"];
|
return ["error" => "Failed to open target file for writing"];
|
||||||
}
|
}
|
||||||
for ($i = 1; $i <= $totalChunks; $i++) {
|
for ($i = 1; $i <= $totalChunks; $i++) {
|
||||||
$chunkPath = $tempDir . $i;
|
$chunkPath = $tempDir . $i;
|
||||||
if (!file_exists($chunkPath)) {
|
if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; }
|
||||||
fclose($out);
|
if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; }
|
||||||
return ["error" => "Chunk $i missing during merge"];
|
while ($buff = fread($in, 4096)) { fwrite($out, $buff); }
|
||||||
}
|
|
||||||
if (!$in = fopen($chunkPath, "rb")) {
|
|
||||||
fclose($out);
|
|
||||||
return ["error" => "Failed to open chunk $i"];
|
|
||||||
}
|
|
||||||
while ($buff = fread($in, 4096)) {
|
|
||||||
fwrite($out, $buff);
|
|
||||||
}
|
|
||||||
fclose($in);
|
fclose($in);
|
||||||
}
|
}
|
||||||
fclose($out);
|
fclose($out);
|
||||||
|
|
||||||
// Update metadata.
|
// metadata
|
||||||
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||||
$uploader = $_SESSION['username'] ?? "Unknown";
|
$uploader = $_SESSION['username'] ?? "Unknown";
|
||||||
$metadataCollection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
$collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
||||||
if (!is_array($metadataCollection)) {
|
if (!is_array($collection)) $collection = [];
|
||||||
$metadataCollection = [];
|
if (!isset($collection[$resumableFilename])) {
|
||||||
}
|
$collection[$resumableFilename] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
|
||||||
if (!isset($metadataCollection[$resumableFilename])) {
|
file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
|
||||||
$metadataCollection[$resumableFilename] = [
|
|
||||||
"uploaded" => $uploadedDate,
|
|
||||||
"uploader" => $uploader
|
|
||||||
];
|
|
||||||
file_put_contents($metadataFile, json_encode($metadataCollection, JSON_PRETTY_PRINT));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup temporary directory.
|
// cleanup temp
|
||||||
$rrmdir = function($dir) use (&$rrmdir) {
|
self::rrmdir($tempDir);
|
||||||
if (!is_dir($dir)) return;
|
|
||||||
$iterator = new RecursiveIteratorIterator(
|
|
||||||
new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
|
|
||||||
RecursiveIteratorIterator::CHILD_FIRST
|
|
||||||
);
|
|
||||||
foreach ($iterator as $item) {
|
|
||||||
$item->isDir() ? rmdir($item->getRealPath()) : unlink($item->getRealPath());
|
|
||||||
}
|
|
||||||
rmdir($dir);
|
|
||||||
};
|
|
||||||
$rrmdir($tempDir);
|
|
||||||
|
|
||||||
return ["success" => "File uploaded successfully"];
|
return ["success" => "File uploaded successfully"];
|
||||||
} else {
|
|
||||||
// Handle full upload (non-chunked)
|
|
||||||
$folderRaw = $post['folder'] ?? 'root';
|
|
||||||
$folderSan = self::sanitizeFolder((string)$folderRaw);
|
|
||||||
}
|
|
||||||
|
|
||||||
$baseUploadDir = UPLOAD_DIR;
|
|
||||||
if ($folderSan !== '') {
|
|
||||||
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
|
||||||
}
|
|
||||||
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
|
||||||
return ["error" => "Failed to create upload directory"];
|
|
||||||
}
|
|
||||||
|
|
||||||
$safeFileNamePattern = REGEX_FILE_NAME;
|
|
||||||
$metadataCollection = [];
|
|
||||||
$metadataChanged = [];
|
|
||||||
|
|
||||||
foreach ($files["file"]["name"] as $index => $fileName) {
|
|
||||||
// Basic PHP upload error check per file
|
|
||||||
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
|
|
||||||
return ["error" => "Error uploading file"];
|
|
||||||
}
|
|
||||||
$safeFileName = trim(urldecode(basename($fileName)));
|
|
||||||
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
|
||||||
return ["error" => "Invalid file name: " . $fileName];
|
|
||||||
}
|
|
||||||
$relativePath = '';
|
|
||||||
if (isset($post['relativePath'])) {
|
|
||||||
$relativePath = is_array($post['relativePath']) ? $post['relativePath'][$index] ?? '' : $post['relativePath'];
|
|
||||||
}
|
|
||||||
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
|
|
||||||
if (!empty($relativePath)) {
|
|
||||||
$subDir = dirname($relativePath);
|
|
||||||
if ($subDir !== '.' && $subDir !== '') {
|
|
||||||
// IMPORTANT: build the subfolder under the *current* base folder
|
|
||||||
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR .
|
|
||||||
str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
|
|
||||||
}
|
|
||||||
$safeFileName = basename($relativePath);
|
|
||||||
}
|
|
||||||
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
|
|
||||||
return ["error" => "Failed to create subfolder: " . $uploadDir];
|
|
||||||
}
|
|
||||||
$targetPath = $uploadDir . $safeFileName;
|
|
||||||
if (move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
|
|
||||||
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
|
||||||
if (!isset($metadataCollection[$metadataKey])) {
|
|
||||||
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
|
||||||
if (!is_array($metadataCollection[$metadataKey])) {
|
|
||||||
$metadataCollection[$metadataKey] = [];
|
|
||||||
}
|
|
||||||
$metadataChanged[$metadataKey] = false;
|
|
||||||
}
|
|
||||||
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
|
|
||||||
$uploadedDate = date(DATE_TIME_FORMAT);
|
|
||||||
$uploader = $_SESSION['username'] ?? "Unknown";
|
|
||||||
$metadataCollection[$metadataKey][$safeFileName] = [
|
|
||||||
"uploaded" => $uploadedDate,
|
|
||||||
"uploader" => $uploader
|
|
||||||
];
|
|
||||||
$metadataChanged[$metadataKey] = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return ["error" => "Error uploading file"];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($metadataCollection as $folderKey => $data) {
|
|
||||||
if ($metadataChanged[$folderKey]) {
|
|
||||||
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
|
|
||||||
$metadataFile = META_DIR . $metadataFileName;
|
|
||||||
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ["success" => "Files uploaded successfully"];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- NON-CHUNKED ---
|
||||||
|
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
|
||||||
|
|
||||||
|
$baseUploadDir = UPLOAD_DIR;
|
||||||
|
if ($folderSan !== '') {
|
||||||
|
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
|
||||||
|
return ["error" => "Failed to create upload directory"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeFileNamePattern = REGEX_FILE_NAME;
|
||||||
|
$metadataCollection = [];
|
||||||
|
$metadataChanged = [];
|
||||||
|
|
||||||
|
foreach ($files["file"]["name"] as $index => $fileName) {
|
||||||
|
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
|
||||||
|
return ["error" => "Error uploading file"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$safeFileName = trim(urldecode(basename($fileName)));
|
||||||
|
if (!preg_match($safeFileNamePattern, $safeFileName)) {
|
||||||
|
return ["error" => "Invalid file name: " . $fileName];
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = '';
|
||||||
|
if (isset($post['relativePath'])) {
|
||||||
|
$relativePath = is_array($post['relativePath']) ? ($post['relativePath'][$index] ?? '') : $post['relativePath'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
|
||||||
|
if (!empty($relativePath)) {
|
||||||
|
$subDir = dirname($relativePath);
|
||||||
|
if ($subDir !== '.' && $subDir !== '') {
|
||||||
|
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
|
||||||
|
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
|
||||||
|
}
|
||||||
|
$safeFileName = basename($relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
|
||||||
|
return ["error" => "Failed to create subfolder: " . $uploadDir];
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetPath = $uploadDir . $safeFileName;
|
||||||
|
if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
|
||||||
|
return ["error" => "Error uploading file"];
|
||||||
|
}
|
||||||
|
|
||||||
|
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
|
||||||
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
|
||||||
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
|
|
||||||
|
if (!isset($metadataCollection[$metadataKey])) {
|
||||||
|
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
|
||||||
|
if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = [];
|
||||||
|
$metadataChanged[$metadataKey] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
|
||||||
|
$uploadedDate = date(DATE_TIME_FORMAT);
|
||||||
|
$uploader = $_SESSION['username'] ?? "Unknown";
|
||||||
|
$metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
|
||||||
|
$metadataChanged[$metadataKey] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($metadataCollection as $folderKey => $data) {
|
||||||
|
if (!empty($metadataChanged[$folderKey])) {
|
||||||
|
$metadataFileName = str_replace(['/', '\\', ' '], '-', $folderKey) . '_metadata.json';
|
||||||
|
$metadataFile = META_DIR . $metadataFileName;
|
||||||
|
file_put_contents($metadataFile, json_encode($data, JSON_PRETTY_PRINT));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ["success" => "Files uploaded successfully"];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively removes a directory and its contents.
|
* Recursively removes a directory and its contents.
|
||||||
|
|||||||
Reference in New Issue
Block a user