From b7d7f7c3ce9dbe1e0a6788fb5fc0273f4aceefcc Mon Sep 17 00:00:00 2001 From: Ryan Date: Sun, 2 Nov 2025 00:32:03 -0400 Subject: [PATCH] release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) --- CHANGELOG.md | 38 ++++++ public/.htaccess | 18 ++- public/css/styles.css | 232 +++++++++++++++++++++---------------- public/index.html | 100 +++++++--------- public/js/adminPanel.js | 22 ++-- public/js/defer-css.js | 47 +++++--- public/js/fileEditor.js | 67 ++++++----- public/js/fileMenu.js | 37 +++--- public/js/filePreview.js | 80 ++++++------- public/js/main.js | 218 +++++++++++++++++++++++++--------- scripts/filerise-deploy.sh | 176 ++++++++++++++++++++++++++++ 11 files changed, 699 insertions(+), 336 deletions(-) create mode 100644 scripts/filerise-deploy.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 60a72c0..e994786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## Changes 11/2/2025 (v1.7.5) + +release(v1.7.5): CSP hardening, API-backed previews, flicker-free theming, cache tuning & deploy script (closes #50) + +### Security/headers + +- Tighten CSP: pin the inline pre-theme snippet with a script-src SHA-256 and keep everything else on 'self'. +- Improve cache policy for versioned assets: force 1y + immutable and add s-maxage for CDNs; also avoid HSTS redirects on local/dev hosts. + +### Previews & editor + +- Remove hardcoded `/uploads/` paths; always build preview URLs via the API (respects UPLOAD_DIR/ACL). +- Use the API URL for gallery prev/next and file-menu “Preview” to fix 404s on custom storage roots. +- Editor now probes size safely (HEAD → Range 0-0 fallback) before fetching, then fetches with credentials. + +### Login, theming & UX polish + +- Pre-theme inline boot sets `dark-mode` + background early; swap to `[hidden]`/`unhide()` instead of inline `display:none`. +- Add full-screen loading overlay with quick fade and proper color-scheme; prevent white/black flash on theme flips. +- Refactor app/login reveal flow in `main.js` (`revealAppAndHideOverlay`, `authed` path, setup wizard). + +### HTML/CSS & perf + +- Make Bootstrap/Styles/Roboto critical (plain ``); keep fonts as true preloads; modulepreload app entry. +- Export a `__CSS_PROMISE__` from `defer-css.js` for sites that still promote preloads. +- Header logo marked `fetchpriority="high"` for faster first paint. +- Normalize dark-mode selectors to `.dark-mode` scope (admin panel, etc.). + +### Manual Deploy script + +- Add `scripts/filerise-deploy.sh`: idempotent rsync-based deploy with writable dirs preserved, optional Composer install, and PHP-FPM/Apache reloads. + +### Notes + +- If you change the inline pre-theme snippet, update the CSP hash accordingly. + +--- + ## Changes 10/31/2025 (v1.7.4) release(v1.7.4): login hint replace toast + fix unauth boot diff --git a/public/.htaccess b/public/.htaccess index 104b6a1..bbd42d1 100644 --- a/public/.htaccess +++ b/public/.htaccess @@ -11,6 +11,9 @@ DirectoryIndex index.html RewriteEngine On +# Never redirect local/dev hosts +RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC] +RewriteRule ^ - [L] # --- HTTPS redirect --- # Use ONE of these blocks. @@ -52,7 +55,7 @@ RewriteEngine On Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" "expr=%{HTTPS} == 'on'" # CSP (modules, blobs, workers, etc.) - Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'" + Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'" # --- Caching (query-string based, no env vars needed) --- @@ -81,10 +84,15 @@ RewriteEngine On Header set Cache-Control "public, max-age=604800" "expr=%{QUERY_STRING} !~ /(^|&)v=/" - # Versioned assets (?v=...): 1 year + immutable - - Header setifempty Cache-Control "public, max-age=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/" - + # --- Versioned assets (?v=...) : 1 year + immutable (override anything else) --- + + + # Only when query string has v= + Header unset Cache-Control "expr=%{QUERY_STRING} =~ /(^|&)v=/" + Header unset Expires "expr=%{QUERY_STRING} =~ /(^|&)v=/" + Header set Cache-Control "public, max-age=31536000, s-maxage=31536000, immutable" "expr=%{QUERY_STRING} =~ /(^|&)v=/" + + # --- Compression --- diff --git a/public/css/styles.css b/public/css/styles.css index 5ff1043..d76c0c1 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1,6 +1,38 @@ /* =========================================================== GENERAL STYLES & BASE LAYOUT =========================================================== */ +/* Reserve stable space for header + main */ +:root { --header-h: 55px; } +.header-container { min-height: var(--header-h); } + + +img.logo{ width:50px; height:50px; display:block; } /* belt & suspenders for logo sizing */ +/* Hidden-but-reserved utility (no clicks) */ +.is-visually-hidden { + visibility: hidden; + pointer-events: none; +} + +/* After auth: show app, hide login */ + + +#fr-login-tip { + min-height: 40px; /* reserve space */ + max-width: 520px; + margin: 8px auto 0; + border-radius: 8px; + padding: 10px 12px; + text-align: left; + margin-bottom: 10px; +} +.main-wrapper{ + display:flex; /* or grid—flex is fine here */ + gap:5px; + align-items:flex-start; +} + + + /* GENERAL STYLES */ body { @@ -24,8 +56,8 @@ body { padding-left: 4px !important; }@media (min-width: 1300px) { .container-fluid { - padding-left: 30px !important; - padding-right: 30px !important; + padding-left: 20px !important; + padding-right: 20px !important; }} @media (max-width: 600px) { .zones-toggle { left: 85px !important; } @@ -37,11 +69,6 @@ body { /************************************************************/ /* FLEXBOX HEADER: LOGO, TITLE, BUTTONS FIXED */ /************************************************************/ - .header-logo .logo { - display:block; - max-width:100%; - height:auto; /* keep aspect ratio; HTML attrs set the intrinsic box */ - } .btn-login { margin-top: 10px; }/* Color overrides */ @@ -65,7 +92,7 @@ body { background-color: #2196F3; transition: background-color 0.3s ease; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); - }body.dark-mode .header-container { + }.dark-mode .header-container { background-color: #1f1f1f; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7); }#darkModeIcon { @@ -77,7 +104,7 @@ body { }.header-logo svg { height: 50px; width: auto; - }body.dark-mode header { + }.dark-mode header { background-color: #1f1f1f; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7); }.header-left { @@ -163,7 +190,7 @@ body { padding: 10px; box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.2); }/* Folder Help Tooltip - Dark Mode */ - body.dark-mode .folder-help-tooltip { + .dark-mode .folder-help-tooltip { background-color: #333 !important; color: #eee !important; border: 1px solid #555 !important; @@ -171,7 +198,7 @@ body { -webkit-text-fill-color: orange !important; color: inherit !important; padding-right: 10px !important; - }body.dark-mode #folderHelpBtn i.material-icons.folder-help-icon { + }.dark-mode #folderHelpBtn i.material-icons.folder-help-icon { -webkit-text-fill-color: #ffa500 !important; padding-right: 10px !important; }/************************************************************/ @@ -221,8 +248,8 @@ body { .material-icons.gallery-icon { color: black; margin-right: 5px; - }body.dark-mode .material-icons.folder-icon, - body.dark-mode .material-icons.gallery-icon { + }.dark-mode .material-icons.folder-icon, + .dark-mode .material-icons.gallery-icon { color: white; margin-right: 5px; }.remove-file-btn { @@ -253,23 +280,23 @@ body { padding: 20px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); border-radius: 4px; - }body.dark-mode #loginForm { + }.dark-mode #loginForm { background-color: #2c2c2c; color: #e0e0e0; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(255, 255, 255, 0.2); - }body.dark-mode #loginForm input { + }.dark-mode #loginForm input { background-color: #333; color: #fff; border: 1px solid #555; - }body.dark-mode #loginForm label { + }.dark-mode #loginForm label { color: #ddd; - }body.dark-mode #loginForm button { + }.dark-mode #loginForm button { background-color: #007bff; color: white; border: none; - }body.dark-mode #loginForm button:hover { + }.dark-mode #loginForm button:hover { background-color: #0056b3; }/* =========================================================== CARDS & MODALS @@ -292,7 +319,7 @@ body { border: 1px solid #ccc; border-radius: 4px; }/* Override modal content for dark mode */ - body.dark-mode #restoreFilesModal .modal-content { + .dark-mode #restoreFilesModal .modal-content { background: #2c2c2c !important; border: 1px solid #555 !important; color: #f0f0f0; @@ -376,7 +403,7 @@ body { transform: translate(-50%, -70%); }} - body.dark-mode .modal .modal-content { + .dark-mode .modal .modal-content { background-color: #2c2c2c; color: #e0e0e0; border-color: #444; @@ -405,10 +432,10 @@ body { background-color: #ff4d4d; box-shadow: 0px 0px 6px rgba(255, 77, 77, 0.8); transform: scale(1.05); - }body.dark-mode .editor-close-btn { + }.dark-mode .editor-close-btn { background-color: rgba(0, 0, 0, 0.7); color: #ff6666; - }body.dark-mode .editor-close-btn:hover { + }.dark-mode .editor-close-btn:hover { background-color: #ff6666; color: #000; }/* Editor Modal */ @@ -434,7 +461,7 @@ body { width: 100% !important; resize: none !important; overflow: auto !important; - }body.dark-mode .editor-modal { + }.dark-mode .editor-modal { background-color: #2c2c2c; color: #e0e0e0; border-color: #444; @@ -459,7 +486,7 @@ body { }.editor-title { margin: 0; line-height: 33px; - }body.dark-mode .editor-header { + }.dark-mode .editor-header { background-color: #2c2c2c; }@media (max-width: 600px) { .editor-title { @@ -527,9 +554,9 @@ body { padding: 4px; border-radius: 4px; transition: background-color 0.2s ease, color 0.2s ease; - }body.dark-mode .material-icons.pauseResumeBtn { + }.dark-mode .material-icons.pauseResumeBtn { color: white !important; - }body.dark-mode .material-icons.pauseResumeBtn:hover { + }.dark-mode .material-icons.pauseResumeBtn:hover { background-color: rgba(255, 215, 0, 0.3); color: #fff; }body:not(.dark-mode) .material-icons.pauseResumeBtn:hover { @@ -632,15 +659,15 @@ body { }#createBtn { background-color: #007bff; color: white; - }body.dark-mode .dropdown-menu { + }.dark-mode .dropdown-menu { background-color: #2c2c2c !important; border-color: #444 !important; color: #e0e0e0!important; - }body.dark-mode .dropdown-menu .dropdown-item { + }.dark-mode .dropdown-menu .dropdown-item { color: #e0e0e0 !important; }.dropdown-item:hover { background-color: rgba(0,0,0,0.05); - }body.dark-mode .dropdown-item:hover { + }.dark-mode .dropdown-item:hover { background-color: rgba(255,255,255,0.1); }#fileList button.edit-btn { background-color: #007bff; @@ -661,7 +688,7 @@ body { background-color: transparent; }#fileList table tr:hover { background-color: #e0e0e0; - }body.dark-mode #fileList table tr:hover { + }.dark-mode #fileList table tr:hover { background-color: #444; }#fileListTitle { white-space: normal !important; @@ -679,7 +706,7 @@ body { box-shadow: none; border: none !important; outline: none !important; - }body.dark-mode #fileList table tr { + }.dark-mode #fileList table tr { box-shadow: none; border: none !important; outline: none !important; @@ -763,7 +790,7 @@ body { color: inherit; cursor: pointer; padding: 0; - }#loginForm, + } #uploadForm { display: none; }.folder-actions { @@ -824,7 +851,7 @@ body { color: #fff; }.row-selected { background-color: #f2f2f2 !important; - }body.dark-mode .row-selected { + }.dark-mode .row-selected { background-color: #444 !important; color: #fff !important; }.custom-prev-next-btn { @@ -838,11 +865,11 @@ body { cursor: pointer; }.custom-prev-next-btn:hover:not(:disabled) { background-color: #d5d5d5; - }body.dark-mode .custom-prev-next-btn { + }.dark-mode .custom-prev-next-btn { background-color: #444; color: #fff; border: none; - }body.dark-mode .custom-prev-next-btn:hover:not(:disabled) { + }.dark-mode .custom-prev-next-btn:hover:not(:disabled) { background-color: #555; }#customToast { position: fixed; @@ -879,6 +906,10 @@ body { line-height: 1 !important; vertical-align: middle !important; }#fileListContainer { + border: 1px solid #e0e0e0; + background: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 8px; max-width: 100%; padding-bottom: 10px !important; padding-left: 5px !important; @@ -889,7 +920,7 @@ body { width: 99%; }} - body.dark-mode #fileListContainer { + .dark-mode #fileListContainer { background-color: #2c2c2c; color: #e0e0e0; border: 1px solid #444; @@ -1122,12 +1153,12 @@ body { background-color: #d0d0d0; border-radius: 4px; padding: 2px 4px; - }body.dark-mode .folder-option.selected { + }.dark-mode .folder-option.selected { background-color: #444; color: #fff; border-radius: 4px; padding: 2px 4px; - }body.dark-mode .folder-option:hover { + }.dark-mode .folder-option:hover { background-color: #333; color: #fff; padding: 2px 4px; @@ -1167,7 +1198,7 @@ body { display: inline-flex !important; }} - body.dark-mode .image-preview-modal-content { + .dark-mode .image-preview-modal-content { background: #2c2c2c; border-color: #444; }.image-modal-img { @@ -1204,13 +1235,13 @@ body { width: 600px !important; max-width: 90vw !important; /* ensures it doesn't exceed the viewport width */ - }body.dark-mode .close-image-modal { + }.dark-mode .close-image-modal { background-color: rgba(0, 0, 0, 0.6); color: #ff6666; - }body.dark-mode .close-image-modal:hover { + }.dark-mode .close-image-modal:hover { background-color: #ff6666; color: #000; - }body.dark-mode .image-preview-modal-content { + }.dark-mode .image-preview-modal-content { background: #2c2c2c; border-color: #444; }.page-indicator { @@ -1223,7 +1254,7 @@ body { margin-right: 0; margin-left: 0; font-size: 32px; - }body.dark-mode .file-icon { + }.dark-mode .file-icon { color: white; }.bottom-select { display: inline-block; @@ -1321,36 +1352,36 @@ body { }/* =========================================================== DARK MODE STYLES =========================================================== */ - body.dark-mode { + .dark-mode { background-color: #121212; color: #e0e0e0; - }body.dark-mode .container { + }.dark-mode .container { background-color: transparent !important; - }body.dark-mode .btn-primary { + }.dark-mode .btn-primary { background-color: #007bff; color: #fff; border-color: #007bff; - }body.dark-mode .btn-secondary { + }.dark-mode .btn-secondary { background-color: #6c757d; color: #fff; border-color: #6c757d; - }body.dark-mode .btn-danger { + }.dark-mode .btn-danger { background-color: #dc3545; color: #fff; border-color: #dc3545; - }body.dark-mode .modal .modal-content, - body.dark-mode .editor-modal { + }.dark-mode .modal .modal-content, + .dark-mode .editor-modal { background-color: #2c2c2c; color: #e0e0e0; border: 1px solid #444; - }body.dark-mode table { + }.dark-mode table { background-color: #2c2c2c; color: #e0e0e0; - }body.dark-mode table tr:hover { + }.dark-mode table tr:hover { background-color: #444; - }body.dark-mode #uploadProgressContainer .progress { + }.dark-mode #uploadProgressContainer .progress { background-color: #333; - }body.dark-mode #uploadProgressContainer .progress-bar { + }.dark-mode #uploadProgressContainer .progress-bar { background-color: #007bff; color: #e0e0e0; }.dark-mode-toggle { @@ -1367,10 +1398,10 @@ body { background-color: rgba(255, 255, 255, 0.15) !important; }.dark-mode-toggle:active { background-color: rgba(255, 255, 255, 0.25) !important; - }body.dark-mode .dark-mode-toggle { + }.dark-mode .dark-mode-toggle { background-color: transparent !important; color: white !important; - }body.dark-mode .dark-mode-toggle:hover { + }.dark-mode .dark-mode-toggle:hover { background-color: rgba(255, 255, 255, 0.15) !important; }.dark-mode-toggle:focus { outline: none !important; @@ -1397,29 +1428,29 @@ body { }.folder-help-list { margin: 0; padding-left: 20px; - }body.dark-mode .folder-help-details { + }.dark-mode .folder-help-details { color: #ddd; background-color: #2c2c2c; border-color: #444; - }body.dark-mode .folder-help-summary { + }.dark-mode .folder-help-summary { color: #ddd; background: #2c2c2c; - }body.dark-mode .folder-help-icon { + }.dark-mode .folder-help-icon { color: #f6a72c; font-size: 20px; - }body.dark-mode .CodeMirror { + }.dark-mode .CodeMirror { background: #1e1e1e !important; color: #ffffff !important; - }body.dark-mode .CodeMirror-cursor { + }.dark-mode .CodeMirror-cursor { border-left: 2px solid #ffffff !important; - }body.dark-mode .CodeMirror-gutters { + }.dark-mode .CodeMirror-gutters { background: #252526 !important; border-right: 1px solid #444 !important; - }body.dark-mode .CodeMirror-linenumber { + }.dark-mode .CodeMirror-linenumber { color: #aaaaaa !important; - }body.dark-mode .CodeMirror-selected { + }.dark-mode .CodeMirror-selected { background: rgba(255, 255, 255, 0.2) !important; - }body.dark-mode .CodeMirror-matchingbracket { + }.dark-mode .CodeMirror-matchingbracket { background-color: rgba(255, 255, 255, 0.1) !important; border-bottom: 1px solid #ffffff !important; }.zoom_in, @@ -1454,7 +1485,7 @@ body { }.drop-hover { background-color: #e0e0e0; border: 1px dashed #666; - }body.dark-mode .drop-hover { + }.dark-mode .drop-hover { background-color: rgba(255, 255, 255, 0.1) !important; border-bottom: 1px dashed #ffffff !important; }#restoreFilesList li { @@ -1466,35 +1497,33 @@ body { transform: translateY(-3px) !important; }#restoreFilesList li label { margin-left: 8px !important; - }body.dark-mode #fileContextMenu { + }.dark-mode #fileContextMenu { background-color: #2c2c2c !important; border: 1px solid #555 !important; color: #e0e0e0 !important; - }body.dark-mode #fileContextMenu div { + }.dark-mode #fileContextMenu div { color: #e0e0e0 !important; }#folderContextMenu { font-family: Arial, sans-serif; font-size: 14px; - }body.dark-mode #folderContextMenu { + }.dark-mode #folderContextMenu { background-color: #2c2c2c; border-color: #555; color: #e0e0e0; - }.main-wrapper { - display: flex; - flex-direction: row; }.drop-target-sidebar { display: none; - width: 50px; - transition: width 0.3s ease; background-color: #f8f9fa; border-right: 2px dashed #1565C0; - padding: 10px; + margin-top: 10px; + margin-left: 10px; }@media (min-width: 769px) { .drop-target-sidebar { display: block; }} - .drop-target-sidebar.active { + .drop-target-sidebar.active, + .drag-header.active { width: 350px; + height: 750px; }.main-column { flex: 1; transition: margin-left 0.3s ease; @@ -1563,13 +1592,12 @@ body { }#sidebarDropArea, #uploadFolderRow { background-color: transparent; - }#sidebarDropArea { - display: none; - }body.dark-mode #sidebarDropArea, - body.dark-mode #uploadFolderRow { + + }.dark-mode #sidebarDropArea, + .dark-mode #uploadFolderRow { background-color: transparent; - }body.dark-mode #sidebarDropArea.highlight, - body.dark-mode #uploadFolderRow.highlight { + }.dark-mode #sidebarDropArea.highlight, + .dark-mode #uploadFolderRow.highlight { background-color: #333; border: 2px dashed #555; color: #fff; @@ -1588,7 +1616,7 @@ body { max-width: 900px; width: 100%; margin: 0 auto; - }body.dark-mode .card { + }.dark-mode .card { background-color: #2c2c2c; color: #e0e0e0; border: 1px solid #444; @@ -1606,17 +1634,17 @@ body { }.admin-panel-content { background: #fff; color: #000; - }body.dark-mode .admin-panel-content { + }.dark-mode .admin-panel-content { background: #2c2c2c; color: #e0e0e0; border: 1px solid #444; - }body.dark-mode .admin-panel-content input, - body.dark-mode .admin-panel-content select, - body.dark-mode .admin-panel-content textarea { + }.dark-mode .admin-panel-content input, + .dark-mode .admin-panel-content select, + .dark-mode .admin-panel-content textarea { background: #3a3a3a; color: #e0e0e0; border: 1px solid #555; - }body.dark-mode .admin-panel-content label { + }.dark-mode .admin-panel-content label { color: #e0e0e0; }#openChangePasswordModalBtn { width: max-content; @@ -1637,7 +1665,7 @@ body { color: var(--download-spinner-color, #000); }body:not(.dark-mode) { --download-spinner-color: #000; - }body.dark-mode { + }.dark-mode { --download-spinner-color: #fff; }.rise-effect { transform: translateY(-20px); @@ -1672,7 +1700,7 @@ body { background-color: transparent; transition: width 0.3s ease; box-sizing: border-box; - }body.dark-mode .header-drop-zone.drag-active { + }.dark-mode .header-drop-zone.drag-active { background-color: #333; border: 2px dashed #555; color: #fff; @@ -1703,16 +1731,16 @@ body { line-height: 1; margin: 0; padding: 0; - }body.dark-mode #fileSummary { + }.dark-mode #fileSummary { color: white; }#searchIcon { border-radius: 4px; padding: 4px 8px; - }body.dark-mode #searchIcon { + }.dark-mode #searchIcon { background-color: #444; border: 1px solid #555; color: #fff; - }body.dark-mode #searchInput { + }.dark-mode #searchInput { background-color: #333; color: #e0e0e0; border: 1px solid #555; @@ -1737,11 +1765,11 @@ body { .btn-icon:focus { background: rgba(0, 0, 0, 0.1); outline: none; - }body.dark-mode .btn-icon .material-icons, - body.dark-mode #searchIcon .material-icons { + }.dark-mode .btn-icon .material-icons, + .dark-mode #searchIcon .material-icons { color: #fff; - }body.dark-mode .btn-icon:hover, - body.dark-mode .btn-icon:focus { + }.dark-mode .btn-icon:hover, + .dark-mode .btn-icon:focus { background: rgba(255, 255, 255, 0.1); }.user-dropdown { position: relative; @@ -1772,12 +1800,12 @@ body { display: inline-block; vertical-align: middle; margin-left: 0.25rem; - }body.dark-mode .user-dropdown .user-menu { + }.dark-mode .user-dropdown .user-menu { background: #2c2c2c; border-color: #444; - }body.dark-mode .user-dropdown .user-menu .item { + }.dark-mode .user-dropdown .user-menu .item { color: #e0e0e0; - }body.dark-mode .user-dropdown .user-menu .item:hover { + }.dark-mode .user-dropdown .user-menu .item:hover { background: rgba(255,255,255,0.1); }.user-dropdown .dropdown-username { margin: 0 8px; @@ -1814,7 +1842,7 @@ body { }:root { --perm-caret: #444; }/* light */ - body.dark-mode { + .dark-mode { --perm-caret: #ccc; }/* dark */ @@ -1827,7 +1855,7 @@ body { background-color 160ms cubic-bezier(.2,.0,.2,1); }:root { --toggle-icon-color: #333; - }body.dark-mode { + }.dark-mode { --toggle-icon-color: #eee; }#zonesToggleFloating .material-icons, #zonesToggleFloating .material-icons-outlined, diff --git a/public/index.html b/public/index.html index 32a550b..48adfa4 100644 --- a/public/index.html +++ b/public/index.html @@ -2,65 +2,37 @@ - - - FileRise - - - - - - - - - - - - - + + + - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + - - - - - - - - - - + + + + + - + \ No newline at end of file diff --git a/public/js/adminPanel.js b/public/js/adminPanel.js index 156a84f..a3e06af 100644 --- a/public/js/adminPanel.js +++ b/public/js/adminPanel.js @@ -170,9 +170,9 @@ async function safeJson(res) { max-width: none !important; } } - body.dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; } - body.dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; } - body.dark-mode .form-control::placeholder { color:#888; } + .dark-mode #adminPanelModal .modal-content { background:#2c2c2c !important; color:#e0e0e0 !important; border-color:#555 !important; } + .dark-mode .form-control { background-color:#333; border-color:#555; color:#eee; } + .dark-mode .form-control::placeholder { color:#888; } .section-header { background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold; @@ -181,8 +181,8 @@ async function safeJson(res) { .section-header:first-of-type { margin-top:0; } .section-header.collapsed .material-icons { transform:rotate(-90deg); } .section-header .material-icons { transition:transform .3s; color:#444; } - body.dark-mode .section-header { background:#3a3a3a; color:#eee; } - body.dark-mode .section-header .material-icons { color:#ccc; } + .dark-mode .section-header { background:#3a3a3a; color:#eee; } + .dark-mode .section-header .material-icons { color:#ccc; } .section-content { display:none; margin-left:20px; margin-top:8px; margin-bottom:8px; } @@ -193,7 +193,7 @@ async function safeJson(res) { border:2px solid transparent; transition:all .3s; } #adminPanelModal .editor-close-btn:hover { color:#fff; background:#ff4d4d; box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05); } - body.dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; } + .dark-mode #adminPanelModal .editor-close-btn { background:rgba(0,0,0,0.6); color:#ff4d4d; } .action-row { display:flex; justify-content:space-between; margin-top:15px; } @@ -210,7 +210,7 @@ async function safeJson(res) { border-radius: 6px; padding: 0; } - body.dark-mode .folder-access-list { border-color:#555; } + .dark-mode .folder-access-list { border-color:#555; } .folder-access-header, .folder-access-row { @@ -228,7 +228,7 @@ async function safeJson(res) { font-weight: 700; border-bottom: 1px solid rgba(0,0,0,0.12); } - body.dark-mode .folder-access-header { background:#2c2c2c; } + .dark-mode .folder-access-header { background:#2c2c2c; } .folder-access-row { border-bottom: 1px solid rgba(0,0,0,0.06); } .folder-access-row:last-child { border-bottom: none; } @@ -257,8 +257,8 @@ async function safeJson(res) { color: #2064ff; margin-left: 6px; } - body.dark-mode .inherited-row { background: rgba(32,132,255,0.12); } - body.dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; } + .dark-mode .inherited-row { background: rgba(32,132,255,0.12); } + .dark-mode .inherited-tag { background: rgba(32,132,255,0.2); color: #89b3ff; } @media (max-width: 900px) { .folder-access-list { --col-perm: 72px; --col-folder-min: 240px; } @@ -274,7 +274,7 @@ async function safeJson(res) { /* nicer thin scrollbar (supported browsers) */ .folder-cell::-webkit-scrollbar{ height:8px; } .folder-cell::-webkit-scrollbar-thumb{ background:rgba(0,0,0,.25); border-radius:4px; } - body.dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); } + .dark-mode .folder-cell::-webkit-scrollbar-thumb{ background:rgba(255,255,255,.25); } /* Badge now doesn't clip; let the wrapper handle scroll */ .folder-badge{ diff --git a/public/js/defer-css.js b/public/js/defer-css.js index a4131e3..1b57cfc 100644 --- a/public/js/defer-css.js +++ b/public/js/defer-css.js @@ -1,20 +1,31 @@ -// 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); - }); +// /public/js/defer-css.js +// Promote preloaded styles to real stylesheets (CSP-safe) and expose a load promise. +(function () { + if (window.__CSS_PROMISE__) return; + var loads = []; - // Optionally load non-critical icon/extra font CSS after first paint: - const extra = document.createElement('link'); - extra.rel = 'stylesheet'; - extra.href = '/css/vendor/material-icons.css?v={{APP_QVER}}'; - document.head.appendChild(extra); -}); \ No newline at end of file + // Promote IN-PLACE + var preloads = document.querySelectorAll('link[rel="preload"][as="style"]'); + for (var i = 0; i < preloads.length; i++) { + var l = preloads[i]; + // resolve when it finishes loading as a stylesheet + loads.push(new Promise(function (res) { l.addEventListener('load', res, { once: true }); })); + l.rel = 'stylesheet'; + if (!l.media || l.media === 'print') l.media = 'all'; // be explicit + l.removeAttribute('as'); // keep some engines happy about "used" preload + } + + // Also wait for any existing that haven't finished yet + var styles = document.querySelectorAll('link[rel="stylesheet"]'); + for (var j = 0; j < styles.length; j++) { + var s = styles[j]; + if (s.sheet) continue; // already applied + loads.push(new Promise(function (res) { s.addEventListener('load', res, { once: true }); })); + } + + // Safari quirk: nudge layout so promoted sheets apply immediately + void document.documentElement.offsetHeight; + + window.__CSS_PROMISE__ = Promise.all(loads); +})(); \ No newline at end of file diff --git a/public/js/fileEditor.js b/public/js/fileEditor.js index 12225e9..652e97e 100644 --- a/public/js/fileEditor.js +++ b/public/js/fileEditor.js @@ -2,6 +2,7 @@ import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}'; import { loadFileList } from './fileListView.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}'; +import { buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}'; // thresholds for editor behavior const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings @@ -14,7 +15,7 @@ const CM_BASE = "/vendor/codemirror/5.65.5/"; const coreUrl = (p) => `${CM_BASE}${p}?v={{APP_QVER}}`; const CORE = { - js: coreUrl("codemirror.min.js"), + js: coreUrl("codemirror.min.js"), css: coreUrl("codemirror.min.css"), themeCss: coreUrl("theme/material-darker.min.css"), }; @@ -22,30 +23,30 @@ const CORE = { // Which mode file to load for a given name/mime const MODE_URL = { // core/common - "xml": "mode/xml/xml.min.js?v={{APP_QVER}}", - "css": "mode/css/css.min.js?v={{APP_QVER}}", + "xml": "mode/xml/xml.min.js?v={{APP_QVER}}", + "css": "mode/css/css.min.js?v={{APP_QVER}}", "javascript": "mode/javascript/javascript.min.js?v={{APP_QVER}}", // meta / combos - "htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}", + "htmlmixed": "mode/htmlmixed/htmlmixed.min.js?v={{APP_QVER}}", "application/x-httpd-php": "mode/php/php.min.js?v={{APP_QVER}}", // docs / data - "markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}", - "yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}", + "markdown": "mode/markdown/markdown.min.js?v={{APP_QVER}}", + "yaml": "mode/yaml/yaml.min.js?v={{APP_QVER}}", "properties": "mode/properties/properties.min.js?v={{APP_QVER}}", - "sql": "mode/sql/sql.min.js?v={{APP_QVER}}", + "sql": "mode/sql/sql.min.js?v={{APP_QVER}}", // shells - "shell": "mode/shell/shell.min.js?v={{APP_QVER}}", + "shell": "mode/shell/shell.min.js?v={{APP_QVER}}", // languages - "python": "mode/python/python.min.js?v={{APP_QVER}}", - "text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}", - "text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}", - "text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}", - "text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}", - "text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}" + "python": "mode/python/python.min.js?v={{APP_QVER}}", + "text/x-csrc": "mode/clike/clike.min.js?v={{APP_QVER}}", + "text/x-c++src": "mode/clike/clike.min.js?v={{APP_QVER}}", + "text/x-java": "mode/clike/clike.min.js?v={{APP_QVER}}", + "text/x-csharp": "mode/clike/clike.min.js?v={{APP_QVER}}", + "text/x-kotlin": "mode/clike/clike.min.js?v={{APP_QVER}}" }; // Mode dependency graph @@ -201,23 +202,37 @@ export function editFile(fileName, folder) { if (existingEditor) existingEditor.remove(); const folderUsed = folder || window.currentFolder || "root"; - const folderPath = folderUsed === "root" - ? "uploads/" - : "uploads/" + folderUsed.split("/").map(encodeURIComponent).join("/") + "/"; - const fileUrl = folderPath + encodeURIComponent(fileName) + "?t=" + new Date().getTime(); + const fileUrl = buildPreviewUrl(folderUsed, fileName); - fetch(fileUrl, { method: "HEAD" }) - .then(response => { - const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length"); - const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null; + // Probe size safely via API. Prefer HEAD; if missing Content-Length, fall back to a 1-byte Range GET. + async function probeSize(url) { + try { + const h = await fetch(url, { method: "HEAD", credentials: "include" }); + const len = h.headers.get("content-length") ?? h.headers.get("Content-Length"); + if (len && !Number.isNaN(parseInt(len, 10))) return parseInt(len, 10); + } catch { } + try { + const r = await fetch(url, { + method: "GET", + headers: { Range: "bytes=0-0" }, + credentials: "include" + }); + // Content-Range: bytes 0-0/12345 + const cr = r.headers.get("content-range") ?? r.headers.get("Content-Range"); + const m = cr && cr.match(/\/(\d+)\s*$/); + if (m) return parseInt(m[1], 10); + } catch { } + return null; + } + probeSize(fileUrl) + .then(sizeBytes => { if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) { showToast("This file is larger than 10 MB and cannot be edited in the browser."); throw new Error("File too large."); } - return response; + return fetch(fileUrl, { credentials: "include" }); }) - .then(() => fetch(fileUrl)) .then(response => { if (!response.ok) throw new Error("HTTP error! Status: " + response.status); const lenHeader = response.headers.get("content-length") ?? response.headers.get("Content-Length"); @@ -269,8 +284,8 @@ export function editFile(fileName, folder) { // Keep buttons responsive even before editor exists const decBtn = document.getElementById("decreaseFont"); const incBtn = document.getElementById("increaseFont"); - decBtn.addEventListener("click", () => {}); - incBtn.addEventListener("click", () => {}); + decBtn.addEventListener("click", () => { }); + incBtn.addEventListener("click", () => { }); // Theme + mode selection const isDarkMode = document.body.classList.contains("dark-mode"); diff --git a/public/js/fileMenu.js b/public/js/fileMenu.js index 57d277a..85f0d44 100644 --- a/public/js/fileMenu.js +++ b/public/js/fileMenu.js @@ -1,7 +1,7 @@ // fileMenu.js import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}'; import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}'; -import { previewFile } from './filePreview.js?v={{APP_QVER}}'; +import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}'; import { editFile } from './fileEditor.js?v={{APP_QVER}}'; import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}'; import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}'; @@ -39,11 +39,11 @@ export function showFileContextMenu(x, y, menuItems) { }); menu.appendChild(menuItem); }); - + menu.style.left = x + "px"; menu.style.top = y + "px"; menu.style.display = "block"; - + const menuRect = menu.getBoundingClientRect(); const viewportHeight = window.innerHeight; if (menuRect.bottom > viewportHeight) { @@ -62,7 +62,7 @@ export function hideFileContextMenu() { export function fileListContextMenuHandler(e) { e.preventDefault(); - + let row = e.target.closest("tr"); if (row) { const checkbox = row.querySelector(".file-checkbox"); @@ -71,9 +71,9 @@ export function fileListContextMenuHandler(e) { updateRowHighlight(checkbox); } } - + const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value); - + let menuItems = [ { label: t("create_file"), action: () => openCreateFileModal() }, { label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } }, @@ -81,14 +81,14 @@ export function fileListContextMenuHandler(e) { { label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } }, { label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } } ]; - + if (selected.some(name => name.toLowerCase().endsWith(".zip"))) { menuItems.push({ label: t("extract_zip"), action: () => { handleExtractZipSelected(new Event("click")); } }); } - + if (selected.length > 1) { menuItems.push({ label: t("tag_selected"), @@ -100,36 +100,33 @@ export function fileListContextMenuHandler(e) { } else if (selected.length === 1) { const file = fileData.find(f => f.name === selected[0]); - + menuItems.push({ label: t("preview"), action: () => { const folder = window.currentFolder || "root"; - const folderPath = folder === "root" - ? "uploads/" - : "uploads/" + folder.split("/").map(encodeURIComponent).join("/") + "/"; - previewFile(folderPath + encodeURIComponent(file.name) + "?t=" + new Date().getTime(), file.name); + previewFile(buildPreviewUrl(folder, file.name), file.name); } }); - + if (canEditFile(file.name)) { menuItems.push({ label: t("edit"), action: () => { editFile(selected[0], window.currentFolder); } }); } - + menuItems.push({ label: t("rename"), action: () => { renameFile(selected[0], window.currentFolder); } }); - + menuItems.push({ label: t("tag_file"), action: () => { openTagModal(file); } }); } - + showFileContextMenu(e.clientX, e.clientY, menuItems); } @@ -140,7 +137,7 @@ export function bindFileListContextMenu() { } } -document.addEventListener("click", function(e) { +document.addEventListener("click", function (e) { const menu = document.getElementById("fileContextMenu"); if (menu && menu.style.display === "block") { hideFileContextMenu(); @@ -148,9 +145,9 @@ document.addEventListener("click", function(e) { }); // Rebind context menu after file table render. -(function() { +(function () { const originalRenderFileTable = window.renderFileTable; - window.renderFileTable = function(folder) { + window.renderFileTable = function (folder) { originalRenderFileTable(folder); bindFileListContextMenu(); }; diff --git a/public/js/filePreview.js b/public/js/filePreview.js index 8d0b360..1070a5f 100644 --- a/public/js/filePreview.js +++ b/public/js/filePreview.js @@ -3,6 +3,12 @@ import { escapeHTML, showToast } from './domUtils.js?v={{APP_QVER}}'; import { fileData } from './fileListView.js?v={{APP_QVER}}'; import { t } from './i18n.js?v={{APP_QVER}}'; +// Build a preview URL that always goes through the API layer (respects ACLs/UPLOAD_DIR) +export function buildPreviewUrl(folder, name) { + const f = (!folder || folder === '') ? 'root' : String(folder); + return `/api/file/download.php?folder=${encodeURIComponent(f)}&file=${encodeURIComponent(name)}&inline=1&t=${Date.now()}`; +} + export function openShareModal(file, folder) { // Remove any existing modal const existing = document.getElementById("shareModal"); @@ -92,10 +98,10 @@ export function openShareModal(file, folder) { if (sel.value === "custom") { value = parseInt(document.getElementById("customExpirationValue").value, 10); - unit = document.getElementById("customExpirationUnit").value; + unit = document.getElementById("customExpirationUnit").value; } else { value = parseInt(sel.value, 10); - unit = "minutes"; + unit = "minutes"; } const password = document.getElementById("sharePassword").value; @@ -115,20 +121,20 @@ export function openShareModal(file, folder) { password }) }) - .then(res => res.json()) - .then(data => { - if (data.token) { - const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`; - document.getElementById("shareLinkInput").value = url; - document.getElementById("shareLinkDisplay").style.display = "block"; - } else { - showToast(t("error_generating_share") + ": " + (data.error||"Unknown")); - } - }) - .catch(err => { - console.error(err); - showToast(t("error_generating_share")); - }); + .then(res => res.json()) + .then(data => { + if (data.token) { + const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`; + document.getElementById("shareLinkInput").value = url; + document.getElementById("shareLinkDisplay").style.display = "block"; + } else { + showToast(t("error_generating_share") + ": " + (data.error || "Unknown")); + } + }) + .catch(err => { + console.error(err); + showToast(t("error_generating_share")); + }); }); // Copy to clipboard @@ -272,10 +278,7 @@ export function previewFile(fileUrl, fileName) { modal.galleryCurrentIndex = (modal.galleryCurrentIndex - 1 + modal.galleryImages.length) % modal.galleryImages.length; let newFile = modal.galleryImages[modal.galleryCurrentIndex]; modal.querySelector("h4").textContent = newFile.name; - img.src = ((window.currentFolder === "root") - ? "uploads/" - : "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/") - + encodeURIComponent(newFile.name) + "?t=" + new Date().getTime(); + img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name); // Reset transforms. img.dataset.scale = 1; img.dataset.rotate = 0; @@ -355,10 +358,7 @@ export function previewFile(fileUrl, fileName) { modal.galleryCurrentIndex = (modal.galleryCurrentIndex + 1) % modal.galleryImages.length; let newFile = modal.galleryImages[modal.galleryCurrentIndex]; modal.querySelector("h4").textContent = newFile.name; - img.src = ((window.currentFolder === "root") - ? "uploads/" - : "uploads/" + window.currentFolder.split("/").map(encodeURIComponent).join("/") + "/") - + encodeURIComponent(newFile.name) + "?t=" + new Date().getTime(); + img.src = buildPreviewUrl(window.currentFolder || 'root', newFile.name); // Reset transforms. img.dataset.scale = 1; img.dataset.rotate = 0; @@ -416,26 +416,26 @@ export function previewFile(fileUrl, fileName) { } } else { // Handle non-image file previews. - if (extension === "pdf") { - // build a cache‐busted URL - const separator = fileUrl.includes('?') ? '&' : '?'; - const urlWithTs = fileUrl + separator + 't=' + Date.now(); - - // open in a new tab (avoids CSP frame-ancestors) - window.open(urlWithTs, "_blank"); - - // tear down the just-created modal - const modal = document.getElementById("filePreviewModal"); - if (modal) modal.remove(); - - // stop further preview logic - return; - } else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) { + if (extension === "pdf") { + // build a cache‐busted URL + const separator = fileUrl.includes('?') ? '&' : '?'; + const urlWithTs = fileUrl + separator + 't=' + Date.now(); + + // open in a new tab (avoids CSP frame-ancestors) + window.open(urlWithTs, "_blank"); + + // tear down the just-created modal + const modal = document.getElementById("filePreviewModal"); + if (modal) modal.remove(); + + // stop further preview logic + return; + } else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) { const video = document.createElement("video"); video.src = fileUrl; video.controls = true; video.className = "image-modal-img"; - + const progressKey = 'videoProgress-' + fileUrl; video.addEventListener("loadedmetadata", () => { const savedTime = localStorage.getItem(progressKey); diff --git a/public/js/main.js b/public/js/main.js index 23ed92f..90d3fda 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -67,32 +67,25 @@ function isDemoHost() { } function showLoginTip(message) { - const form = document.getElementById('loginForm'); - if (!form) return; - - let tip = document.getElementById('fr-login-tip'); - if (!tip) { - tip = document.createElement('div'); - tip.id = 'fr-login-tip'; - tip.className = 'alert alert-info'; // fine even without Bootstrap - tip.style.marginTop = '8px'; - form.prepend(tip); - } - - // Clear & rebuild so we can add the demo hint cleanly - tip.textContent = ''; - tip.append(document.createTextNode(message || '')); - - if (isDemoHost()) { - const line = document.createElement('div'); - line.style.marginTop = '6px'; - const mk = (txt) => { const k = document.createElement('code'); k.textContent = txt; return k; }; - line.append( - document.createTextNode('Demo login — user: '), mk('demo'), - document.createTextNode(' · pass: '), mk('demo') - ); + const tip = document.getElementById('fr-login-tip'); + if (!tip) return; + tip.innerHTML = ''; // clear + if (message) tip.append(document.createTextNode(message)); + if (location.hostname.replace(/^www\./, '') === 'demo.filerise.net') { + const line = document.createElement('div'); line.style.marginTop = '6px'; + const mk = t => { const k = document.createElement('code'); k.textContent = t; return k; }; + line.append(document.createTextNode('Demo login — user: '), mk('demo'), + document.createTextNode(' · pass: '), mk('demo')); tip.append(line); } + tip.style.display = 'block'; // reveal without shifting layout +} + +async function hideOverlaySmoothly(overlay) { + if (!overlay) return; + try { await document.fonts?.ready; } catch { } + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + overlay.style.display = 'none'; } function wireModalEnterDefault() { @@ -322,7 +315,6 @@ function applyDarkMode({ fromSystemChange = false } = {}) { let stored = null; try { stored = localStorage.getItem('darkMode'); } catch { } - // If no stored pref, fall back to system let isDark = (stored === null) ? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) : (stored === '1' || stored === 'true'); @@ -336,15 +328,26 @@ function applyDarkMode({ fromSystemChange = false } = {}) { el.setAttribute('data-theme', isDark ? 'dark' : 'light'); }); + // keep UA chrome & bg consistent post-toggle + const bg = isDark ? '#121212' : '#ffffff'; + root.style.backgroundColor = bg; + root.style.colorScheme = isDark ? 'dark' : 'light'; + if (body) { + body.style.backgroundColor = bg; + body.style.colorScheme = isDark ? 'dark' : 'light'; + } + const mt = document.querySelector('meta[name="theme-color"]'); + if (mt) mt.content = bg; + const mcs = document.querySelector('meta[name="color-scheme"]'); + if (mcs) mcs.content = isDark ? 'dark light' : 'light dark'; + const btn = document.getElementById('darkModeToggle'); const icon = document.getElementById('darkModeIcon'); if (icon) icon.textContent = isDark ? 'light_mode' : 'dark_mode'; - if (btn) { const ttOn = (typeof t === 'function' ? t('switch_to_dark_mode') : 'Switch to dark mode'); const ttOff = (typeof t === 'function' ? t('switch_to_light_mode') : 'Switch to light mode'); const aria = (typeof t === 'function' ? (isDark ? t('light_mode') : t('dark_mode')) : (isDark ? 'Light mode' : 'Dark mode')); - btn.classList.toggle('active', isDark); btn.setAttribute('aria-label', aria); btn.setAttribute('title', isDark ? ttOff : ttOn); @@ -381,6 +384,9 @@ function bindDarkMode() { // ---------- tiny utils ---------- const $ = (s, root = document) => root.querySelector(s); const $$ = (s, root = document) => Array.from(root.querySelectorAll(s)); + // Safe show/hide that work with both CSS and [hidden] + const unhide = (el) => { if (!el) return; el.removeAttribute('hidden'); el.style.display = ''; }; + const hideEl = (el) => { if (!el) return; el.setAttribute('hidden', ''); el.style.display = 'none'; }; const show = (el) => { if (!el) return; el.hidden = false; el.classList?.remove('d-none', 'hidden'); @@ -394,28 +400,88 @@ function bindDarkMode() { }; // ---------- site config / auth ---------- - function applySiteConfig(cfg) { + function applySiteConfig(cfg, { phase = 'final' } = {}) { try { const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise'; + + // Always keep correct early (no visual flicker) document.title = title; - const h1 = document.querySelector('.header-title h1'); if (h1) h1.textContent = title; - + + // --- Login options (apply in BOTH phases so login page is correct) --- const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {}; - const disableForm = !!lo.disableFormLogin; - const disableOIDC = !!lo.disableOIDCLogin; + const disableForm = !!lo.disableFormLogin; + const disableOIDC = !!lo.disableOIDCLogin; const disableBasic = !!lo.disableBasicAuth; - - const row = $('#loginForm'); if (row) row.style.display = disableForm ? 'none' : ''; - const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : ''; + + const row = $('#loginForm'); + if (row) { + if (disableForm) { + row.setAttribute('hidden', ''); + row.style.display = ''; // don't leave display:none lying around + } else { + row.removeAttribute('hidden'); + row.style.display = ''; + } + } + const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : ''; const basic = document.querySelector('a[href="/api/auth/login_basic.php"]'); if (basic) basic.style.display = disableBasic ? 'none' : ''; + + // --- Header <h1> only in the FINAL phase (prevents visible flips) --- + if (phase === 'final') { + const h1 = document.querySelector('.header-title h1'); + if (h1) { + // prevent i18n or legacy from overwriting it + if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key'); + + if (h1.textContent !== title) h1.textContent = title; + + // lock it so late code can't stomp it + if (!h1.__titleLock) { + const mo = new MutationObserver(() => { + if (h1.textContent !== title) h1.textContent = title; + }); + mo.observe(h1, { childList: true, characterData: true, subtree: true }); + h1.__titleLock = mo; + } + } + } } catch { } } + + async function readyToReveal() { + // Wait for CSS + fonts so the first revealed frame is fully styled + try { await (window.__CSS_PROMISE__ || Promise.resolve()); } catch { } + try { await document.fonts?.ready; } catch { } + // Give layout one paint to settle + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + } + + async function revealAppAndHideOverlay() { + const appRoot = document.getElementById('appRoot'); + const overlay = document.getElementById('loadingOverlay'); + await readyToReveal(); + if (appRoot) appRoot.style.visibility = 'visible'; + if (overlay) { + overlay.style.transition = 'opacity .18s ease-out'; + overlay.style.opacity = '0'; + setTimeout(() => { overlay.style.display = 'none'; }, 220); + } + } + async function loadSiteConfig() { try { const r = await fetch('/api/siteConfig.php', { credentials: 'include' }); - const j = await r.json().catch(() => ({})); applySiteConfig(j); - } catch { applySiteConfig({}); } + const j = await r.json().catch(() => ({})); + window.__FR_SITE_CFG__ = j || {}; + // Early pass: title + login options (skip touching <h1> to avoid flicker) + applySiteConfig(window.__FR_SITE_CFG__, { phase: 'early' }); + return window.__FR_SITE_CFG__; + } catch { + window.__FR_SITE_CFG__ = {}; + applySiteConfig({}, { phase: 'early' }); + return null; + } } async function primeCsrf() { try { @@ -665,7 +731,6 @@ function bindDarkMode() { function forceLoginVisible() { show($('#main')); show($('#loginForm')); - hide($('.main-wrapper')); const hb = $('.header-buttons'); if (hb) hb.style.visibility = 'hidden'; const ov = $('#loadingOverlay'); if (ov) ov.style.display = 'none'; } @@ -809,8 +874,7 @@ function bindDarkMode() { window.__FR_FLAGS.booted = true; ensureToastReady(); // show chrome - const wrap = document.querySelector('.main-wrapper'); if (wrap) { wrap.hidden = false; wrap.classList?.remove('d-none', 'hidden'); wrap.style.display = 'block'; } - const lf = document.getElementById('loginForm'); if (lf) lf.style.display = 'none'; + const hb = document.querySelector('.header-buttons'); if (hb) hb.style.visibility = 'visible'; const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'flex'; @@ -825,6 +889,9 @@ function bindDarkMode() { window.__FR_AUTH_STATE = state; } catch { } + // authed → heavy boot path + document.body.classList.add('authed'); + // 1) i18n (safe) // i18n: honor saved language first, then apply translations try { @@ -840,10 +907,20 @@ function bindDarkMode() { if (!window.__FR_FLAGS.initialized) { if (typeof app.loadCsrfToken === 'function') await app.loadCsrfToken(); if (typeof app.initializeApp === 'function') app.initializeApp(); + const darkBtn = document.getElementById('darkModeToggle'); + if (darkBtn) { + darkBtn.removeAttribute('hidden'); + darkBtn.style.setProperty('display', 'inline-flex', 'important'); // beats any CSS + darkBtn.style.visibility = ''; // just in case + } + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = '/css/vendor/material-icons.css?v={{APP_QVER}}'; + document.head.appendChild(link); + window.__FR_FLAGS.initialized = true; - // Show "Welcome back, <username>!" only once per tab-session try { if (!sessionStorage.getItem('__fr_welcomed')) { const name = (window.__FR_AUTH_STATE?.username) || localStorage.getItem('username') || ''; @@ -864,7 +941,7 @@ function bindDarkMode() { auth.applyProxyBypassUI && auth.applyProxyBypassUI(); auth.updateAuthenticatedUI && auth.updateAuthenticatedUI(state); - // ⬇️ bind ALL the admin / change-password buttons once + // bind ALL the admin / change-password buttons once if (!window.__FR_FLAGS.wired.authInit && typeof auth.initAuth === 'function') { try { auth.initAuth(); } catch (e) { console.warn('[auth] initAuth failed', e); } window.__FR_FLAGS.wired.authInit = true; @@ -913,36 +990,71 @@ function bindDarkMode() { // ---------- entry (no flicker: decide state BEFORE showing login) ---------- document.addEventListener('DOMContentLoaded', async () => { - - if (window.__FR_FLAGS.entryStarted) return; window.__FR_FLAGS.entryStarted = true; + // Always start clean + document.body.classList.remove('authed'); + + const overlay = document.getElementById('loadingOverlay'); + const wrap = document.querySelector('.main-wrapper'); // app shell + const mainEl = document.getElementById('main'); // contains loginForm + const login = document.getElementById('loginForm'); + bindDarkMode(); await loadSiteConfig(); const { authed, setup } = await checkAuth(); - if (setup) { await bootSetupWizard(); return; } - if (authed) { await bootHeavy(); return; } + if (setup) { + // Setup wizard runs inside app shell + unhide(wrap); + hideEl(login); + await bootSetupWizard(); + await revealAppAndHideOverlay(); - // login view - show(document.querySelector('#main')); - show(document.querySelector('#loginForm')); - (document.querySelector('.header-buttons') || {}).style && (document.querySelector('.header-buttons').style.visibility = 'hidden'); - const ov = document.getElementById('loadingOverlay'); if (ov) ov.style.display = 'none'; + return; + } + + if (authed) { + // Authenticated path: show app, hide login + document.body.classList.add('authed'); + unhide(wrap); // works whether CSS or [hidden] was used + hideEl(login); + await bootHeavy(); + await revealAppAndHideOverlay(); + requestAnimationFrame(() => { + const pre = document.getElementById('pretheme-css'); + if (pre) pre.remove(); + }); + return; + } + + // ---- NOT AUTHED: show only the login view ---- + hideEl(wrap); // ensure app shell stays hidden while logged out + unhide(mainEl); + unhide(login); + if (login) login.style.display = ''; + // …wire stuff… + applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' }); + await revealAppAndHideOverlay(); + const hb = document.querySelector('.header-buttons'); + if (hb) hb.style.visibility = 'hidden'; + + // keep app cards inert while logged out (no layout poke) ['uploadCard', 'folderManagementCard'].forEach(id => { const el = document.getElementById(id); if (!el) return; - el.style.display = 'none'; el.setAttribute('aria-hidden', 'true'); try { el.inert = true; } catch { } }); + bindLogin(); wireCreateDropdown(); keepCreateDropdownWired(); wireModalEnterDefault(); showLoginTip('Please log in to continue'); - }, { once: true }); // <— important + if (overlay) overlay.style.display = 'none'; + }, { once: true }); })(); \ No newline at end of file diff --git a/scripts/filerise-deploy.sh b/scripts/filerise-deploy.sh new file mode 100644 index 0000000..8d100c2 --- /dev/null +++ b/scripts/filerise-deploy.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env bash +# FileRise release deployer +# /usr/local/bin/filerise-deploy.sh and chmod +x /usr/local/bin/filerise-deploy.sh +# Usage: +# filerise-deploy.sh [vX.Y.Z|latest] [force] +# Examples: +# filerise-deploy.sh latest +# filerise-deploy.sh v1.7.4 +# filerise-deploy.sh 1.7.4 force + +set -euo pipefail + +REPO="error311/FileRise" +DEST="/var/www" +OWNER="www-data:www-data" # change if your web user differs (e.g., apache:apache) +PHPFPM_SERVICES=(php8.4-fpm php8.3-fpm php8.2-fpm php8.1-fpm php8.0-fpm) + +TAG_INPUT="${1:-latest}" +FORCE="${2:-}" + +EXCLUDES=( + "--exclude=uploads" + "--exclude=uploads/**" + "--exclude=users" + "--exclude=users/**" + "--exclude=metadata" + "--exclude=metadata/**" + "--exclude=vendor" + "--exclude=vendor/**" + "--exclude=config/config.php" + "--exclude=.env" +) + +die() { echo "ERROR: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"; } + +need curl +need unzip +need rsync + +if [[ ! -d "$DEST" ]]; then + die "DEST '$DEST' does not exist. Create it or set DEST variable in script." +fi + +create_dir() { + local sub="$1" + install -d -m 2775 -o "${OWNER%:*}" -g "${OWNER#*:}" "${DEST}/${sub}" + chmod g+s "${DEST}/${sub}" || true +} + +ensure_writable_dirs() { + create_dir uploads + create_dir users + create_dir metadata +} + +normalize_tag() { + local t="$1" + [[ "$t" =~ ^v ]] && echo "$t" || echo "v${t}" +} + +# --- pick release tag --- +if [[ "$TAG_INPUT" == "latest" ]]; then + TAG="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')" || die "Could not resolve latest tag" +else + TAG="$(normalize_tag "$TAG_INPUT")" +fi +[[ -n "$TAG" ]] || die "Empty tag resolved." + +# Skip if already on this version unless 'force' +if [[ -f "${DEST}/.filerise_version" ]] && grep -qx "${TAG}" "${DEST}/.filerise_version" && [[ "$FORCE" != "force" ]]; then + echo "FileRise ${TAG} already installed; nothing to do." + exit 0 +fi + +ensure_writable_dirs + +ZIP_NAME="FileRise-${TAG}.zip" +ZIP_URL="https://github.com/${REPO}/releases/download/${TAG}/${ZIP_NAME}" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +echo "Downloading ${ZIP_URL} …" +curl -fL --retry 3 -o "${WORKDIR}/${ZIP_NAME}" "${ZIP_URL}" || die "Download failed" + +echo "Unzipping…" +unzip -q "${WORKDIR}/${ZIP_NAME}" -d "${WORKDIR}/unz" || die "Unzip failed" + +# Determine source root inside the zip (prefer a directory with public/) +SRC_DIR="" +if [[ -d "${WORKDIR}/unz/public" ]]; then + SRC_DIR="${WORKDIR}/unz" +else + # find first top-level dir that contains public/ + while IFS= read -r -d '' d; do + if [[ -d "$d/public" ]]; then SRC_DIR="$d"; break; fi + done < <(find "${WORKDIR}/unz" -mindepth 1 -maxdepth 1 -type d -print0) + + # fallback: if nothing has public/, use unz root + SRC_DIR="${SRC_DIR:-${WORKDIR}/unz}" +fi + +echo "Using source root: ${SRC_DIR}" +echo " - $(ls -1 ${SRC_DIR} | tr '\n' ' ')" +echo + +# Sync to DEST while preserving data/secret bits +echo "Rsync → ${DEST} …" +rsync -a --delete "${EXCLUDES[@]}" "${SRC_DIR}/" "${DEST}/" + +# Stamp version file +echo "${TAG}" > "${DEST}/.filerise_version" + +# Ensure writable dirs stay correct (even if rsync changed perms on parents) +chown -R "${OWNER}" "${DEST}/uploads" "${DEST}/users" "${DEST}/metadata" +chmod -R u+rwX,g+rwX "${DEST}/uploads" "${DEST}/users" "${DEST}/metadata" +find "${DEST}/uploads" "${DEST}/users" "${DEST}/metadata" -type d -exec chmod g+s {} + || true + +# --- Composer dependencies (install only if needed) --- +install_composer_deps() { + need composer + echo "Installing Composer deps in ${DEST}…" + # optimize + authoritative for prod + sudo -u "${OWNER%:*}" env COMPOSER_HOME="${DEST}" composer install \ + --no-dev --prefer-dist --no-interaction --no-progress \ + --optimize-autoloader --classmap-authoritative \ + -d "${DEST}" + # record lock hash to trigger re-install only when lock changes + if [[ -f "${DEST}/composer.lock" ]]; then + sha256sum "${DEST}/composer.lock" | awk '{print $1}' > "${DEST}/.vendor_lock_hash" + fi +} + +should_install_vendor() { + # no vendor dir → install + [[ ! -d "${DEST}/vendor" ]] && return 0 + # empty vendor → install + [[ -z "$(ls -A "${DEST}/vendor" 2>/dev/null || true)" ]] && return 0 + # if composer.lock exists and hash differs from last install → install + if [[ -f "${DEST}/composer.lock" ]]; then + local cur prev + cur="$(sha256sum "${DEST}/composer.lock" | awk '{print $1}')" + prev="$(cat "${DEST}/.vendor_lock_hash" 2>/dev/null || true)" + [[ "$cur" != "$prev" ]] && return 0 + fi + return 1 +} + +if should_install_vendor; then + install_composer_deps +else + echo "Composer deps already up to date." +fi + +# Reload PHP-FPM (clear opcache) if present +for svc in "${PHPFPM_SERVICES[@]}"; do + if systemctl is-active --quiet "$svc" 2>/dev/null; then + echo "Reloading ${svc} …" + systemctl reload "$svc" || true + break + fi +done + +# Reload Apache if present +if systemctl is-active --quiet apache2 2>/dev/null; then + echo "Reloading apache2 …" + systemctl reload apache2 || true +fi + +# Quick sanity check: DocumentRoot should contain index +if [[ ! -f "${DEST}/public/index.html" && ! -f "${DEST}/public/index.php" ]]; then + echo "WARN: ${DEST}/public/index.(html|php) not found. Verify your release layout & DocumentRoot (${DEST}/public)." +fi + +echo "Deployed FileRise ${TAG} → ${DEST}" \ No newline at end of file