Compare commits

...

50 Commits

Author SHA1 Message Date
Ryan
3dd5a8664a feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48) 2025-10-06 00:10:31 -04:00
Ryan
0cb47b4054 fix(admin): OIDC optional by default; validate only when enabled (fixes #44) 2025-10-05 05:48:25 -04:00
Ryan
e3e3aaa475 chore(scanner): skip profile_pics subtree during scans 2025-10-04 03:35:39 -04:00
Ryan
494be05801 fix(scanner): rebuild per-folder metadata to match File/Folder models 2025-10-04 03:15:55 -04:00
Ryan
ceb651894e fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash 2025-10-04 03:00:15 -04:00
Ryan
ad72ef74d1 Fix: robust PUID/PGID handling; optional ownership normalization (closes #43) 2025-10-04 02:25:16 -04:00
Ryan
680c82638f Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect 2025-10-04 01:55:02 -04:00
Ryan
31f54afc74 Fix: index externally added files on startup; harden start.sh (fixes #46) 2025-10-04 01:13:45 -04:00
Ryan
4f39b3a41e Support clipboard paste image uploads with UI cleanup (closes #40) 2025-05-27 23:48:33 -04:00
Ryan
40cecc10ad support CIFS-mounted uploads and automatic scan on container start (closes #34) 2025-05-27 19:53:00 -04:00
Ryan
aee78c9750 REGEX_FOLDER_NAME updated (closes #39) 2025-05-26 18:14:08 -04:00
Ryan
16ccb66d55 Add folder-strip context menu & combined Create File/Folder dropdown 2025-05-23 08:55:09 -04:00
Ryan
9209f7a582 Center folder strip name, fix file share url, keep fileList wrapping tight (closes #38) 2025-05-22 07:32:38 -04:00
Ryan
4a736b0224 Enable drag-and-drop to folder strip & fix restore toast messaging 2025-05-21 00:54:20 -04:00
Ryan
f162a7d0d7 updateFileActionButtons to hide or show depending on action 2025-05-20 09:55:40 -04:00
Ryan
3fc526df7f Add folder strip and “Create File” functionality (closes #36) 2025-05-19 00:39:10 -04:00
Ryan
20422cf5a7 Drag‐and‐Drop Upload extended to File List 2025-05-15 02:24:26 -04:00
Ryan
492bab36ca Fix duplicated Upload & Folder cards if they were added to header and page was refreshed 2025-05-14 08:08:18 -04:00
Ryan
f2f7697994 Link updated in readme 2025-05-14 07:09:55 -04:00
Ryan
13aa011632 #nosec to silence false positive 2025-05-14 07:05:35 -04:00
Ryan
1add160f5d setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts 2025-05-14 07:00:04 -04:00
Ryan
87368143b5 Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked 2025-05-14 06:51:16 -04:00
Ryan
939aa032f0 ui: polish header and user panel with dropdown + profile pic support & file list adjustments 2025-05-14 05:20:22 -04:00
Ryan
fbd21a035b Ensure /var/www/config exists and is owned by www-data so that start.sh sed -i updates work reliably 2025-05-08 17:20:36 -04:00
Ryan
2f391d11db fix(admin-api): omit clientSecret from getConfig response for security & add OIDC scope. 2025-05-08 11:39:44 -04:00
Ryan
8c70783d5a fix(upload): relax filename validation regex to allow broader Unicode and special chars (closes #29) 2025-05-08 04:58:57 -04:00
Ryan
b4d6f01432 feat(admin): add proxy-only auth bypass and configurable auth header (closes #28) 2025-05-08 04:43:33 -04:00
Ryan
d48b15a5f4 screenshots updated 2025-05-05 08:49:27 -04:00
Ryan
d1726f0160 Refactor auth flow: add loading overlay, separate login, extract initializeApp 2025-05-05 07:28:28 -04:00
Ryan
bd1841b788 Unify modals: shared close button, truncate long filenames, fix sizing & overflow 2025-05-04 15:44:43 -04:00
Ryan
bde35d1d31 Extend clean up expired shared entries 2025-05-04 02:28:33 -04:00
Ryan
8d6a1be777 Fix FolderController readOnly create folder permission 2025-05-04 01:22:43 -04:00
Ryan
56f34ba362 Admin Panel Refactor & Enhancements 2025-05-04 00:23:46 -04:00
Ryan
4d329e046f Remove old controllers 2025-05-04 00:02:38 -04:00
Ryan
f3977153fb Refactor AdminPanel: extract module, add collapsible sections & shared-links management, enforce PascalCase controllers 2025-05-03 23:41:02 -04:00
Ryan
274bedd186 Improve PDF preview and input focus behaviors 2025-04-30 00:53:44 -04:00
Ryan
2e4dbe7f7f support custom expiration durations for file and folder shares (closes #26) 2025-04-28 20:02:11 -04:00
Ryan
0334e443eb fix(shared-folder): sanitize gallery rendering to avoid innerHTML and resolve CodeQL warning (fixes #27) 2025-04-27 18:28:39 -04:00
Ryan
76f5ed5c96 shared-folder: externalize gallery view JS & enforce CSP compliance 2025-04-27 18:18:00 -04:00
Ryan
18f588dc24 Update Manual Installation 2025-04-27 17:23:43 -04:00
Ryan
491c686762 fix: advancedSearchToggle in renderFileTable & renderGalleryView 2025-04-27 17:08:49 -04:00
Ryan
25303df677 fixed: Pagination controls & Items-per-page dropdown 2025-04-27 16:50:22 -04:00
Ryan
ae0d63b86f fix: checkbox in toolbar does not select all files (Fixes #25) 2025-04-27 15:34:41 -04:00
Ryan
41ade2e205 refactor/fix: api redoc 2025-04-26 17:31:51 -04:00
Ryan
0a9d332d60 refactor(auth): relocate logout handler to main.js 2025-04-26 04:33:01 -04:00
Ryan
1983f7705f enhance CSP for iframe and refactor gallery view event handlers 2025-04-26 04:08:56 -04:00
Ryan
6b2bf0ba70 Refactor event binding in domUtils & fileListView 2025-04-26 03:33:23 -04:00
Ryan
6d9715169c Harden security: enable CSP, add SRI, and externalize inline scripts 2025-04-26 02:28:02 -04:00
Ryan
0645a3712a Use Material icons for dark/light toggle and simplify download flows 2025-04-25 20:40:00 -04:00
Ryan
ebc32ea965 consolidate & protect API docs with php wrapper 2025-04-24 19:34:09 -04:00
111 changed files with 6005 additions and 2516 deletions

View File

@@ -1,5 +1,618 @@
# Changelog # Changelog
## Changes 10/6/2025 v1.3.15
feat/perf: large-file handling, faster file list, richer CodeMirror modes (fixes #48)
- fileEditor.js: block ≥10 MB; plain-text fallback >5 MB; lighter CM settings for big files.
- fileListView.js: latest-call-wins; compute editable via ext + sizeBytes (no blink).
- FileModel.php: add sizeBytes; cap inline content to ≤5 MB (INDEX_TEXT_BYTES_MAX).
- HTML: load extra CM modes: htmlmixed, php, clike, python, yaml, markdown, shell, sql, vb, ruby, perl, properties, nginx.
---
## Changes 10/5/2025 v1.3.14
fix(admin): OIDC optional by default; validate only when enabled (fixes #44)
- AdminModel::updateConfig now enforces OIDC fields only if disableOIDCLogin=false
- AdminModel::getConfig defaults disableOIDCLogin=true and guarantees OIDC keys
- AdminController default loginOptions sets disableOIDCLogin=true; CSRF via header or body
- Normalize file perms to 0664 after write
---
## Changes 10/4/2025 v1.3.13
fix(scanner): resolve dirs via CLI/env/constants; write per-item JSON; skip trash
fix(scanner): rebuild per-folder metadata to match File/Folder models
chore(scanner): skip profile_pics subtree during scans
- scan_uploads.php now falls back to UPLOAD_DIR/META_DIR from config.php
- prevents double slashes in metadata paths; respects app timezone
- unblocks SCAN_ON_START so externally added files are indexed at boot
- Writes per-folder metadata files (root_metadata.json / folder_metadata.json) using the same naming rule as the models
- Adds missing entries for files (uploaded, modified using DATE_TIME_FORMAT, uploader=Imported)
- Prunes stale entries for files that no longer exist
- Skips uploads/trash and symlinks
- Resolves paths from CLI flags, env vars, or config constants (UPLOAD_DIR/META_DIR)
- Idempotent; safe to run at startup via SCAN_ON_START
- Avoids indexing internal avatar images (folder already hidden in UI)
- Reduces scan noise and metadata churn; keeps firmware/other content indexed
---
## Changes 10/4/2025 v1.3.12
Fix: robust PUID/PGID handling; optional ownership normalization (closes #43)
- Remap www-data to PUID/PGID when running as root; skip with helpful log if non-root
- Added CHOWN_ON_START env to control recursive chown (default true; turn off after first run)
- SCAN_ON_START unchanged, with non-root fallback
---
## Changes 10/4/2025 v1.3.11
Chore: keep BASE_URL fallback, prefer env SHARE_URL; fix HTTPS auto-detect
- Remove no-op sed of SHARE_URL from start.sh (env already used)
- Build default share link with correct scheme (http/https, proxy-aware)
---
## Changes 10/4/2025 v1.3.10
Fix: index externally added files on startup; harden start.sh (#46)
- Run metadata scan before Apache when SCAN_ON_START=true (was unreachable after exec)
- Execute scan as www-data; continue on failure so startup isnt blocked
- Guard env reads for set -u; add umask 002 for consistent 775/664
- Make ServerName idempotent; avoid duplicate entries
- Ensure sessions/metadata/log dirs exist with correct ownership and perms
No behavior change unless SCAN_ON_START=true.
---
## Changes 5/27/2025 v1.3.9
- Support for mounting CIFS (SMB) network shares via Docker volumes
- New `scripts/scan_uploads.php` script to generate metadata for imported files and folders
- `SCAN_ON_START` environment variable to trigger automatic scanning on container startup
- Documentation for configuring CIFS share mounting and scanning
- Clipboard Paste Upload Support (single image):
- Users can now paste images directly into the FileRise web interface.
- Pasted images are renamed to `image<TIMESTAMP>.png` and added to the upload queue using the existing drag-and-drop logic.
- Implemented using a `.isClipboard` flag and a delayed UI cleanup inside `xhr.addEventListener("load", ...)`.
---
## Changes 5/26/2025
- Updated `REGEX_FOLDER_NAME` in `config.php` to forbids < > : " | ? * characters in folder names.
- Ensures the whole name cant end in a space or period.
- Blocks Windows device names.
- Updated `FolderController.php` when `createFolder` issues invalid folder name to return `http_response_code(400);`
---
## Changes 5/23/2025 v1.3.8
- **Folder-strip context menu**
- Enabled right-click on items in the new folder strip (above file list) to open the same “Create / Rename / Share / Delete Folder” menu as in the main folder tree.
- Bound `contextmenu` event on each `.folder-item` in `loadFileList` to:
- Prevent the default browser menu
- Highlight the clicked folder-strip item
- Invoke `showFolderManagerContextMenu` with menu entries:
- Create Folder
- Rename Folder
- Share Folder (passes the strips `data-folder` value)
- Delete Folder
- Ensured menu actions are wrapped in arrow functions (`() => …`) so they fire only on menu-item click, not on render.
- Refactored folder-strip injection in `fileListView.js` to:
- Mark each strip item as `draggable="true"` (for drag-and-drop)
- Add `el.addEventListener("contextmenu", …)` alongside existing click/drag handlers
- Clean up global click listener for hiding the context menu
- Prevented premature invocation of `openFolderShareModal` by switching to `action: () => openFolderShareModal(dest)` instead of calling it directly.
- **Create File/Folder dropdown**
- Replaced standalone “Create File” button with a combined dropdown button in the actions toolbar.
- New markup
- Wired up JS handlers in `fileActions.js`:
- `#createFileOption``openCreateFileModal()`
- `#createFolderOption``document.getElementById('createFolderModal').style.display = 'block'`
- Toggled `.dropdown-menu` visibility on button click, and closed on outside click.
- Applied dark-mode support: dropdown background and text colors switch with `.dark-mode` class.
---
## Changes 5/22/2025 v1.3.7
- `.folder-strip-container .folder-name` css added to center text below folder material icon.
- Override file share_url to always use current origin
- Update `fileList` css to keep file name wrapping tight.
---
## Changes 5/21/2025
- **Drag & Drop to Folder Strip**
- Enabled dragging files from the file list directly onto the folder-strip items.
- Hooked up `folderDragOverHandler`, `folderDragLeaveHandler`, and `folderDropHandler` to `.folder-strip-container .folder-item`.
- On drop, files are moved via `/api/file/moveFiles.php` and the file list is refreshed.
- **Restore files from trash Toast Message**
- Changed the restore handlers so that the toast always reports the actual file(s) restored (e.g. “Restored file: foo.txt”) instead of “No trash record found.”
- Removed reliance on backend message payload and now generate the confirmation text client-side based on selected items.
---
## Changes 5/20/2025 v1.3.6
- **domUtils.js**
- `updateFileActionButtons`
- Hide selection buttons (`Delete Files`, `Copy Files`, `Move Files` & `Download ZIP`) until file is selected.
- Hide `Extract ZIP` until selecting zip files
- Hide `Create File` button when file list items are selected.
---
## Changes 5/19/2025 v1.3.5
### Added Folder strip & Create File
- **Folder strip in file list**
- `loadFileList` now fetches sub-folders in parallel from `/api/folder/getFolderList.php`.
- Filters to only *direct* children of the current folder, hiding `profile_pics` and `trash`.
- Injects a new `.folder-strip-container` just below the Files In above (summary + slider).
- Clicking a folder in the strip updates:
- the breadcrumb (via `updateBreadcrumbTitle`)
- the tree selection highlight
- reloads `loadFileList` for the chosen folder.
- **Create File feature**
- New “Create New File” button added to the file-actions toolbar and context menu.
- New endpoint `public/api/file/createFile.php` (handled by `FileController`/`FileModel`):
- Creates an empty file if it doesnt already exist.
- Appends an entry to `<folder>_metadata.json` with `uploaded` timestamp and `uploader`.
- `fileActions.js`:
- Implemented `handleCreateFile()` to show a modal, POST to the new endpoint, and refresh the list.
- Added translations for `create_new_file` and `newfile_placeholder`.
---
## Changees 5/15/2025
### DragandDrop Upload extended to File List
- **Forward filelist drops**
Dropping files onto the filelist area (`#fileListContainer`) now redispatches the same `drop` event to the upload cards drop zone (`#uploadDropArea`)
- **Visual feedback**
Added a `.drop-hover` class on `#fileListContainer` during dragover for a dashedborder + lightbackground hover state to indicate it accepts file drops.
---
## Changes 5/14/2025 v1.3.4
### 1. Button Grouping (Bootstrap)
- Converted individual action buttons (`download`, `edit`, `rename`, `share`) in both **table view** and **gallery view** into a single Bootstrap button group for a cleaner, more compact UI.
- Applied `btn-group` and `btn-sm` classes for consistent sizing and spacing.
### 2. Header Dropdown Replacement
- Replaced the standalone “User Panel” icon button with a **dropdown wrapper** (`.user-dropdown`) in the header.
- Dropdown toggle now shows:
- **Profile picture** (if set) or the Material “account_circle” icon
- **Username** text (between avatar and caret)
- Down-arrow caret span.
### 3. Menu Items Moved to Dropdown
- Moved previously standalone header buttons into the dropdown menu:
- **User Panel** opens the modal
- **Admin Panel** only shown when `data.isAdmin` *and* on `demo.filerise.net`
- **API Docs** calls `openApiModal()`
- **Logout** calls `triggerLogout()`
- Each menu item now has a matching Material icon (e.g. `person`, `admin_panel_settings`, `description`, `logout`).
### 4. Profile Picture Support
- Added a new `/api/profile/uploadPicture.php` endpoint + `UserController::uploadPicture()` + corresponding `UserModel::setProfilePicture()`.
- On **Open User Panel**, display:
- Default avatar if none set
- Current profile picture if available
- In the **User Panel** modal:
- Stylish “edit” overlay icon on the avatar to launch file picker
- Auto-upload on file selection (no “Save” button click needed)
- Preview updates immediately and header avatar refreshes live
- Persisted in `users.txt` and re-fetched via `getCurrentUser.php`
### 5. API Docs & Logout Relocation
- Removed API Docs from User Panel
- Removed “Logout” buttons from the header toolbar.
- Both are now menu entries in the **User Dropdown**.
### 6. Admin Panel Conditional
- The **Admin Panel** button was:
- Kept in the dropdown only when `data.isAdmin`
- Removed entirely elsewhere.
### 7. Utility & Styling Tweaks
- Introduced a small `normalizePicUrl()` helper to strip stray colons and ensure a leading slash.
- Hidden the scrollbar in the User Panel modal via:
- Inline CSS (`scrollbar-width: none; -ms-overflow-style: none;`)
- Global/WebKit rule for `::-webkit-scrollbar { display: none; }`
- Made the User Panel modal fully responsive and vertically centered, with smooth dark-mode support.
### 8. File/List View & Gallery View Sliders
- **Unified “ViewMode” Slider**
Added a single slider panel (`#viewSliderContainer`) in the filelist actions toolbar that switches behavior based on the current view mode:
- **Table View**: shows a **Row Height** slider (min 31px, max 60px).
- Adjusts the CSS variable `--file-row-height` to resize all `<tr>` heights.
- Persists the chosen height in `localStorage`.
- **Gallery View**: shows a **Columns** slider (min 1, max 6).
- Updates the grids `grid-template-columns: repeat(N, 1fr)`.
- Persists the chosen column count in `localStorage`.
- **Injection Point**
The slider container is dynamically inserted (or updated) just before the folder summary (`#fileSummary`) in `loadFileList()`, ensuring a consistent position across both view modes.
- **Live Updates**
Moving the slider thumb immediately updates the visible table row heights or gallery column layout without a full rerender.
- **Styling & Alignment**
- `#viewSliderContainer` uses `inline-flex` and `align-items: center` so that label, slider, and value text are vertically aligned with the other toolbar elements.
- Reset margins/padding on the label and value span within `#viewSliderContainer` to eliminate any vertical misalignment.
### 9. Fixed new issues with Undefined username in header on profile pic change & TOTP Enabled not checked
**openUserPanel**
- **Rewritten entirely with DOM APIs** instead of `innerHTML` for any user-supplied text to eliminates “DOM text reinterpreted as HTML” warnings.
- **Default avatar fallback**: now uses `'/assets/default-avatar.png'` whenever `profile_picture` is empty.
- **TOTP checkbox initial state** is now set from the `totp_enabled` value returned by the server.
- **Modal title sync** on reopen now updates the `(username)` correctly (no more “undefined” until refresh).
- **Re-sync on reopen**: background color, avatar, TOTP checkbox and language selector all update when reopen the panel.
**updateAuthenticatedUI**
- **Username fix**: dropdown toggle now always uses `data.username` so the name never becomes `undefined` after uploading a picture.
- **Profile URL update** via `fetchProfilePicture()` always writes into `localStorage` before rebuilding the header, ensuring avatar+name stay in sync instantly.
- **Dropdown rebuild logic** tweaked to update the toggles innerHTML with both avatar and username on every call.
**UserModel::getUser**
- Switched to `explode(':', $line, 4)` to the fourth “profile_picture” field without clobbering the TOTP secret.
- **Strip trailing colons** from the stored URL (`rtrim($parts[3], ':')`) so we never send `…png:` back to the client.
- Returns an array with both `'username'` and `'profile_picture'`, matching what `getCurrentUser.php` needs.
### 10. setAttribute + encodeURI to avoid “DOM text reinterpreted as HTML” alerts
### 11. Fix duplicated Upload & Folder cards if they were added to header and page was refreshed
---
## Changes 5/8/2025
### Docker 🐳
- Ensure `/var/www/config` exists and is owned by `www-data` (chmod 750) so that `start.sh`s `sed -i` updates to `config.php` work reliably
---
## Changes 5/8/2025 v1.3.3
### Enhancements
- **Admin API** (`updateConfig.php`):
- Now merges incoming payload onto existing on-disk settings instead of overwriting blanks.
- Preserves `clientId`, `clientSecret`, `providerUrl` and `redirectUri` when those fields are omitted or empty in the request.
- **Admin API** (`getConfig.php`):
- Returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
- **Frontend** (`auth.js`):
- Update UI based on merged loginOptions from the server, ensuring blank or missing fields no longer revert your existing config.
- **Auth API** (`auth.php`):
- Added `$oidc->addScope(['openid','profile','email']);` to OIDC flow. (This should resolve authentik issue)
---
## Changes 5/8/2025 v1.3.2
### config/config.php
- Added a default `define('AUTH_BYPASS', false)` at the top so the constant always exists.
- Removed the static `AUTH_HEADER` fallback; instead read the adminConfig.json at the end of the file and:
- Overwrote `AUTH_BYPASS` with the `loginOptions.authBypass` setting from disk.
- Defined `AUTH_HEADER` (normalized, e.g. `"X_REMOTE_USER"`) based on `loginOptions.authHeaderName`.
- Inserted a **proxy-only auto-login** block *before* the usual session/auth checks:
If `AUTH_BYPASS` is true and the trusted header (`$_SERVER['HTTP_' . AUTH_HEADER]`) is present, bump the session, mark the user authenticated/admin, load their permissions, and skip straight to JSON output.
- Relax filename validation regex to allow broader Unicode and special chars
### src/controllers/AdminController.php
- Ensured the returned `loginOptions` object always contains:
- `authBypass` (boolean, default false)
- `authHeaderName` (string, default `"X-Remote-User"`)
- Read `authBypass` and `authHeaderName` from the nested `loginOptions` in the request payload.
- Validated them (`authBypass` → bool; `authHeaderName` → non-empty string, fallback to `"X-Remote-User"`).
- Included them when building the `$configUpdate` array to pass to the model.
### src/models/AdminModel.php
- Normalized `loginOptions.authBypass` to a boolean (default false).
- Validated/truncated `loginOptions.authHeaderName` to a non-empty trimmed string (default `"X-Remote-User"`).
- JSON-encoded and encrypted the full config, now including the two new fields.
- After decrypting & decoding, normalized the loaded `loginOptions` to always include:
- `authBypass` (bool)
- `authHeaderName` (string, default `"X-Remote-User"`)
- Left all existing defaults & validations for the original flags intact.
### public/js/adminPanel.js
- **Login Options** section:
- Added a checkbox for **Disable All Built-in Logins (proxy only)** (`authBypass`).
- Added a text input for **Auth Header Name** (`authHeaderName`).
- In `handleSave()`:
- Included the new `authBypass` and `authHeaderName` values in the payload sent to `updateConfig.php`.
- In `openAdminPanel()`:
- Initialized those inputs from `config.loginOptions.authBypass` and `config.loginOptions.authHeaderName`.
### public/js/auth.js
- In `loadAdminConfigFunc()`:
- Stored `authBypass` and `authHeaderName` in `localStorage`.
- In `checkAuthentication()`:
- After a successful login check, called a new helper (`applyProxyBypassUI()`) which reads `localStorage.authBypass` and conditionally hides the entire login form/UI.
- In the “not authenticated” branch, only shows the login form if `authBypass` is false.
- No other core fetch/token logic changed; all existing flows remain intact.
### Security
- **Admin API**: `getConfig.php` now returns only a safe subset of admin settings (omits `clientSecret`) to prevent accidental exposure of sensitive data.
---
## Changes 5/4/2025 v1.3.1
### Modals
- **Added** a shared `.editor-close-btn` component for all modals:
- File Tags
- User Panel
- TOTP Login & Setup
- Change Password
- **Truncated** long filenames in the File Tags modal header using CSS `text-overflow: ellipsis`.
- **Resized** File Tags modal from 400px to 450px wide (with `max-width: 90vw` fallback).
- **Capped** User Panel height at 381px and hidden scrollbars to eliminate layout jumps on hover.
### HTML
- **Moved** `<div id="loginForm">…</div>` out of `.main-wrapper` so the login form can show independently of the app shell.
- **Added** `<div id="loadingOverlay"></div>` immediately inside `<body>` to cover the UI during auth checks.
- **Inserted** inline `<style>` in `<head>` to:
- Hide `.main-wrapper` by default.
- Style `#loadingOverlay` as a full-viewport white overlay.
- **Added** `addUserModal`, `removeUserModal` & `renameFileModal` modals to `style="display:none;"`
### `main.js`
- **Extracted** `initializeApp()` helper to centralize post-auth startup (tag search, file list, drag-and-drop, folder tree, upload, trash/restore, admin config).
- **Updated** DOMContentLoaded `checkAuthentication()` flow to call `initializeApp()` when already authenticated.
- **Extended** `updateAuthenticatedUI()` to call `initializeApp()` after a fresh login so all UI modules re-hydrate.
- **Enhanced** setup-mode in `checkAuthentication()`:
- Show `#addUserModal` as a flex overlay (`style.display = 'flex'`).
- Keep `.main-wrapper` hidden until setup completes.
- **Added** post-setup handler in the Add-User modals save button:
- Hide setup modal.
- Show login form.
- Keep app shell hidden.
- Pre-fill and focus the new username in the login inputs.
### `auth.js` / Auth Logic
- **Refactored** `checkAuthentication()` to handle three states:
1. **`data.setup`** remove overlay, hide main UI, show setup modal.
2. **`data.authenticated`** remove overlay, call `updateAuthenticatedUI()`.
3. **not authenticated** remove overlay, show login form, keep main UI hidden.
- **Refined** `updateAuthenticatedUI()` to:
- Remove loading overlay.
- Show `.main-wrapper` and main operations.
- Hide `#loginForm`.
- Reveal header buttons.
- Initialize dynamic header buttons (restore, admin, user-panel).
- Call `initializeApp()` to load all modules after login.
---
## Changes 5/3/2025 v1.3.0
**Admin Panel Refactor & Enhancements**
### Moved from `authModals.js` to `adminPanel.js`
- Extracted all admin-related UI and logic out of `authModals.js`
- Created a standalone `adminPanel.js` module
- Initialized `openAdminPanel()` and `closeAdminPanel()` exports
### Responsive, Collapsible Sections
- Injected new CSS via JS (`adminPanelStyles`)
- Default modal width: 50%
- Small-screen override (`@media (max-width: 600px)`) to 90% width
- Introduced `.section-header` / `.section-content` pattern
- Click header to expand/collapse its content
- Animated arrow via Material Icons
- Indented and padded expanded content
### “Manage Shared Links” Feature
- Added new **Manage Shared Links** section to Admin Panel
- Endpoint **GET** `/api/admin/readMetadata.php?file=…`
- Reads `share_folder_links.json` & `share_links.json` under `META_DIR`
- Endpoint **POST**
- `/api/folder/deleteShareFolderLink.php`
- `/api/file/deleteShareLink.php`
- `loadShareLinksSection()` AJAX loader
- Displays folder & file shares, expiry dates, upload-allowed, and 🔒 if password-protected
- “🗑️” delete buttons refresh the list on success
### Dark-Mode & Theming Fixes
- Dark-mode CSS overrides for:
- Modal border
- `.btn-primary`, `.btn-secondary`
- `.form-control` backgrounds & placeholders
- Section headers & icons
- Close button restyled to use shared **.editor-close-btn** look
### API and Controller changes
- Updated all endpoints to use correct controller casing
- Renamed controller files to PascalCase (e.g. `adminController.php` to `AdminController.php`, `fileController.php` to `FileController.php`, `folderController.php` to `FolderController.php`)
- Adjusted endpoint paths to match controller filenames
- Fix FolderController readOnly create folder permission
### Additional changes
- Extend clean up expired shared entries
---
## Changes 4/30/2025 v1.2.8
- **Added** PDF preview in `filePreview.js` (the `extension === "pdf"` block): replaced in-modal `<embed>` with `window.open(urlWithTs, "_blank")` and closed the modal to avoid CSP `frame-ancestors 'none'` restrictions.
- **Added** `autofocus` attribute to the login forms username input (`#loginUsername`) so the cursor is ready for typing on page load.
- **Enhanced** login initialization with a `DOMContentLoaded` fallback that calls `loginUsername.focus()` (via `setTimeout`) if needed.
- **Set** focus to the “New Username” field (`#newUsername`) when entering setup mode, hiding the login form and showing the Add-User modal.
- **Implemented** Enter-key support in setup mode by attaching `attachEnterKeyListener("addUserModal", "saveUserBtn")`, allowing users to press Enter to submit the Add-User form.
---
## Changes 4/28/2025
**Added**
- **Custom expiration** option to File Share modal
- Users can specify a value + unit (seconds, minutes, hours, days)
- Displays a warning when a custom duration is selected
- **Custom expiration** option to Folder Share modal (same value+unit picker and warning)
**Changed**
- **API parameters** for both endpoints:
- Replaced `expirationMinutes` with `expirationValue` + `expirationUnit`
- Front-end now sends `{ expirationValue, expirationUnit }`
- Back-end converts those into total seconds before saving
- **UI**
- FileShare and FolderShare modals updated to handle “Custom…” selection
**Updated Models & Controllers**
- **FileModel::createShareLink** now accepts expiration in seconds
- **FolderModel::createShareFolderLink** now accepts expiration in seconds
- **createShareLink.php** & **createShareFolderLink.php** updated to parse and convert new parameters
**Documentation**
- OpenAPI annotations for both endpoints updated to require `expirationValue` + `expirationUnit` (enum: seconds, minutes, hours, days)
## Changes 4/27/2025 v1.2.7
- **Select-All** checkbox now correctly toggles all `.file-checkbox` inputs
- Updated `toggleAllCheckboxes(masterCheckbox)` to call `updateRowHighlight()` on each row so selections get the `.row-selected` highlight
- **Master checkbox sync** in toolbar
- Enhanced `updateFileActionButtons()` to set the header checkbox to checked, unchecked, or indeterminate based on how many files are selected
- Fixed Pagination controls & Items-per-page dropdown
- Fixed `#advancedSearchToggle` in both `renderFileTable()` and `renderGalleryView()`
- **Shared folder gallery view logic**
- Introduced new `public/js/sharedFolderView.js` containing all DOMContentLoaded wiring, `toggleViewMode()`, gallery rendering, and event listeners
- Embedded a non-executing JSON payload in `shareFolder.php`
- **`FolderController::shareFolder()` / `shareFolder.php`**
- Removed all inline `onclick="…"` attributes and inline `<script>` blocks
- Added `<script type="application/json" id="shared-data">…</script>` to export `$token` and `$files`
- Added `<script src="/js/sharedFolderView.js" defer></script>` to load the external view logic
- **Styling updates**
- Added `.toggle-btn` CSS for blue header-style toggle button and applied it in JS
- Added `.pagination a:hover { background-color: #0056b3; }` to match button hover
- Tweaked `body` padding and `header h1` margins to reduce whitespace above header
- Refactored `sharedFolderView.js:renderGalleryView()` to eliminate `innerHTML` usage; now uses `document.createElement` and `textContent` so filenames and URLs are fully escaped and CSP-safe
---
## Changes 4/26/2025 1.2.6
**Apache / Dockerfile (CSP)**
- Enabled Apaches `mod_headers` in the Dockerfile (`a2enmod headers ssl deflate expires proxy proxy_fcgi rewrite`)
- Added a strong `Content-Security-Policy` header in the vhost configs to lock down allowed sources for scripts, styles, fonts, images, and connections
**index.html & CDN Includes**
- Applied Subresource Integrity (`integrity` + `crossorigin="anonymous"`) to all static CDN assets (Bootstrap CSS, CodeMirror CSS/JS, Resumable.js, DOMPurify, Fuse.js)
- Omitted SRI on Google Fonts & Material Icons links (dynamic per-browser CSS)
- Removed all inline `<script>` and `onclick` attributes; now all behaviors live in external JS modules
**auth.js (Logout Handling)**
- Moved the logout-on-`?logout=1` snippet from inline HTML into `auth.js`
- In `DOMContentLoaded`, attached a `click` listener to `#logoutBtn` that POSTs to `/api/auth/logout.php` and reloads
**fileActions.js (Modal Button Handlers)**
- Externalized the cancel/download buttons for single-file and ZIP-download modals by adding `click` listeners in `fileActions.js`
- Removed the inline `onclick` attributes from `#cancelDownloadFile` and `#confirmSingleDownloadButton` in the HTML
- Ensured all file-action modals (delete, download, extract, copy, move, rename) now use JS event handlers instead of inline code
**domUtils.js**
- **Removed** all inline `onclick` and `onchange` attributes from:
- `buildSearchAndPaginationControls` (advanced search toggle, prev/next buttons, items-per-page selector)
- `buildFileTableHeader` (select-all checkbox)
- `buildFileTableRow` (download, edit, preview, rename buttons)
- **Retained** all original logic (file-type icon detection, shift-select, debounce, custom confirm modal, etc.)
**fileListView.js**
- **Stopped** generating inline `onclick` handlers in both table and gallery views.
- **Added** `data-` attributes on actionable elements:
- `data-download-name`, `data-download-folder`
- `data-edit-name`, `data-edit-folder`
- `data-rename-name`, `data-rename-folder`
- `data-preview-url`, `data-preview-name`
- IDs on controls: `#advancedSearchToggle`, `#searchInput`, `#prevPageBtn`, `#nextPageBtn`, `#selectAll`, `#itemsPerPageSelect`
- **Introduced** `attachListControlListeners()` to bind all events via `addEventListener` immediately after rendering, preserving every interaction without inline code.
**Additional changes**
- **Security**: Added `frame-src 'self'` to the Content-Security-Policy header so that the embedded API docs iframe can load from our own origin without relaxing JS restrictions.
- **Controller**: Updated `FolderController::shareFolder()` (folderController) to include the gallery-view toggle script block intact, ensuring the “Switch to Gallery View” button works when sharing folders.
- **UI (fileListView.js)**: Refactored `renderGalleryView` to remove all inline `onclick=` handlers; switched to using data-attributes and `addEventListener()` for preview, download, edit and rename buttons, fully CSP-compliant.
- Moved logout button handler out of inline `<script>` in `index.html` and into the `DOMContentLoaded` init in **main.js** (via `auth.js`), so it now attaches reliably after the CSRF token is loaded and DOM is ready.
- Added Content-Security-Policy for `<Files "api.php">` block to allow embedding the ReDoc iframe.
- Extracted inline ReDoc init into `public/js/redoc-init.js` and updated `public/api.php` to use deferred `<script>` tags.
---
## Changes 4/25/2025
- Switch singlefile download to native `<a>` link (no JS buffering)
- Keep spinner modal during ZIP creation and download blob on POST response
- Replace text toggle with a single button showing sun/moon icons and hover tooltip
## Changes 4/24/2025 1.2.5 ## Changes 4/24/2025 1.2.5
- Enhance README and wiki with expanded installation instructions - Enhance README and wiki with expanded installation instructions
@@ -10,13 +623,27 @@
- Enable `mod_deflate` compression for HTML, plain text, CSS, JS and JSON - Enable `mod_deflate` compression for HTML, plain text, CSS, JS and JSON
- Configure `mod_expires` caching for images (1 month), CSS (1 week) and JS (3 hour) - Configure `mod_expires` caching for images (1 month), CSS (1 week) and JS (3 hour)
- Deny access to hidden files (dot-files) - Deny access to hidden files (dot-files)
- Add access control in public/.htaccess for api.html & openapi.json; update Nginx example in wiki ~~- Add access control in public/.htaccess for api.html & openapi.json; update Nginx example in wiki~~
- Remove obsolete folders from repo root - Remove obsolete folders from repo root
- Embed API documentation (`api.html`) directly in the FileRise UI as a full-screen modal - Embed API documentation (`api.php`) directly in the FileRise UI as a full-screen modal
- Introduced `openApiModalBtn` in the user panel to launch the API modal - Introduced `openApiModalBtn` in the user panel to launch the API modal
- Added `#apiModal` container with a same-origin `<iframe src="api.html">` so session cookies authenticate automatically - Added `#apiModal` container with a same-origin `<iframe src="api.php">` so session cookies authenticate automatically
- Close control uses the existing `.editor-close-btn` for consistent styling and hover effects - Close control uses the existing `.editor-close-btn` for consistent styling and hover effects
- public/api.html has been replaced by the new api.php wrapper
- **`public/api.php`**
- Single PHP endpoint for both UI and spec
- Enforces `$_SESSION['authenticated']`
- Renders the Redoc API docs when accessed normally
- Streams the JSON spec from `openapi.json.dist` when called as `api.php?spec=1`
- Redirects unauthenticated users to `index.html?redirect=/api.php`
- **Moved** `public/openapi.json``openapi.json.dist` (moved outside of `public/`) to prevent direct static access
- **Dockerfile**: enabled required Apache modules for rewrite, security headers, proxying, caching and compression:
```dockerfile
RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate
```
## Changes 4/23/2025 1.2.4 ## Changes 4/23/2025 1.2.4
**AuthModel** **AuthModel**
@@ -136,7 +763,7 @@
Refactored to: Refactored to:
1. Fetch CSRF 1. Fetch CSRF
2. POST credentials to `/api/auth/auth.php` 2. POST credentials to `/api/auth/auth.php`
3. On `totp_required`, refetch CSRF *again* before calling `openTOTPLoginModal()` 3. On `totp_required`, refetch CSRF again before calling `openTOTPLoginModal()`
4. Handle full logins vs. TOTP flows cleanly. 4. Handle full logins vs. TOTP flows cleanly.
- **TOTP handlers update** - **TOTP handlers update**
@@ -1082,7 +1709,7 @@ The enhancements extend the existing drag-and-drop functionality by adding a hea
- Adjusted file preview and icon styling for better alignment. - Adjusted file preview and icon styling for better alignment.
- Centered the header and optimized the layout for a clean, modern appearance. - Centered the header and optimized the layout for a clean, modern appearance.
*This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.* This changelog and feature summary reflect the improvements made during the refactor from a monolithic utils file to modular ES6 components, along with enhancements in UI responsiveness, sorting, file uploads, and file management operations.
--- ---

View File

@@ -51,6 +51,11 @@ COPY custom-php.ini /etc/php/8.3/apache2/conf.d/99-app-tuning.ini
COPY --from=appsource /var/www /var/www COPY --from=appsource /var/www /var/www
COPY --from=composer /app/vendor /var/www/vendor COPY --from=composer /app/vendor /var/www/vendor
# ── ensure config/ is writable by www-data so sed -i can work ──
RUN mkdir -p /var/www/config \
&& chown -R www-data:www-data /var/www/config \
&& chmod 750 /var/www/config
# Secure permissions: code read-only, only data dirs writable # Secure permissions: code read-only, only data dirs writable
RUN chown -R root:www-data /var/www && \ RUN chown -R root:www-data /var/www && \
find /var/www -type d -exec chmod 755 {} \; && \ find /var/www -type d -exec chmod 755 {} \; && \
@@ -78,6 +83,7 @@ RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
Header always set X-Content-Type-Options "nosniff" Header always set X-Content-Type-Options "nosniff"
Header always set X-XSS-Protection "1; mode=block" Header always set X-XSS-Protection "1; mode=block"
Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://stackpath.bootstrapcdn.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self'; frame-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"
</IfModule> </IfModule>
# Compression # Compression
@@ -119,6 +125,10 @@ RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
<FilesMatch "^\."> <FilesMatch "^\.">
Require all denied Require all denied
</FilesMatch> </FilesMatch>
<Files "api.php">
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.redoc.ly; style-src 'self' 'unsafe-inline'; worker-src 'self' https://cdn.redoc.ly blob:; connect-src 'self'; img-src 'self' data: blob:; frame-ancestors 'self'; base-uri 'self'; form-action 'self';"
</Files>
ErrorLog /var/www/metadata/log/error.log ErrorLog /var/www/metadata/log/error.log
CustomLog /var/www/metadata/log/access.log combined CustomLog /var/www/metadata/log/access.log combined
@@ -126,7 +136,7 @@ RUN cat <<'EOF' > /etc/apache2/sites-available/000-default.conf
EOF EOF
# Enable required modules # Enable required modules
RUN a2enmod rewrite headers RUN a2enmod rewrite headers proxy proxy_fcgi expires deflate ssl
EXPOSE 80 443 EXPOSE 80 443
COPY start.sh /usr/local/bin/start.sh COPY start.sh /usr/local/bin/start.sh

130
README.md
View File

@@ -52,38 +52,61 @@ Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (
You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below. You can deploy FileRise either by running the **Docker container** (quickest way) or by a **manual installation** on a PHP web server. Both methods are outlined below.
### 1. Running with Docker (Recommended) ---
If you have Docker installed, you can get FileRise up and running in minutes: ### 1) Running with Docker (Recommended)
- **Pull the image from Docker Hub:** #### Pull the image
``` bash ```bash
docker pull error311/filerise-docker:latest docker pull error311/filerise-docker:latest
``` ```
- **Run a container:** #### Run a container
``` bash ```bash
docker run -d \ docker run -d \
--name filerise \
-p 8080:80 \ -p 8080:80 \
-e TIMEZONE="America/New_York" \ -e TIMEZONE="America/New_York" \
-e DATE_TIME_FORMAT="m/d/y h:iA" \
-e TOTAL_UPLOAD_SIZE="5G" \ -e TOTAL_UPLOAD_SIZE="5G" \
-e SECURE="false" \ -e SECURE="false" \
-e PERSISTENT_TOKENS_KEY="please_change_this_@@" \
-e PUID="1000" \
-e PGID="1000" \
-e CHOWN_ON_START="true" \
-e SCAN_ON_START="true" \
-e SHARE_URL="" \
-v ~/filerise/uploads:/var/www/uploads \ -v ~/filerise/uploads:/var/www/uploads \
-v ~/filerise/users:/var/www/users \ -v ~/filerise/users:/var/www/users \
-v ~/filerise/metadata:/var/www/metadata \ -v ~/filerise/metadata:/var/www/metadata \
--name filerise \
error311/filerise-docker:latest error311/filerise-docker:latest
``` ```
This will start FileRise on port 8080. Visit `http://your-server-ip:8080` to access it. Environment variables shown above are optional for instance, set `SECURE="true"` to enforce HTTPS (assuming you have SSL at proxy level) and adjust `TIMEZONE` as needed. The volume mounts ensure your files and user data persist outside the container. This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
- **Using Docker Compose:** **Notes**
Alternatively, use **docker-compose**. Save the snippet below as docker-compose.yml and run `docker-compose up -d`:
``` yaml - **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
version: '3' - `CHOWN_ON_START=true` is recommended on **first run** to normalize ownership of existing trees. Set to **false** later for faster restarts.
- `SCAN_ON_START=true` runs a one-time index of files added outside the UI so their metadata appears.
- `SHARE_URL` is optional; leave blank to auto-detect from the current host/scheme. You can set it to your site root (e.g., `https://files.example.com`) or directly to the full endpoint.
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
**Verify ownership mapping (optional)**
```bash
docker exec -it filerise id www-data
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
```
#### Using Docker Compose
Save as `docker-compose.yml`, then `docker-compose up -d`:
```yaml
version: "3"
services: services:
filerise: filerise:
image: error311/filerise-docker:latest image: error311/filerise-docker:latest
@@ -91,59 +114,88 @@ services:
- "8080:80" - "8080:80"
environment: environment:
TIMEZONE: "UTC" TIMEZONE: "UTC"
DATE_TIME_FORMAT: "m/d/y h:iA"
TOTAL_UPLOAD_SIZE: "10G" TOTAL_UPLOAD_SIZE: "10G"
SECURE: "false" SECURE: "false"
PERSISTENT_TOKENS_KEY: "please_change_this_@@" PERSISTENT_TOKENS_KEY: "please_change_this_@@"
# Ownership & indexing
PUID: "1000" # Unraid users often use 99
PGID: "1000" # Unraid users often use 100
CHOWN_ON_START: "true" # first run; set to "false" afterwards
SCAN_ON_START: "true" # index files added outside the UI at boot
# Sharing URL (optional): leave blank to auto-detect from host/scheme
SHARE_URL: ""
volumes: volumes:
- ./uploads:/var/www/uploads - ./uploads:/var/www/uploads
- ./users:/var/www/users - ./users:/var/www/users
- ./metadata:/var/www/metadata - ./metadata:/var/www/metadata
``` ```
FileRise will be accessible at `http://localhost:8080` (or your servers IP). The above example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “remember me” tokens) be sure to change it to a random string for security. FileRise will be accessible at `http://localhost:8080` (or your servers IP).
The example also sets a custom `PERSISTENT_TOKENS_KEY` (used to encrypt “Remember Me” tokens)—change it to a strong random string.
**First-time Setup:** On first launch, FileRise will detect no users and prompt you to create an **Admin account**. Choose your admin username & password, and youre in! You can then head to the **User Management** section to add additional users if needed. **First-time Setup**
On first launch, if no users exist, youll be prompted to create an **Admin account**. After logging in, use **User Management** to add more users.
### 2. Manual Installation (PHP/Apache) ---
### 2) Manual Installation (PHP/Apache)
If you prefer to run FileRise on a traditional web server (LAMP stack or similar): If you prefer to run FileRise on a traditional web server (LAMP stack or similar):
- **Requirements:** PHP 8.1 or higher, Apache (with mod_php) or another web server configured for PHP. Ensure PHP extensions json, curl, and zip are enabled. No database needed. **Requirements**
- **Download Files:** Clone this repo or download the [latest release archive](https://github.com/error311/FileRise/releases).
``` bash - PHP **8.3+**
git clone https://github.com/error311/FileRise.git - Apache (mod_php) or another web server configured for PHP
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
**Download Files**
```bash
git clone https://github.com/error311/FileRise.git
``` ```
Place the files into your web servers directory (e.g., `/var/www/public`). It can be in a subfolder (just adjust the `BASE_URL` in config as below). Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
- **Composer Dependencies:** If you plan to use OIDC (SSO login), install Composer and run `composer install` in the FileRise directory. (This pulls in a couple of PHP libraries like jumbojett/openid-connect for OAuth support.) **Composer (if applicable)**
If you use optional features requiring Composer libraries, run:
- **Folder Permissions:** Ensure the server can write to the following directories (create them if they dont exist): ```bash
composer install
```
``` bash **Folders & Permissions**
```bash
mkdir -p uploads users metadata mkdir -p uploads users metadata
chown -R www-data:www-data uploads users metadata # www-data is Apache user; use appropriate user chown -R www-data:www-data uploads users metadata # use your web user
chmod -R 775 uploads users metadata chmod -R 775 uploads users metadata
``` ```
The uploads/ folder is where files go, users/ stores the user credentials file, and metadata/ holds metadata like tags and share links. - `uploads/`: actual files
- `users/`: credentials & token storage
- `metadata/`: file metadata (tags, share links, etc.)
- **Configuration:** Open the `config.php` file in a text editor. You may want to adjust: **Configuration**
- `BASE_URL` the URL where you will access FileRise (e.g., `“https://files.mydomain.com/”`). This is used for generating share links. Open `config.php` and consider:
- `TIMEZONE` and `DATE_TIME_FORMAT` match your locale (for correct timestamps).
- `TOTAL_UPLOAD_SIZE` max aggregate upload size (default 5G). Also adjust PHPs `upload_max_filesize` and `post_max_size` to at least this value (the Docker start script auto-adjusts PHP limits).
- `PERSISTENT_TOKENS_KEY` set a unique secret if you use “Remember Me” logins, to encrypt the tokens.
- Other settings like `UPLOAD_DIR`, `USERS_FILE` etc. generally dont need changes unless you move those folders. Defaults are set for the directories mentioned above.
- **Web Server Config:** If using Apache, ensure `.htaccess` files are allowed or manually add the rules from `.htaccess` to your Apache config these disable directory listings and prevent access to certain files. For Nginx or others, youll need to replicate those protections (see Wiki: [Nginx Setup for examples](https://github.com/error311/FileRise/wiki/Nginx-Setup)). Also enable mod_rewrite if not already, as FileRise may use pretty URLs for share links. - `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
- `TOTAL_UPLOAD_SIZE` (also ensure your PHP `upload_max_filesize` & `post_max_size` meet/exceed this).
- `PERSISTENT_TOKENS_KEY` set to a unique secret if using “Remember Me”.
Now navigate to the FileRise URL in your browser. On first load, youll be prompted to create the Admin user (same as Docker setup). After that, the application is ready to use! **Share links base URL**
- You can set **`SHARE_URL`** via your web server environment variables (preferred),
**or** keep using `BASE_URL` in `config.php` as a fallback for manual installs.
- If neither is set, FileRise auto-detects from the current host/scheme.
**Web Server Config**
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
- Nginx/other: replicate the basic protections (no directory listing, deny sensitive files). See Wiki for examples.
Now browse to your FileRise URL; youll be prompted to create the Admin user on first load.
--- ---
@@ -218,7 +270,7 @@ Areas where you can help: translations, bug fixes, UI improvements, or building
## Community and Support ## Community and Support
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1jl01pi/introducing_filerise_a_modern_selfhosted_file/) (Announcement and user feedback thread). - **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) (Announcement and user feedback thread).
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific support or issues. - **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific support or issues.
- **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements. - **GitHub Discussions:** Use the Q&A category for any setup questions, and the Ideas category to suggest enhancements.

View File

@@ -28,13 +28,14 @@ define('TRASH_DIR', UPLOAD_DIR . 'trash/');
define('TIMEZONE', 'America/New_York'); define('TIMEZONE', 'America/New_York');
define('DATE_TIME_FORMAT','m/d/y h:iA'); define('DATE_TIME_FORMAT','m/d/y h:iA');
define('TOTAL_UPLOAD_SIZE','5G'); define('TOTAL_UPLOAD_SIZE','5G');
define('REGEX_FOLDER_NAME', '/^[\p{L}\p{N}_\-\s\/\\\\]+$/u'); define('REGEX_FOLDER_NAME','/^(?!^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$)(?!.*[. ]$)(?:[^<>:"\/\\\\|?*\x00-\x1F]{1,255})(?:[\/\\\\][^<>:"\/\\\\|?*\x00-\x1F]{1,255})*$/xu');
define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+'); define('PATTERN_FOLDER_NAME','[\p{L}\p{N}_\-\s\/\\\\]+');
define('REGEX_FILE_NAME', '/^[\p{L}\p{N}\p{M}%\-\.\(\) _]+$/u'); define('REGEX_FILE_NAME', '/^[^\x00-\x1F\/\\\\]{1,255}$/u');
define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u'); define('REGEX_USER', '/^[\p{L}\p{N}_\- ]+$/u');
date_default_timezone_set(TIMEZONE); date_default_timezone_set(TIMEZONE);
// Encryption helpers // Encryption helpers
function encryptData($data, $encryptionKey) function encryptData($data, $encryptionKey)
{ {
@@ -114,6 +115,7 @@ if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32)); $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
} }
// Autologin via persistent token // Autologin via persistent token
if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) { if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token'])) {
$tokFile = USERS_DIR . 'persistent_tokens.json'; $tokFile = USERS_DIR . 'persistent_tokens.json';
@@ -140,13 +142,75 @@ if (empty($_SESSION["authenticated"]) && !empty($_COOKIE['remember_me_token']))
} }
} }
// Share URL fallback $adminConfigFile = USERS_DIR . 'adminConfig.json';
// sane defaults:
$cfgAuthBypass = false;
$cfgAuthHeader = 'X_REMOTE_USER';
if (file_exists($adminConfigFile)) {
$encrypted = file_get_contents($adminConfigFile);
$decrypted = decryptData($encrypted, $encryptionKey);
$adminCfg = json_decode($decrypted, true) ?: [];
$loginOpts = $adminCfg['loginOptions'] ?? [];
// proxy-only bypass flag
$cfgAuthBypass = ! empty($loginOpts['authBypass']);
// header name (e.g. “X-Remote-User” → HTTP_X_REMOTE_USER)
$hdr = trim($loginOpts['authHeaderName'] ?? '');
if ($hdr === '') {
$hdr = 'X-Remote-User';
}
// normalize to PHPs $_SERVER key format:
$cfgAuthHeader = 'HTTP_' . strtoupper(str_replace('-', '_', $hdr));
}
define('AUTH_BYPASS', $cfgAuthBypass);
define('AUTH_HEADER', $cfgAuthHeader);
// ─────────────────────────────────────────────────────────────────────────────
// PROXY-ONLY AUTOLOGIN now uses those constants:
if (AUTH_BYPASS) {
$hdrKey = AUTH_HEADER; // e.g. "HTTP_X_REMOTE_USER"
if (!empty($_SERVER[$hdrKey])) {
// regenerate once per session
if (empty($_SESSION['authenticated'])) {
session_regenerate_id(true);
}
$username = $_SERVER[$hdrKey];
$_SESSION['authenticated'] = true;
$_SESSION['username'] = $username;
// ◾ lookup actual role instead of forcing admin
require_once PROJECT_ROOT . '/src/models/AuthModel.php';
$role = AuthModel::getUserRole($username);
$_SESSION['isAdmin'] = ($role === '1');
// carry over any folder/read/upload perms
$perms = loadUserPermissions($username) ?: [];
$_SESSION['folderOnly'] = $perms['folderOnly'] ?? false;
$_SESSION['readOnly'] = $perms['readOnly'] ?? false;
$_SESSION['disableUpload'] = $perms['disableUpload'] ?? false;
}
}
// Share URL fallback (keep BASE_URL behavior)
define('BASE_URL', 'http://yourwebsite/uploads/'); define('BASE_URL', 'http://yourwebsite/uploads/');
// Detect scheme correctly (works behind proxies too)
$proto = $_SERVER['HTTP_X_FORWARDED_PROTO'] ?? (
(!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'
);
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
if (strpos(BASE_URL, 'yourwebsite') !== false) { if (strpos(BASE_URL, 'yourwebsite') !== false) {
$defaultShare = isset($_SERVER['HTTP_HOST']) $defaultShare = "{$proto}://{$host}/api/file/share.php";
? "http://{$_SERVER['HTTP_HOST']}/api/file/share.php"
: "http://localhost/api/file/share.php";
} else { } else {
$defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php"; $defaultShare = rtrim(BASE_URL, '/') . "/api/file/share.php";
} }
// Final: env var wins, else fallback
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare); define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);

View File

@@ -15,10 +15,6 @@ DirectoryIndex index.html
Require all denied Require all denied
</FilesMatch> </FilesMatch>
<FilesMatch "^(api\.html|openapi\.json)$">
Require valid-user
</FilesMatch>
# ----------------------------- # -----------------------------
# Enforce HTTPS (optional) # Enforce HTTPS (optional)
# ----------------------------- # -----------------------------

View File

@@ -1,20 +0,0 @@
<!-- public/api.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js" integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX" crossorigin="anonymous"></script>
</head>
<body>
<redoc spec-url="openapi.json"></redoc>
<div id="redoc-container"></div>
<script>
// If the <redoc> tag didnt render, fall back to init()
if (!customElements.get('redoc')) {
Redoc.init('openapi.json', {}, document.getElementById('redoc-container'));
}
</script>
</body>
</html>

31
public/api.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
// public/api.php
require_once __DIR__ . '/../config/config.php';
if (empty($_SESSION['authenticated'])) {
header('Location: /index.html?redirect=/api.php');
exit;
}
if (isset($_GET['spec'])) {
header('Content-Type: application/json');
readfile(__DIR__ . '/../openapi.json.dist');
exit;
}
?><!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>FileRise API Docs</title>
<script defer src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
integrity="sha384-4vOjrBu7SuDWXcAw1qFznVLA/sKL+0l4nn+J1HY8w7cpa6twQEYuh4b0Cwuo7CyX"
crossorigin="anonymous"></script>
<script defer src="/js/redoc-init.js"></script>
</head>
<body>
<redoc spec-url="api.php?spec=1"></redoc>
<div id="redoc-container"></div>
</body>
</html>

View File

@@ -2,7 +2,7 @@
// public/api/addUser.php // public/api/addUser.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->addUser(); $userController->addUser();

View File

@@ -2,7 +2,7 @@
// public/api/admin/getConfig.php // public/api/admin/getConfig.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/adminController.php'; require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
$adminController = new AdminController(); $adminController = new AdminController();
$adminController->getConfig(); $adminController->getConfig();

View File

@@ -0,0 +1,63 @@
<?php
// public/api/admin/readMetadata.php
require_once __DIR__ . '/../../../config/config.php';
// Only admins may read these
if (empty($_SESSION['isAdmin']) || $_SESSION['isAdmin'] !== true) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden']);
exit;
}
// Must supply ?file=share_links.json or share_folder_links.json
if (empty($_GET['file'])) {
http_response_code(400);
echo json_encode(['error' => 'Missing `file` parameter']);
exit;
}
$file = basename($_GET['file']);
$allowed = ['share_links.json', 'share_folder_links.json'];
if (!in_array($file, $allowed, true)) {
http_response_code(403);
echo json_encode(['error' => 'Invalid file requested']);
exit;
}
$path = META_DIR . $file;
if (!file_exists($path)) {
// Return empty object so JS sees `{}` not an error
http_response_code(200);
header('Content-Type: application/json');
echo json_encode((object)[]);
exit;
}
$jsonData = file_get_contents($path);
$data = json_decode($jsonData, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) {
http_response_code(500);
echo json_encode(['error' => 'Corrupted JSON']);
exit;
}
// ——— Clean up expired entries ———
$now = time();
$changed = false;
foreach ($data as $token => $entry) {
if (!empty($entry['expires']) && $entry['expires'] < $now) {
unset($data[$token]);
$changed = true;
}
}
if ($changed) {
// overwrite file with cleaned data
file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT));
}
// ——— Send cleaned data back ———
http_response_code(200);
header('Content-Type: application/json');
echo json_encode($data);
exit;

View File

@@ -2,7 +2,7 @@
// public/api/admin/updateConfig.php // public/api/admin/updateConfig.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/adminController.php'; require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
$adminController = new AdminController(); $adminController = new AdminController();
$adminController->updateConfig(); $adminController->updateConfig();

View File

@@ -3,7 +3,7 @@
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php'; require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php'; require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
$authController = new AuthController(); $authController = new AuthController();
$authController->auth(); $authController->auth();

View File

@@ -2,7 +2,7 @@
// public/api/auth/checkAuth.php // public/api/auth/checkAuth.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php'; require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
$authController = new AuthController(); $authController = new AuthController();
$authController->checkAuth(); $authController->checkAuth();

View File

@@ -2,7 +2,7 @@
// public/api/auth/login_basic.php // public/api/auth/login_basic.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php'; require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
$authController = new AuthController(); $authController = new AuthController();
$authController->loginBasic(); $authController->loginBasic();

View File

@@ -2,7 +2,7 @@
// public/api/auth/logout.php // public/api/auth/logout.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php'; require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
$authController = new AuthController(); $authController = new AuthController();
$authController->logout(); $authController->logout();

View File

@@ -2,7 +2,7 @@
// public/api/auth/token.php // public/api/auth/token.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/authController.php'; require_once PROJECT_ROOT . '/src/controllers/AuthController.php';
$authController = new AuthController(); $authController = new AuthController();
$authController->getToken(); $authController->getToken();

View File

@@ -2,7 +2,7 @@
// public/api/changePassword.php // public/api/changePassword.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->changePassword(); $userController->changePassword();

View File

@@ -2,7 +2,7 @@
// public/api/file/copyFiles.php // public/api/file/copyFiles.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->copyFiles(); $fileController->copyFiles();

View File

@@ -0,0 +1,15 @@
<?php
// public/api/file/createFile.php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['success'=>false,'error'=>'Unauthorized']);
exit;
}
$fc = new FileController();
$fc->createFile();

View File

@@ -2,7 +2,7 @@
// public/api/file/createShareLink.php // public/api/file/createShareLink.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->createShareLink(); $fileController->createShareLink();

View File

@@ -2,7 +2,7 @@
// public/api/file/deleteFiles.php // public/api/file/deleteFiles.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->deleteFiles(); $fileController->deleteFiles();

View File

@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController();
$fileController->deleteShareLink();

View File

@@ -2,7 +2,7 @@
// public/api/file/deleteTrashFiles.php // public/api/file/deleteTrashFiles.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->deleteTrashFiles(); $fileController->deleteTrashFiles();

View File

@@ -2,7 +2,7 @@
// public/api/file/download.php // public/api/file/download.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->downloadFile(); $fileController->downloadFile();

View File

@@ -2,7 +2,7 @@
// public/api/file/downloadZip.php // public/api/file/downloadZip.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->downloadZip(); $fileController->downloadZip();

View File

@@ -2,7 +2,7 @@
// public/api/file/extractZip.php // public/api/file/extractZip.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->extractZip(); $fileController->extractZip();

View File

@@ -2,7 +2,7 @@
// public/api/file/getFileList.php // public/api/file/getFileList.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->getFileList(); $fileController->getFileList();

View File

@@ -2,7 +2,7 @@
// public/api/file/getFileTag.php // public/api/file/getFileTag.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->getFileTags(); $fileController->getFileTags();

View File

@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController();
$fileController->getShareLinks();

View File

@@ -2,7 +2,7 @@
// public/api/file/getTrashItems.php // public/api/file/getTrashItems.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->getTrashItems(); $fileController->getTrashItems();

View File

@@ -2,7 +2,7 @@
// public/api/file/moveFiles.php // public/api/file/moveFiles.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->moveFiles(); $fileController->moveFiles();

View File

@@ -2,7 +2,7 @@
// public/api/file/renameFile.php // public/api/file/renameFile.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->renameFile(); $fileController->renameFile();

View File

@@ -2,7 +2,7 @@
// public/api/file/restoreFiles.php // public/api/file/restoreFiles.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->restoreFiles(); $fileController->restoreFiles();

View File

@@ -2,7 +2,7 @@
// public/api/file/saveFile.php // public/api/file/saveFile.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->saveFile(); $fileController->saveFile();

View File

@@ -2,7 +2,7 @@
// public/api/file/saveFileTag.php // public/api/file/saveFileTag.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->saveFileTag(); $fileController->saveFileTag();

View File

@@ -2,7 +2,7 @@
// public/api/file/share.php // public/api/file/share.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/fileController.php'; require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$fileController = new FileController(); $fileController = new FileController();
$fileController->shareFile(); $fileController->shareFile();

View File

@@ -1,2 +0,0 @@
cd /var/www/public
ln -s ../uploads uploads

View File

@@ -2,7 +2,7 @@
// public/api/folder/createFolder.php // public/api/folder/createFolder.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->createFolder(); $folderController->createFolder();

View File

@@ -2,7 +2,7 @@
// public/api/folder/createShareFolderLink.php // public/api/folder/createShareFolderLink.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->createShareFolderLink(); $folderController->createShareFolderLink();

View File

@@ -2,7 +2,7 @@
// public/api/folder/deleteFolder.php // public/api/folder/deleteFolder.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->deleteFolder(); $folderController->deleteFolder();

View File

@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController();
$folderController->deleteShareFolderLink();

View File

@@ -2,7 +2,7 @@
// public/api/folder/downloadSharedFile.php // public/api/folder/downloadSharedFile.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->downloadSharedFile(); $folderController->downloadSharedFile();

View File

@@ -2,7 +2,7 @@
// public/api/folder/getFolderList.php // public/api/folder/getFolderList.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->getFolderList(); $folderController->getFolderList();

View File

@@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController();
$folderController->getShareFolderLinks();

View File

@@ -2,7 +2,7 @@
// public/api/folder/renameFolder.php // public/api/folder/renameFolder.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->renameFolder(); $folderController->renameFolder();

View File

@@ -2,7 +2,7 @@
// public/api/folder/shareFolder.php // public/api/folder/shareFolder.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->shareFolder(); $folderController->shareFolder();

View File

@@ -2,7 +2,7 @@
// public/api/folder/uploadToSharedFolder.php // public/api/folder/uploadToSharedFolder.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/folderController.php'; require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
$folderController = new FolderController(); $folderController = new FolderController();
$folderController->uploadToSharedFolder(); $folderController->uploadToSharedFolder();

View File

@@ -2,7 +2,7 @@
// public/api/getUserPermissions.php // public/api/getUserPermissions.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->getUserPermissions(); $userController->getUserPermissions();

View File

@@ -2,7 +2,7 @@
// public/api/getUsers.php // public/api/getUsers.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->getUsers(); // This will output the JSON response $userController->getUsers(); // This will output the JSON response

View File

@@ -0,0 +1,15 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
header('Content-Type: application/json');
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error'=>'Unauthorized']);
exit;
}
$user = $_SESSION['username'];
$data = UserModel::getUser($user);
echo json_encode($data);

View File

@@ -0,0 +1,17 @@
<?php
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/UserController.php';
// Always JSON, even on PHP notices
header('Content-Type: application/json');
try {
$userController = new UserController();
$userController->uploadPicture();
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => 'Exception: ' . $e->getMessage()
]);
}

View File

@@ -2,7 +2,7 @@
// public/api/removeUser.php // public/api/removeUser.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->removeUser(); $userController->removeUser();

View File

@@ -3,7 +3,7 @@
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php'; require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->disableTOTP(); $userController->disableTOTP();

View File

@@ -2,7 +2,7 @@
// public/api/totp_recover.php // public/api/totp_recover.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->recoverTOTP(); $userController->recoverTOTP();

View File

@@ -2,7 +2,7 @@
// public/api/totp_saveCode.php // public/api/totp_saveCode.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->saveTOTPRecoveryCode(); $userController->saveTOTPRecoveryCode();

View File

@@ -3,7 +3,7 @@
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php'; require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->setupTOTP(); $userController->setupTOTP();

View File

@@ -3,7 +3,7 @@
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/vendor/autoload.php'; require_once PROJECT_ROOT . '/vendor/autoload.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->verifyTOTP(); $userController->verifyTOTP();

View File

@@ -2,7 +2,7 @@
// public/api/updateUserPanel.php // public/api/updateUserPanel.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->updateUserPanel(); $userController->updateUserPanel();

View File

@@ -2,7 +2,7 @@
// public/api/updateUserPermissions.php // public/api/updateUserPermissions.php
require_once __DIR__ . '/../../config/config.php'; require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/userController.php'; require_once PROJECT_ROOT . '/src/controllers/UserController.php';
$userController = new UserController(); $userController = new UserController();
$userController->updateUserPermissions(); $userController->updateUserPermissions();

View File

@@ -2,7 +2,7 @@
// public/api/upload/removeChunks.php // public/api/upload/removeChunks.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/uploadController.php'; require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
$uploadController = new UploadController(); $uploadController = new UploadController();
$uploadController->removeChunks(); $uploadController->removeChunks();

View File

@@ -1,7 +1,7 @@
<?php <?php
// public/api/upload/upload.php // public/api/upload/upload.php
require_once __DIR__ . '/../../../config/config.php'; require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/uploadController.php'; require_once PROJECT_ROOT . '/src/controllers/UploadController.php';
$uploadController = new UploadController(); $uploadController = new UploadController();
$uploadController->handleUpload(); $uploadController->handleUpload();

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -80,6 +80,9 @@ body.dark-mode .header-container {
background-color: #1f1f1f; background-color: #1f1f1f;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
} }
#darkModeIcon {
color: #fff;
}
.header-logo { .header-logo {
max-height: 50px; max-height: 50px;
@@ -131,17 +134,27 @@ body.dark-mode header {
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 9px;
border-radius: 50%;
color: #fff; color: #fff;
transition: background-color 0.2s ease, box-shadow 0.2s ease; transition: background-color 0.2s ease, box-shadow 0.2s ease;
} }
.header-buttons button:not(#userDropdownToggle) {
border-radius: 50%;
padding: 9px;
}
#userDropdownToggle {
border-radius: 4px !important;
padding: 6px 10px !important;
}
.header-buttons button:hover { .header-buttons button:hover {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
color: #fff;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
header { header {
flex-direction: column; flex-direction: column;
@@ -835,6 +848,27 @@ body:not(.dark-mode) .material-icons.pauseResumeBtn:hover {
background-color: #00796B; background-color: #00796B;
} }
#createBtn {
background-color: #007bff;
color: white;
}
body.dark-mode .dropdown-menu {
background-color: #2c2c2c !important;
border-color: #444 !important;
color: #e0e0e0!important;
}
body.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 {
background-color: rgba(255,255,255,0.1);
}
#fileList button.edit-btn { #fileList button.edit-btn {
background-color: #007bff; background-color: #007bff;
color: white; color: white;
@@ -952,6 +986,29 @@ body.dark-mode #fileList table tr {
padding: 8px 10px !important; padding: 8px 10px !important;
} }
:root {
--file-row-height: 48px;
}
#fileList table.table tbody tr {
height: auto !important;
min-height: var(--file-row-height) !important;
}
#fileList table.table tbody td:not(.file-name-cell) {
height: var(--file-row-height) !important;
line-height: var(--file-row-height) !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
vertical-align: middle;
}
#fileList table.table tbody td.file-name-cell {
white-space: normal;
word-break: break-word;
line-height: 1.2em !important;
height: auto !important;
}
/* =========================================================== /* ===========================================================
HEADINGS & FORM LABELS HEADINGS & FORM LABELS
@@ -1325,26 +1382,6 @@ body.dark-mode .image-preview-modal-content {
border-color: #444; border-color: #444;
} }
.preview-btn,
.download-btn,
.rename-btn,
.share-btn,
.edit-btn {
display: flex;
align-items: center;
padding: 8px 12px;
justify-content: center;
}
.share-btn {
border: none;
color: white;
padding: 8px 12px;
cursor: pointer;
margin-left: 0px;
transition: background 0.3s;
}
.image-modal-img { .image-modal-img {
max-width: 100%; max-width: 100%;
max-height: 80vh; max-height: 80vh;
@@ -2099,13 +2136,23 @@ body.dark-mode .header-drop-zone.drag-active {
color: black; color: black;
} }
@media only screen and (max-width: 600px) { @media only screen and (max-width: 600px) {
#fileSummary { #fileSummary,
float: none !important; #rowHeightSliderContainer,
margin: 0 auto !important; #viewSliderContainer {
text-align: center !important; float: none !important;
margin: 0 auto !important;
text-align: center !important;
display: block !important;
} }
} }
#viewSliderContainer label,
#viewSliderContainer span {
line-height: 1;
margin: 0;
padding: 0;
}
body.dark-mode #fileSummary { body.dark-mode #fileSummary {
color: white; color: white;
} }
@@ -2162,4 +2209,100 @@ body.dark-mode #searchIcon .material-icons {
body.dark-mode .btn-icon:hover, body.dark-mode .btn-icon:hover,
body.dark-mode .btn-icon:focus { body.dark-mode .btn-icon:focus {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
}
.user-dropdown {
position: relative;
display: inline-block;
}
.user-dropdown .user-menu {
display: none;
position: absolute;
right: 0;
margin-top: 0.25rem;
background: var(--bs-body-bg, #fff);
border: 1px solid #ccc;
border-radius: 4px;
min-width: 150px;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
z-index: 1000;
}
.user-dropdown .user-menu.show {
display: block;
}
.user-dropdown .user-menu .item {
padding: 0.5rem 0.75rem;
cursor: pointer;
white-space: nowrap;
}
.user-dropdown .user-menu .item:hover {
background: #f5f5f5;
}
.user-dropdown .dropdown-caret {
border-top: 5px solid currentColor;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
display: inline-block;
vertical-align: middle;
margin-left: 0.25rem;
}
body.dark-mode .user-dropdown .user-menu {
background: #2c2c2c;
border-color: #444;
}
body.dark-mode .user-dropdown .user-menu .item {
color: #e0e0e0;
}
body.dark-mode .user-dropdown .user-menu .item:hover {
background: rgba(255,255,255,0.1);
}
.user-dropdown .dropdown-username {
margin: 0 8px;
font-weight: 500;
vertical-align: middle;
white-space: nowrap;
}
.folder-strip-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 8px 0;
}
.folder-strip-container .folder-item {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
width: 80px;
color: inherit;
font-size: 0.85em;
}
.folder-strip-container .folder-item i.material-icons {
font-size: 28px;
margin-bottom: 4px;
}
.folder-strip-container .folder-name {
text-align: center;
white-space: normal;
word-break: break-word;
max-width: 80px;
margin-top: 4px;
}
.folder-strip-container .folder-item i.material-icons {
color: currentColor;
}
.folder-strip-container .folder-item:hover {
background-color: rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
} }

View File

@@ -5,24 +5,40 @@
<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 data-i18n-key="title">FileRise</title> <title data-i18n-key="title">FileRise</title>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('logout') === '1') {
localStorage.removeItem("username");
localStorage.removeItem("userTOTPEnabled");
}
</script>
<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">
<meta name="csrf-token" content=""> <meta name="csrf-token" content="">
<meta name="share-url" content=""> <meta name="share-url" content="">
<style>
/* hide the app shell until JS says otherwise */
.main-wrapper {
display: none;
}
/* full-screen white overlay while we check auth */
#loadingOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-color, #fff);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
</style>
<!-- Google Fonts and Material Icons --> <!-- Google Fonts and Material Icons -->
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet" /> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css" /> integrity="sha384-JcKb8q3iqJ61gNV9KGb8thSsNjpSL0n8PARn9HuZOnIxN0hoP+VmmDGMN5t9UJ0Z" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.css"
integrity="sha384-zaeBlB/vwYsDRSlFajnDd7OydJ0cWk+c2OWybl3eSUf6hW2EbhlCsQPqKr3gkznT" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/theme/material-darker.min.css"
integrity="sha384-eZTPTN0EvJdn23s24UDYJmUM2T7C2ZFa3qFLypeBruJv8mZeTusKUAO/j5zPAQ6l" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/codemirror.min.js"
integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma" integrity="sha384-UXbkZAbZYZ/KCAslc6UO4d6UHNKsOxZ/sqROSQaPTZCuEIKhfbhmffQ64uXFOcma"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
@@ -41,9 +57,9 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js" <script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.4.0/purify.min.js"
integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E" integrity="sha384-Tsl3d5pUAO7a13enIvSsL3O0/95nsthPJiPto5NtLuY8w3+LbZOpr3Fl2MNmrh1E"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js" <script src="https://cdn.jsdelivr.net/npm/fuse.js@6.6.2/dist/fuse.min.js"
integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa" integrity="sha384-zPE55eyESN+FxCWGEnlNxGyAPJud6IZ6TtJmXb56OFRGhxZPN4akj9rjA3gw5Qqa"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<link rel="stylesheet" href="css/styles.css" /> <link rel="stylesheet" href="css/styles.css" />
</head> </head>
@@ -78,16 +94,16 @@
stroke: white; stroke: white;
stroke-width: 2; stroke-width: 2;
} }
.divider { .divider {
stroke: #1565C0; stroke: #1565C0;
stroke-width: 1.5; stroke-width: 1.5;
} }
.drawer { .drawer {
fill: #FFFFFF; fill: #FFFFFF;
} }
.handle { .handle {
fill: #1565C0; fill: #1565C0;
} }
@@ -124,9 +140,6 @@
<!-- Your header drop zone --> <!-- Your header drop zone -->
<div id="headerDropArea" class="header-drop-zone"></div> <div id="headerDropArea" class="header-drop-zone"></div>
<div class="header-buttons"> <div class="header-buttons">
<button id="logoutBtn" data-i18n-title="logout">
<i class="material-icons">exit_to_app</i>
</button>
<button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;"> <button id="changePasswordBtn" data-i18n-title="change_password" style="display: none;">
<i class="material-icons">vpn_key</i> <i class="material-icons">vpn_key</i>
</button> </button>
@@ -159,16 +172,52 @@
<button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;"> <button id="removeUserBtn" data-i18n-title="remove_user" style="display: none;">
<i class="material-icons">person_remove</i> <i class="material-icons">person_remove</i>
</button> </button>
<button id="darkModeToggle" class="dark-mode-toggle" data-i18n-key="dark_mode_toggle">Dark Mode</button> <button id="darkModeToggle" class="btn-icon" aria-label="Toggle dark mode">
<span class="material-icons" id="darkModeIcon">
dark_mode
</span>
</button>
</div> </div>
</div> </div>
</div> </div>
</header> </header>
<div id="loadingOverlay"></div>
<!-- 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>
<div class="row mt-4" id="loginForm">
<div class="col-12">
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label>
<input type="text" class="form-control" id="loginUsername" name="username" required autofocus />
</div>
<div class="form-group">
<label for="loginPassword" data-i18n-key="password">Password:</label>
<input type="password" class="form-control" id="loginPassword" name="password" required />
</div>
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
<div class="form-group remember-me-container">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
</div>
</form>
<!-- OIDC Login Option -->
<div class="text-center mt-3">
<button id="oidcLoginBtn" class="btn btn-secondary" data-i18n-key="login_oidc">Login with OIDC</button>
</div>
<!-- Basic HTTP Login Option -->
<div class="text-center mt-3">
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic
HTTP
Login</a>
</div>
</div>
</div>
<!-- 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">
<!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) --> <!-- Sidebar Drop Zone: Hidden until you drag a card (display controlled by JS) -->
@@ -176,36 +225,6 @@
<!-- Main Column --> <!-- Main Column -->
<div id="mainColumn" class="main-column"> <div id="mainColumn" class="main-column">
<div class="container-fluid"> <div class="container-fluid">
<!-- Login Form (unchanged) -->
<div class="row" id="loginForm">
<div class="col-12">
<form id="authForm" method="post">
<div class="form-group">
<label for="loginUsername" data-i18n-key="user">User:</label>
<input type="text" class="form-control" id="loginUsername" name="username" required />
</div>
<div class="form-group">
<label for="loginPassword" data-i18n-key="password">Password:</label>
<input type="password" class="form-control" id="loginPassword" name="password" required />
</div>
<button type="submit" class="btn btn-primary btn-block btn-login" data-i18n-key="login">Login</button>
<div class="form-group remember-me-container">
<input type="checkbox" id="rememberMeCheckbox" name="remember_me" />
<label for="rememberMeCheckbox" data-i18n-key="remember_me">Remember me</label>
</div>
</form>
<!-- OIDC Login Option -->
<div class="text-center mt-3">
<button id="oidcLoginBtn" class="btn btn-secondary" data-i18n-key="login_oidc">Login with OIDC</button>
</div>
<!-- Basic HTTP Login Option -->
<div class="text-center mt-3">
<a href="/api/auth/login_basic.php" class="btn btn-secondary" data-i18n-key="basic_http_login">Use Basic HTTP
Login</a>
</div>
</div>
</div>
<!-- Main Operations: Upload and Folder Management --> <!-- Main Operations: Upload and Folder Management -->
<div id="mainOperations"> <div id="mainOperations">
<div class="container" style="max-width: 1400px; margin: 0 auto;"> <div class="container" style="max-width: 1400px; margin: 0 auto;">
@@ -284,10 +303,10 @@
</div> </div>
</div> </div>
</div> </div>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder"> <button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i> <i class="material-icons">share</i>
</button> </button>
<button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder"> <button id="deleteFolderBtn" class="btn btn-danger ml-2" data-i18n-title="delete_folder">
<i class="material-icons">delete</i> <i class="material-icons">delete</i>
</button> </button>
@@ -370,8 +389,55 @@
</div> </div>
<button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled <button id="downloadZipBtn" class="btn action-btn" style="display: none;" disabled
data-i18n-key="download_zip">Download ZIP</button> data-i18n-key="download_zip">Download ZIP</button>
<button id="extractZipBtn" class="btn btn-sm btn-info" data-i18n-title="extract_zip" <button id="extractZipBtn" class="btn action-btn btn-sm btn-info" data-i18n-title="extract_zip"
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;">
<button id="createBtn" class="btn action-btn" data-i18n-key="create">
${t('create')} <span class="material-icons" style="font-size:16px;vertical-align:middle;">arrow_drop_down</span>
</button>
<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: 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 -->
<div id="createFileModal" class="modal" style="display:none;">
<div class="modal-content">
<h4 data-i18n-key="create_new_file">Create New File</h4>
<input
type="text"
id="createFileNameInput"
class="form-control"
placeholder="Enter filename…"
data-i18n-placeholder="newfile_placeholder"
/>
<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="confirmCreateFile" class="btn btn-primary" data-i18n-key="create">Create</button>
</div>
</div>
</div>
<div id="downloadZipModal" class="modal" style="display:none;"> <div id="downloadZipModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4> <h4 data-i18n-key="download_zip_title">Download Selected Files as Zip</h4>
@@ -391,36 +457,42 @@
</div> <!-- end mainColumn --> </div> <!-- end mainColumn -->
</div> <!-- end main-wrapper --> </div> <!-- end main-wrapper -->
<!-- Download Progress Modal --> <!-- Download Progress Modal -->
<div id="downloadProgressModal" class="modal" style="display: none;"> <div id="downloadProgressModal" class="modal" style="display: none;">
<div class="modal-content" style="text-align: center; padding: 20px;"> <div class="modal-content" style="text-align: center; padding: 20px;">
<!-- Material icon spinner with a dedicated class --> <h4 id="downloadProgressTitle" data-i18n-key="preparing_download">
<span class="material-icons download-spinner">autorenew</span> Preparing your download...
<p data-i18n-key="preparing_download">Preparing your download...</p> </h4>
</div>
</div>
<!-- Single File Download Modal --> <!-- spinner -->
<div id="downloadFileModal" class="modal" style="display: none;"> <span class="material-icons download-spinner">autorenew</span>
<div class="modal-content" style="text-align: center; padding: 20px;">
<h4 data-i18n-key="download_file">Download File</h4> <!-- these were missing -->
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p> <progress id="downloadProgressBar" value="0" max="100" style="width:100%; height:1.5em; display:none;"></progress>
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename" placeholder="Filename" /> <p>
<div style="margin-top: 15px; text-align: right;"> <span id="downloadProgressPercent" style="display:none;">0%</span>
<button id="cancelDownloadFile" class="btn btn-secondary" </p>
onclick="document.getElementById('downloadFileModal').style.display = 'none';" </div>
data-i18n-key="cancel">Cancel</button> </div>
<button id="confirmSingleDownloadButton" class="btn btn-primary"
onclick="confirmSingleDownload()" <!-- Single File Download Modal -->
data-i18n-key="download">Download</button> <div id="downloadFileModal" class="modal" style="display: none;">
<div class="modal-content" style="text-align: center; padding: 20px;">
<h4 data-i18n-key="download_file">Download File</h4>
<p data-i18n-key="confirm_or_change_filename">Confirm or change the download file name:</p>
<input type="text" id="downloadFileNameInput" class="form-control" data-i18n-placeholder="filename"
placeholder="Filename" />
<div style="margin-top: 15px; text-align: right;">
<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>
</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="max-width:400px; margin:auto;">
<span id="closeChangePasswordModal" style="position:absolute; top:10px; right:10px; cursor:pointer; font-size:24px;">&times;</span> <span id="closeChangePasswordModal" class="editor-close-btn">&times;</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"
placeholder="Old Password" style="width:100%; margin: 5px 0;" /> placeholder="Old Password" style="width:100%; margin: 5px 0;" />
@@ -431,24 +503,36 @@
<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%;">Save</button>
</div> </div>
</div> </div>
<div id="addUserModal" class="modal"> <div id="addUserModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="create_new_user_title">Create New User</h3> <h3 data-i18n-key="create_new_user_title">Create New User</h3>
<label for="newUsername" data-i18n-key="username">Username:</label> <!-- 1) Add a form around these fields -->
<input type="text" id="newUsername" class="form-control" /> <form id="addUserForm">
<label for="addUserPassword" data-i18n-key="password">Password:</label> <label for="newUsername" data-i18n-key="username">Username:</label>
<input type="password" id="addUserPassword" class="form-control" /> <input type="text" id="newUsername" class="form-control" required />
<div id="adminCheckboxContainer">
<input type="checkbox" id="isAdmin" /> <label for="addUserPassword" data-i18n-key="password">Password:</label>
<label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label> <input type="password" id="addUserPassword" class="form-control" required />
</div>
<div class="button-container"> <div id="adminCheckboxContainer">
<button id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">Cancel</button> <input type="checkbox" id="isAdmin" />
<button id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">Save User</button> <label for="isAdmin" data-i18n-key="grant_admin">Grant Admin Access</label>
</div> </div>
<div class="button-container">
<!-- Cancel stays type="button" -->
<button type="button" id="cancelUserBtn" class="btn btn-secondary" data-i18n-key="cancel">
Cancel
</button>
<!-- Save becomes type="submit" -->
<button type="submit" id="saveUserBtn" class="btn btn-primary" data-i18n-key="save_user">
Save User
</button>
</div>
</form>
</div> </div>
</div> </div>
<div id="removeUserModal" class="modal"> <div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3> <h3 data-i18n-key="remove_user_title">Remove User</h3>
<label for="removeUsernameSelect" data-i18n-key="select_user_remove">Select a user to remove:</label> <label for="removeUsernameSelect" data-i18n-key="select_user_remove">Select a user to remove:</label>
@@ -459,7 +543,7 @@
</div> </div>
</div> </div>
</div> </div>
<div id="renameFileModal" class="modal"> <div id="renameFileModal" class="modal" style="display:none;">
<div class="modal-content"> <div class="modal-content">
<h4 data-i18n-key="rename_file_title">Rename File</h4> <h4 data-i18n-key="rename_file_title">Rename File</h4>
<input type="text" id="newFileName" class="form-control" data-i18n-placeholder="rename_file_placeholder" <input type="text" id="newFileName" class="form-control" data-i18n-placeholder="rename_file_placeholder"

706
public/js/adminPanel.js Normal file
View File

@@ -0,0 +1,706 @@
import { t } from './i18n.js';
import { loadAdminConfigFunc } from './auth.js';
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js';
import { sendRequest } from './networkUtils.js';
const version = "v1.3.15";
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
// ————— Inject updated styles —————
(function () {
if (document.getElementById('adminPanelStyles')) return;
const style = document.createElement('style');
style.id = 'adminPanelStyles';
style.textContent = `
/* Modal sizing */
#adminPanelModal .modal-content {
max-width: 1100px;
width: 50%;
}
/* Small phones: 90% width */
@media (max-width: 900px) {
#adminPanelModal .modal-content {
width: 90% !important;
max-width: none !important;
}
}
/* Dark-mode fixes */
body.dark-mode #adminPanelModal .modal-content {
border-color: #555 !important;
}
/* enforce lightmode styling */
#adminPanelModal .modal-content {
max-width: 1100px;
width: 50%;
background: #fff !important;
color: #000 !important;
border: 1px solid #ccc !important;
}
/* enforce darkmode styling */
body.dark-mode #adminPanelModal .modal-content {
background: #2c2c2c !important;
color: #e0e0e0 !important;
border-color: #555 !important;
}
/* form controls in dark */
body.dark-mode .form-control {
background-color: #333;
border-color: #555;
color: #eee;
}
body.dark-mode .form-control::placeholder { color: #888; }
/* Section headers */
.section-header {
background: #f5f5f5;
padding: 10px 15px;
cursor: pointer;
border-radius: 4px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
}
.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; }
/* Hidden by default */
.section-content {
display: none;
margin-left: 20px;
margin-top: 8px;
margin-bottom: 8px;
}
/* Close button */
#adminPanelModal .editor-close-btn {
position: absolute; top:10px; right:10px;
display:flex; align-items:center; justify-content:center;
font-size:20px; font-weight:bold; cursor:pointer;
z-index:1000; width:32px; height:32px; border-radius:50%;
text-align:center; line-height:30px;
color:#ff4d4d; background:rgba(255,255,255,0.9);
border:2px solid transparent; transition:all .3s;
}
#adminPanelModal .editor-close-btn:hover {
color:white; 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;
}
/* Action-row */
.action-row {
display:flex;
justify-content:space-between;
margin-top:15px;
}
`;
document.head.appendChild(style);
})();
// ————————————————————————————————————
let originalAdminConfig = {};
function captureInitialAdminConfig() {
originalAdminConfig = {
headerTitle: document.getElementById("headerTitle").value.trim(),
oidcProviderUrl: document.getElementById("oidcProviderUrl").value.trim(),
oidcClientId: document.getElementById("oidcClientId").value.trim(),
oidcClientSecret: document.getElementById("oidcClientSecret").value.trim(),
oidcRedirectUri: document.getElementById("oidcRedirectUri").value.trim(),
disableFormLogin: document.getElementById("disableFormLogin").checked,
disableBasicAuth: document.getElementById("disableBasicAuth").checked,
disableOIDCLogin: document.getElementById("disableOIDCLogin").checked,
enableWebDAV: document.getElementById("enableWebDAV").checked,
sharedMaxUploadSize: document.getElementById("sharedMaxUploadSize").value.trim(),
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim()
};
}
function hasUnsavedChanges() {
const o = originalAdminConfig;
return (
document.getElementById("headerTitle").value.trim() !== o.headerTitle ||
document.getElementById("oidcProviderUrl").value.trim() !== o.oidcProviderUrl ||
document.getElementById("oidcClientId").value.trim() !== o.oidcClientId ||
document.getElementById("oidcClientSecret").value.trim() !== o.oidcClientSecret ||
document.getElementById("oidcRedirectUri").value.trim() !== o.oidcRedirectUri ||
document.getElementById("disableFormLogin").checked !== o.disableFormLogin ||
document.getElementById("disableBasicAuth").checked !== o.disableBasicAuth ||
document.getElementById("disableOIDCLogin").checked !== o.disableOIDCLogin ||
document.getElementById("enableWebDAV").checked !== o.enableWebDAV ||
document.getElementById("sharedMaxUploadSize").value.trim() !== o.sharedMaxUploadSize ||
document.getElementById("globalOtpauthUrl").value.trim() !== o.globalOtpauthUrl
);
}
function showCustomConfirmModal(message) {
return new Promise(resolve => {
const modal = document.getElementById("customConfirmModal");
const msg = document.getElementById("confirmMessage");
const yes = document.getElementById("confirmYesBtn");
const no = document.getElementById("confirmNoBtn");
msg.textContent = message;
modal.style.display = "block";
function clean() {
modal.style.display = "none";
yes.removeEventListener("click", onYes);
no.removeEventListener("click", onNo);
}
function onYes() { clean(); resolve(true); }
function onNo() { clean(); resolve(false); }
yes.addEventListener("click", onYes);
no.addEventListener("click", onNo);
});
}
function toggleSection(id) {
const hdr = document.getElementById(id + "Header");
const cnt = document.getElementById(id + "Content");
const isCollapsedNow = hdr.classList.toggle("collapsed");
// collapsed class present => hide; absent => show
cnt.style.display = isCollapsedNow ? "none" : "block";
if (!isCollapsedNow && id === "shareLinks") {
loadShareLinksSection();
}
}
function loadShareLinksSection() {
const container = document.getElementById("shareLinksContent");
container.textContent = t("loading") + "...";
// helper: fetch one metadata file, but never throw —
// on non-2xx (including 404) or network error, resolve to {}
function fetchMeta(fileName) {
return fetch(`/api/admin/readMetadata.php?file=${encodeURIComponent(fileName)}`, {
credentials: "include"
})
.then(resp => {
if (!resp.ok) {
// 404 or any other non-OK → treat as empty
return {};
}
return resp.json();
})
.catch(() => {
// network failure, parse error, etc → also empty
return {};
});
}
Promise.all([
fetchMeta("share_folder_links.json"),
fetchMeta("share_links.json")
])
.then(([folders, files]) => {
// if *both* are empty, show "no shared links"
const hasAny = Object.keys(folders).length || Object.keys(files).length;
if (!hasAny) {
container.innerHTML = `<p>${t("no_shared_links_available")}</p>`;
return;
}
let html = `<h5>${t("folder_shares")}</h5><ul>`;
Object.entries(folders).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
html += `
<li>
${lock}<strong>${o.folder}</strong>
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button"
data-key="${token}"
data-type="folder"
class="btn btn-sm btn-link delete-share">🗑️</button>
</li>`;
});
html += `</ul><h5 style="margin-top:1em;">${t("file_shares")}</h5><ul>`;
Object.entries(files).forEach(([token, o]) => {
const lock = o.password ? "🔒 " : "";
html += `
<li>
${lock}<strong>${o.folder}/${o.file}</strong>
<small>(${new Date(o.expires * 1000).toLocaleString()})</small>
<button type="button"
data-key="${token}"
data-type="file"
class="btn btn-sm btn-link delete-share">🗑️</button>
</li>`;
});
html += `</ul>`;
container.innerHTML = html;
// wire up delete buttons
container.querySelectorAll(".delete-share").forEach(btn => {
btn.addEventListener("click", evt => {
evt.preventDefault();
const token = btn.dataset.key;
const isFolder = btn.dataset.type === "folder";
const endpoint = isFolder
? "/api/folder/deleteShareFolderLink.php"
: "/api/file/deleteShareLink.php";
fetch(endpoint, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ token })
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => {
if (json.success) {
showToast(t("share_deleted_successfully"));
loadShareLinksSection();
} else {
showToast(t("error_deleting_share") + ": " + (json.error || ""), "error");
}
})
.catch(err => {
console.error("Delete error:", err);
showToast(t("error_deleting_share"), "error");
});
});
});
})
.catch(err => {
console.error("loadShareLinksSection error:", err);
container.textContent = t("error_loading_share_links");
});
}
export function openAdminPanel() {
fetch("/api/admin/getConfig.php", { credentials: "include" })
.then(r => r.json())
.then(config => {
// apply header title + globals
if (config.header_title) {
document.querySelector(".header-title h1").textContent = config.header_title;
window.headerTitle = config.header_title;
}
if (config.oidc) Object.assign(window.currentOIDCConfig, config.oidc);
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
const dark = document.body.classList.contains("dark-mode");
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const inner = `
background:${dark ? "#2c2c2c" : "#fff"};
color:${dark ? "#e0e0e0" : "#000"};
padding:20px; max-width:1100px; width:50%;
border-radius:8px; position:relative;
max-height:90vh; overflow:auto;
border:1px solid ${dark ? "#555" : "#ccc"};
`;
let mdl = document.getElementById("adminPanelModal");
if (!mdl) {
mdl = document.createElement("div");
mdl.id = "adminPanelModal";
mdl.style.cssText = `
position:fixed; top:0; left:0;
width:100vw; height:100vh;
background:${bg};
display:flex; justify-content:center; align-items:center;
z-index:3000;
`;
mdl.innerHTML = `
<div class="modal-content" style="${inner}">
<div class="editor-close-btn" id="closeAdminPanel">&times;</div>
<h3>${adminTitle}</h3>
<form id="adminPanelForm">
<!-- each section: header + content -->
${[
{ id: "userManagement", label: t("user_management") },
{ id: "headerSettings", label: t("header_settings") },
{ id: "loginOptions", label: t("login_options") },
{ id: "webdav", label: "WebDAV Access" },
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
{ id: "shareLinks", label: t("manage_shared_links") }
].map(sec => `
<div id="${sec.id}Header" class="section-header collapsed">
${sec.label} <i class="material-icons">expand_more</i>
</div>
<div id="${sec.id}Content" class="section-content"></div>
`).join("")}
<div class="action-row">
<button type="button" id="cancelAdminSettings" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveAdminSettings" class="btn btn-primary">${t("save_settings")}</button>
</div>
</form>
</div>
`;
document.body.appendChild(mdl);
// Bind close & cancel
document.getElementById("closeAdminPanel")
.addEventListener("click", closeAdminPanel);
document.getElementById("cancelAdminSettings")
.addEventListener("click", closeAdminPanel);
// Section toggles
["userManagement", "headerSettings", "loginOptions", "webdav", "upload", "oidc", "shareLinks"]
.forEach(id => {
document.getElementById(id + "Header")
.addEventListener("click", () => toggleSection(id));
});
// Populate each sections CONTENT:
// — User Mgmt —
document.getElementById("userManagementContent").innerHTML = `
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${t("user_permissions")}</button>
`;
document.getElementById("adminOpenAddUser")
.addEventListener("click", () => {
toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus();
});
document.getElementById("adminOpenRemoveUser")
.addEventListener("click", () => {
if (typeof window.loadUserList === "function") window.loadUserList();
toggleVisibility("removeUserModal", true);
});
document.getElementById("adminOpenUserPermissions")
.addEventListener("click", openUserPermissionsModal);
// — Header Settings —
document.getElementById("headerSettingsContent").innerHTML = `
<div class="form-group">
<label for="headerTitle">${t("header_title_text")}:</label>
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle}" />
</div>
`;
// — Login Options —
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="disableBasicAuth" /> <label for="disableBasicAuth">${t("disable_basic_http_auth")}</label></div>
<div class="form-group"><input type="checkbox" id="disableOIDCLogin" /> <label for="disableOIDCLogin">${t("disable_oidc_login")}</label></div>
<div class="form-group">
<input type="checkbox" id="authBypass" />
<label for="authBypass">Disable all built-in logins (proxy only)</label>
</div>
<div class="form-group">
<label for="authHeaderName">Auth header name:</label>
<input type="text" id="authHeaderName" class="form-control" placeholder="e.g. X-Remote-User" />
</div>
`;
// — WebDAV —
document.getElementById("webdavContent").innerHTML = `
<div class="form-group"><input type="checkbox" id="enableWebDAV" /> <label for="enableWebDAV">Enable WebDAV</label></div>
`;
// — Upload —
document.getElementById("uploadContent").innerHTML = `
<div class="form-group">
<label for="sharedMaxUploadSize">${t("shared_max_upload_size_bytes")}:</label>
<input type="number" id="sharedMaxUploadSize" class="form-control" placeholder="e.g. 52428800" />
<small>${t("max_bytes_shared_uploads_note")}</small>
</div>
`;
// — OIDC & TOTP —
document.getElementById("oidcContent").innerHTML = `
<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>
</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"><label for="oidcClientSecret">${t("oidc_client_secret")}:</label><input type="text" id="oidcClientSecret" class="form-control" value="${window.currentOIDCConfig.clientSecret}" /></div>
<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>
`;
// — Share Links —
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
// — Save handler & constraints —
document.getElementById("saveAdminSettings")
.addEventListener("click", handleSave);
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"].forEach(id => {
document.getElementById(id)
.addEventListener("change", e => {
const chk = ["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"]
.filter(i => document.getElementById(i).checked).length;
if (chk === 3) {
showToast(t("at_least_one_login_method"));
e.target.checked = false;
}
});
});
// If authBypass is checked, clear the other three
document.getElementById("authBypass").addEventListener("change", e => {
if (e.target.checked) {
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"]
.forEach(i => document.getElementById(i).checked = false);
}
});
// Initialize inputs from config + capture
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("authBypass").checked = !!config.loginOptions.authBypass;
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
captureInitialAdminConfig();
} else {
// modal already exists → just refresh values & re-show
mdl.style.display = "flex";
// update dark/light as above...
document.getElementById("disableFormLogin").checked = config.loginOptions.disableFormLogin === true;
document.getElementById("disableBasicAuth").checked = config.loginOptions.disableBasicAuth === true;
document.getElementById("disableOIDCLogin").checked = config.loginOptions.disableOIDCLogin === true;
document.getElementById("authBypass").checked = !!config.loginOptions.authBypass;
document.getElementById("authHeaderName").value = config.loginOptions.authHeaderName || "X-Remote-User";
document.getElementById("enableWebDAV").checked = config.enableWebDAV === true;
document.getElementById("sharedMaxUploadSize").value = config.sharedMaxUploadSize || "";
document.getElementById("oidcProviderUrl").value = window.currentOIDCConfig.providerUrl;
document.getElementById("oidcClientId").value = window.currentOIDCConfig.clientId;
document.getElementById("oidcClientSecret").value = window.currentOIDCConfig.clientSecret;
document.getElementById("oidcRedirectUri").value = window.currentOIDCConfig.redirectUri;
document.getElementById("globalOtpauthUrl").value = window.currentOIDCConfig.globalOtpauthUrl || '';
captureInitialAdminConfig();
}
})
.catch(() => {/* if even fetching fails, open empty panel */ });
}
function handleSave() {
const dFL = document.getElementById("disableFormLogin").checked;
const dBA = document.getElementById("disableBasicAuth").checked;
const dOIDC = document.getElementById("disableOIDCLogin").checked;
const aBypass= document.getElementById("authBypass").checked;
const aHeader= document.getElementById("authHeaderName").value.trim() || "X-Remote-User";
const eWD = document.getElementById("enableWebDAV").checked;
const sMax = parseInt(document.getElementById("sharedMaxUploadSize").value, 10) || 0;
const nHT = document.getElementById("headerTitle").value.trim();
const nOIDC = {
providerUrl: document.getElementById("oidcProviderUrl").value.trim(),
clientId: document.getElementById("oidcClientId").value.trim(),
clientSecret:document.getElementById("oidcClientSecret").value.trim(),
redirectUri: document.getElementById("oidcRedirectUri").value.trim()
};
const gURL = document.getElementById("globalOtpauthUrl").value.trim();
if ([dFL, dBA, dOIDC].filter(x => x).length === 3) {
showToast(t("at_least_one_login_method"));
return;
}
sendRequest("/api/admin/updateConfig.php", "POST", {
header_title: nHT,
oidc: nOIDC,
loginOptions: {
disableFormLogin: dFL,
disableBasicAuth: dBA,
disableOIDCLogin: dOIDC,
authBypass: aBypass,
authHeaderName: aHeader
},
enableWebDAV: eWD,
sharedMaxUploadSize: sMax,
globalOtpauthUrl: gURL
}, {
"X-CSRF-Token": window.csrfToken
})
.then(res => {
if (res.success) {
showToast(t("settings_updated_successfully"), "success");
captureInitialAdminConfig();
closeAdminPanel();
loadAdminConfigFunc();
} else {
showToast(t("error_updating_settings") + ": " + (res.error || t("unknown_error")), "error");
}
}).catch(() => {/*noop*/});
}
export async function closeAdminPanel() {
if (hasUnsavedChanges()) {
const ok = await showCustomConfirmModal(t("unsaved_changes_confirm"));
if (!ok) return;
}
document.getElementById("adminPanelModal").style.display = "none";
}
// --- New: User Permissions Modal ---
export function openUserPermissionsModal() {
let userPermissionsModal = document.getElementById("userPermissionsModal");
const isDarkMode = document.body.classList.contains("dark-mode");
const overlayBackground = isDarkMode ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
const modalContentStyles = `
background: ${isDarkMode ? "#2c2c2c" : "#fff"};
color: ${isDarkMode ? "#e0e0e0" : "#000"};
padding: 20px;
max-width: 500px;
width: 90%;
border-radius: 8px;
position: relative;
`;
if (!userPermissionsModal) {
userPermissionsModal = document.createElement("div");
userPermissionsModal.id = "userPermissionsModal";
userPermissionsModal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: ${overlayBackground};
display: flex;
justify-content: center;
align-items: center;
z-index: 3500;
`;
userPermissionsModal.innerHTML = `
<div class="modal-content" style="${modalContentStyles}">
<span id="closeUserPermissionsModal" class="editor-close-btn">&times;</span>
<h3>${t("user_permissions")}</h3>
<div id="userPermissionsList" style="max-height: 300px; overflow-y: auto; margin-bottom: 15px;">
<!-- User rows will be loaded here -->
</div>
<div style="display: flex; justify-content: flex-end; gap: 10px;">
<button type="button" id="cancelUserPermissionsBtn" class="btn btn-secondary">${t("cancel")}</button>
<button type="button" id="saveUserPermissionsBtn" class="btn btn-primary">${t("save_permissions")}</button>
</div>
</div>
`;
document.body.appendChild(userPermissionsModal);
document.getElementById("closeUserPermissionsModal").addEventListener("click", () => {
userPermissionsModal.style.display = "none";
});
document.getElementById("cancelUserPermissionsBtn").addEventListener("click", () => {
userPermissionsModal.style.display = "none";
});
document.getElementById("saveUserPermissionsBtn").addEventListener("click", () => {
// Collect permissions data from each user row.
const rows = userPermissionsModal.querySelectorAll(".user-permission-row");
const permissionsData = [];
rows.forEach(row => {
const username = row.getAttribute("data-username");
const folderOnlyCheckbox = row.querySelector("input[data-permission='folderOnly']");
const readOnlyCheckbox = row.querySelector("input[data-permission='readOnly']");
const disableUploadCheckbox = row.querySelector("input[data-permission='disableUpload']");
permissionsData.push({
username,
folderOnly: folderOnlyCheckbox.checked,
readOnly: readOnlyCheckbox.checked,
disableUpload: disableUploadCheckbox.checked
});
});
// Send the permissionsData to the server.
sendRequest("/api/updateUserPermissions.php", "POST", { permissions: permissionsData }, { "X-CSRF-Token": window.csrfToken })
.then(response => {
if (response.success) {
showToast(t("user_permissions_updated_successfully"));
userPermissionsModal.style.display = "none";
} else {
showToast(t("error_updating_permissions") + ": " + (response.error || t("unknown_error")));
}
})
.catch(() => {
showToast(t("error_updating_permissions"));
});
});
} else {
userPermissionsModal.style.display = "flex";
}
// Load the list of users into the modal.
loadUserPermissionsList();
}
function loadUserPermissionsList() {
const listContainer = document.getElementById("userPermissionsList");
if (!listContainer) return;
listContainer.innerHTML = "";
// First, fetch the current permissions from the server.
fetch("/api/getUserPermissions.php", { credentials: "include" })
.then(response => response.json())
.then(permissionsData => {
// Then, fetch the list of users.
return fetch("/api/getUsers.php", { credentials: "include" })
.then(response => response.json())
.then(usersData => {
const users = Array.isArray(usersData) ? usersData : (usersData.users || []);
if (users.length === 0) {
listContainer.innerHTML = "<p>" + t("no_users_found") + "</p>";
return;
}
users.forEach(user => {
// Skip admin users.
if ((user.role && user.role === "1") || user.username.toLowerCase() === "admin") return;
// Use stored permissions if available; otherwise fall back to defaults.
const defaultPerm = {
folderOnly: false,
readOnly: false,
disableUpload: false,
};
// Normalize the username key to match server storage (e.g., lowercase)
const usernameKey = user.username.toLowerCase();
const userPerm = (permissionsData && typeof permissionsData === "object" && (usernameKey in permissionsData))
? permissionsData[usernameKey]
: defaultPerm;
// Create a row for the user.
const row = document.createElement("div");
row.classList.add("user-permission-row");
row.setAttribute("data-username", user.username);
row.style.padding = "10px 0";
row.innerHTML = `
<div style="font-weight: bold; margin-bottom: 5px;">${user.username}</div>
<div style="display: flex; flex-direction: column; gap: 5px;">
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="folderOnly" ${userPerm.folderOnly ? "checked" : ""} />
${t("user_folder_only")}
</label>
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="readOnly" ${userPerm.readOnly ? "checked" : ""} />
${t("read_only")}
</label>
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" data-permission="disableUpload" ${userPerm.disableUpload ? "checked" : ""} />
${t("disable_upload")}
</label>
</div>
<hr style="margin-top: 10px; border: 0; border-bottom: 1px solid #ccc;">
`;
listContainer.appendChild(row);
});
});
})
.catch(() => {
listContainer.innerHTML = "<p>" + t("error_loading_users") + "</p>";
});
}

View File

@@ -15,16 +15,17 @@ import {
openUserPanel, openUserPanel,
openTOTPModal, openTOTPModal,
closeTOTPModal, closeTOTPModal,
openAdminPanel, setLastLoginData,
closeAdminPanel, openApiModal
setLastLoginData
} from './authModals.js'; } from './authModals.js';
import { openAdminPanel } from './adminPanel.js';
import { initializeApp, triggerLogout } from './main.js';
// Production OIDC configuration (override via API as needed) // Production OIDC configuration (override via API as needed)
const currentOIDCConfig = { const currentOIDCConfig = {
providerUrl: "https://your-oidc-provider.com", providerUrl: "https://your-oidc-provider.com",
clientId: "YOUR_CLIENT_ID", clientId: "",
clientSecret: "YOUR_CLIENT_SECRET", clientSecret: "",
redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback", redirectUri: "https://yourdomain.com/api/auth/auth.php?oidc=callback",
globalOtpauthUrl: "" globalOtpauthUrl: ""
}; };
@@ -125,10 +126,24 @@ function updateItemsPerPageSelect() {
} }
} }
function applyProxyBypassUI() {
const bypass = localStorage.getItem("authBypass") === "true";
const loginContainer = document.getElementById("loginForm");
if (loginContainer) {
loginContainer.style.display = bypass ? "none" : "";
}
}
function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) { function updateLoginOptionsUI({ disableFormLogin, disableBasicAuth, disableOIDCLogin }) {
const authForm = document.getElementById("authForm"); const authForm = document.getElementById("authForm");
if
if (authForm) authForm.style.display = disableFormLogin ? "none" : "block"; (authForm) {
authForm.style.display = disableFormLogin ? "none" : "block";
setTimeout(() => {
const loginInput = document.getElementById('loginUsername');
if (loginInput) loginInput.focus();
}, 0);
}
const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']"); const basicAuthLink = document.querySelector("a[href='/api/auth/login_basic.php']");
if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block"; if (basicAuthLink) basicAuthLink.style.display = disableBasicAuth ? "none" : "inline-block";
const oidcLoginBtn = document.getElementById("oidcLoginBtn"); const oidcLoginBtn = document.getElementById("oidcLoginBtn");
@@ -139,7 +154,8 @@ function updateLoginOptionsUIFromStorage() {
updateLoginOptionsUI({ updateLoginOptionsUI({
disableFormLogin: localStorage.getItem("disableFormLogin") === "true", disableFormLogin: localStorage.getItem("disableFormLogin") === "true",
disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true", disableBasicAuth: localStorage.getItem("disableBasicAuth") === "true",
disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true" disableOIDCLogin: localStorage.getItem("disableOIDCLogin") === "true",
authBypass: localStorage.getItem("authBypass") === "true"
}); });
} }
@@ -154,6 +170,8 @@ export function loadAdminConfigFunc() {
localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth); localStorage.setItem("disableBasicAuth", config.loginOptions.disableBasicAuth);
localStorage.setItem("disableOIDCLogin", config.loginOptions.disableOIDCLogin); localStorage.setItem("disableOIDCLogin", config.loginOptions.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");
localStorage.setItem("authBypass", String(!!config.loginOptions.authBypass));
localStorage.setItem("authHeaderName", config.loginOptions.authHeaderName || "X-Remote-User");
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
@@ -182,16 +200,48 @@ function insertAfter(newNode, referenceNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
} }
function updateAuthenticatedUI(data) { async function fetchProfilePicture() {
try {
const res = await fetch('/api/profile/getCurrentUser.php', {
credentials: 'include'
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const info = await res.json();
let pic = info.profile_picture || '';
// --- take only what's after the *last* colon ---
const parts = pic.split(':');
pic = parts[parts.length - 1] || '';
// strip any stray leading colons
pic = pic.replace(/^:+/, '');
// ensure exactly one leading slash
if (pic && !pic.startsWith('/')) pic = '/' + pic;
return pic;
} catch (e) {
console.warn('fetchProfilePicture failed:', e);
return '';
}
}
export async function updateAuthenticatedUI(data) {
// Save latest auth data for later reuse
window.__lastAuthData = data;
// 1) Remove loading overlay safely
const loading = document.getElementById('loadingOverlay');
if (loading) loading.remove();
// 2) Show main UI
document.querySelector('.main-wrapper').style.display = '';
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("addUserModal", "saveUserBtn"); 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)
if (typeof data.totp_enabled !== "undefined") { if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
} }
@@ -199,64 +249,157 @@ 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
const profilePicUrl = await fetchProfilePicture();
localStorage.setItem("profilePicUrl", profilePicUrl);
// 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
if (data.isAdmin) { if (data.isAdmin) {
let restoreBtn = document.getElementById("restoreFilesBtn"); let r = document.getElementById("restoreFilesBtn");
if (!restoreBtn) { if (!r) {
restoreBtn = document.createElement("button"); r = document.createElement("button");
restoreBtn.id = "restoreFilesBtn"; r.id = "restoreFilesBtn";
restoreBtn.classList.add("btn", "btn-warning"); r.classList.add("btn","btn-warning");
restoreBtn.setAttribute("data-i18n-title", "trash_restore_delete"); r.setAttribute("data-i18n-title","trash_restore_delete");
restoreBtn.innerHTML = '<i class="material-icons">restore_from_trash</i>'; r.innerHTML = '<i class="material-icons">restore_from_trash</i>';
if (firstButton) insertAfter(restoreBtn, firstButton); if (firstButton) insertAfter(r, firstButton);
else headerButtons.appendChild(restoreBtn); else headerButtons.appendChild(r);
}
restoreBtn.style.display = "block";
let adminPanelBtn = document.getElementById("adminPanelBtn");
if (!adminPanelBtn) {
adminPanelBtn = document.createElement("button");
adminPanelBtn.id = "adminPanelBtn";
adminPanelBtn.classList.add("btn", "btn-info");
adminPanelBtn.setAttribute("data-i18n-title", "admin_panel");
adminPanelBtn.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
insertAfter(adminPanelBtn, restoreBtn);
adminPanelBtn.addEventListener("click", openAdminPanel);
} else {
adminPanelBtn.style.display = "block";
} }
r.style.display = "block";
} else { } else {
const restoreBtn = document.getElementById("restoreFilesBtn"); const r = document.getElementById("restoreFilesBtn");
if (restoreBtn) restoreBtn.style.display = "none"; if (r) r.style.display = "none";
const adminPanelBtn = document.getElementById("adminPanelBtn");
if (adminPanelBtn) adminPanelBtn.style.display = "none";
} }
if (window.location.hostname !== "demo.filerise.net") { // b) admin panel button only on demo.filerise.net
let userPanelBtn = document.getElementById("userPanelBtn"); if (data.isAdmin && window.location.hostname === "demo.filerise.net") {
if (!userPanelBtn) { let a = document.getElementById("adminPanelBtn");
userPanelBtn = document.createElement("button"); if (!a) {
userPanelBtn.id = "userPanelBtn"; a = document.createElement("button");
userPanelBtn.classList.add("btn", "btn-user"); a.id = "adminPanelBtn";
userPanelBtn.setAttribute("data-i18n-title", "user_panel"); a.classList.add("btn","btn-info");
userPanelBtn.innerHTML = '<i class="material-icons">account_circle</i>'; a.setAttribute("data-i18n-title","admin_panel");
a.innerHTML = '<i class="material-icons">admin_panel_settings</i>';
insertAfter(a, document.getElementById("restoreFilesBtn"));
a.addEventListener("click", openAdminPanel);
}
a.style.display = "block";
} else {
const a = document.getElementById("adminPanelBtn");
if (a) a.style.display = "none";
}
// c) user dropdown on non-demo
if (window.location.hostname !== "demo.filerise.net") {
let dd = document.getElementById("userDropdown");
// choose icon *or* img
const avatarHTML = profilePicUrl
? `<img src="${profilePicUrl}" style="width:24px;height:24px;border-radius:50%;vertical-align:middle;">`
: `<i class="material-icons">account_circle</i>`;
// fallback username if missing
const usernameText = data.username
|| localStorage.getItem("username")
|| "";
if (!dd) {
dd = document.createElement("div");
dd.id = "userDropdown";
dd.classList.add("user-dropdown");
// toggle button
const toggle = document.createElement("button");
toggle.id = "userDropdownToggle";
toggle.classList.add("btn","btn-user");
toggle.setAttribute("title", t("user_settings"));
toggle.innerHTML = `
${avatarHTML}
<span class="dropdown-username">${usernameText}</span>
<span class="dropdown-caret"></span>
`;
dd.append(toggle);
// menu
const menu = document.createElement("div");
menu.classList.add("user-menu");
menu.innerHTML = `
<div class="item" id="menuUserPanel">
<i class="material-icons folder-icon">person</i> ${t("user_panel")}
</div>
${data.isAdmin ? `
<div class="item" id="menuAdminPanel">
<i class="material-icons folder-icon">admin_panel_settings</i> ${t("admin_panel")}
</div>` : ''}
<div class="item" id="menuApiDocs">
<i class="material-icons folder-icon">description</i> ${t("api_docs")}
</div>
<div class="item" id="menuLogout">
<i class="material-icons folder-icon">logout</i> ${t("logout")}
</div>
`;
dd.append(menu);
// insert
const dm = document.getElementById("darkModeToggle");
if (dm) insertAfter(dd, dm);
else if (firstButton) insertAfter(dd, firstButton);
else headerButtons.appendChild(dd);
// open/close
toggle.addEventListener("click", e => {
e.stopPropagation();
menu.classList.toggle("show");
});
document.addEventListener("click", () => menu.classList.remove("show"));
// actions
document.getElementById("menuUserPanel")
.addEventListener("click", () => {
menu.classList.remove("show");
openUserPanel();
});
if (data.isAdmin) {
document.getElementById("menuAdminPanel")
.addEventListener("click", () => {
menu.classList.remove("show");
openAdminPanel();
});
}
document.getElementById("menuApiDocs")
.addEventListener("click", () => {
menu.classList.remove("show");
openApiModal();
});
document.getElementById("menuLogout")
.addEventListener("click", () => {
menu.classList.remove("show");
triggerLogout();
});
const adminBtn = document.getElementById("adminPanelBtn");
if (adminBtn) insertAfter(userPanelBtn, adminBtn);
else if (firstButton) insertAfter(userPanelBtn, firstButton);
else headerButtons.appendChild(userPanelBtn);
userPanelBtn.addEventListener("click", openUserPanel);
} else { } else {
userPanelBtn.style.display = "block"; // update avatar & username only
const tog = dd.querySelector("#userDropdownToggle");
tog.innerHTML = `
${avatarHTML}
<span class="dropdown-username">${usernameText}</span>
<span class="dropdown-caret"></span>
`;
dd.style.display = "inline-block";
} }
} }
// 6) Finalize
initializeApp();
applyTranslations(); applyTranslations();
updateItemsPerPageSelect(); updateItemsPerPageSelect();
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
@@ -266,6 +409,12 @@ function checkAuthentication(showLoginToast = true) {
return sendRequest("/api/auth/checkAuth.php") return sendRequest("/api/auth/checkAuth.php")
.then(data => { .then(data => {
if (data.setup) { if (data.setup) {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
// show the wrapper (so the login form can be visible)
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = 'none';
window.setupMode = true; window.setupMode = true;
if (showLoginToast) showToast("Setup mode: No users found. Please add an admin user."); if (showLoginToast) showToast("Setup mode: No users found. Please add an admin user.");
toggleVisibility("loginForm", false); toggleVisibility("loginForm", false);
@@ -277,11 +426,13 @@ function checkAuthentication(showLoginToast = true) {
} }
window.setupMode = false; window.setupMode = false;
if (data.authenticated) { if (data.authenticated) {
localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false'); localStorage.setItem('isAdmin', data.isAdmin ? 'true' : 'false');
localStorage.setItem("folderOnly", data.folderOnly); localStorage.setItem("folderOnly", data.folderOnly);
localStorage.setItem("readOnly", data.readOnly); localStorage.setItem("readOnly", data.readOnly);
localStorage.setItem("disableUpload", data.disableUpload); localStorage.setItem("disableUpload", data.disableUpload);
updateLoginOptionsUIFromStorage(); updateLoginOptionsUIFromStorage();
applyProxyBypassUI();
if (typeof data.totp_enabled !== "undefined") { if (typeof data.totp_enabled !== "undefined") {
localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false"); localStorage.setItem("userTOTPEnabled", data.totp_enabled ? "true" : "false");
} }
@@ -292,8 +443,14 @@ function checkAuthentication(showLoginToast = true) {
updateAuthenticatedUI(data); updateAuthenticatedUI(data);
return data; return data;
} else { } else {
const overlay = document.getElementById('loadingOverlay');
if (overlay) overlay.remove();
// show the wrapper (so the login form can be visible)
document.querySelector('.main-wrapper').style.display = '';
document.getElementById('loginForm').style.display = '';
if (showLoginToast) showToast("Please log in to continue."); if (showLoginToast) showToast("Please log in to continue.");
toggleVisibility("loginForm", true); toggleVisibility("loginForm", !(localStorage.getItem("authBypass") === "true"));
toggleVisibility("mainOperations", false); toggleVisibility("mainOperations", false);
toggleVisibility("uploadFileForm", false); toggleVisibility("uploadFileForm", false);
toggleVisibility("fileListContainer", false); toggleVisibility("fileListContainer", false);
@@ -437,45 +594,54 @@ function initAuth() {
submitLogin(formData); submitLogin(formData);
}); });
} }
document.getElementById("logoutBtn").addEventListener("click", function () {
fetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
}).then(() => window.location.reload(true)).catch(() => { });
});
document.getElementById("addUserBtn").addEventListener("click", function () { document.getElementById("addUserBtn").addEventListener("click", function () {
resetUserForm(); resetUserForm();
toggleVisibility("addUserModal", true); toggleVisibility("addUserModal", true);
document.getElementById("newUsername").focus(); document.getElementById("newUsername").focus();
}); });
document.getElementById("saveUserBtn").addEventListener("click", function () {
// remove your old saveUserBtn click-handler…
// instead:
const addUserForm = document.getElementById("addUserForm");
addUserForm.addEventListener("submit", function (e) {
e.preventDefault(); // stop the browser from reloading the page
const newUsername = document.getElementById("newUsername").value.trim(); const newUsername = document.getElementById("newUsername").value.trim();
const newPassword = document.getElementById("addUserPassword").value.trim(); const newPassword = document.getElementById("addUserPassword").value.trim();
const isAdmin = document.getElementById("isAdmin").checked; const isAdmin = document.getElementById("isAdmin").checked;
if (!newUsername || !newPassword) { if (!newUsername || !newPassword) {
showToast("Username and password are required!"); showToast("Username and password are required!");
return; return;
} }
let url = "/api/addUser.php"; let url = "/api/addUser.php";
if (window.setupMode) url += "?setup=1"; if (window.setupMode) url += "?setup=1";
fetchWithCsrf(url, { fetchWithCsrf(url, {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin }) body: JSON.stringify({ username: newUsername, password: newPassword, isAdmin })
}) })
.then(response => response.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.success) { if (data.success) {
showToast("User added successfully!"); showToast("User added successfully!");
closeAddUserModal(); closeAddUserModal();
checkAuthentication(false); checkAuthentication(false);
if (window.setupMode) {
toggleVisibility("loginForm", true);
}
} else { } else {
showToast("Error: " + (data.error || "Could not add user")); showToast("Error: " + (data.error || "Could not add user"));
} }
}) })
.catch(() => { }); .catch(() => {
showToast("Error: Could not add user");
});
}); });
document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal); document.getElementById("cancelUserBtn").addEventListener("click", closeAddUserModal);

File diff suppressed because it is too large Load Diff

View File

@@ -25,46 +25,74 @@ export function toggleAllCheckboxes(masterCheckbox) {
const checkboxes = document.querySelectorAll(".file-checkbox"); const checkboxes = document.querySelectorAll(".file-checkbox");
checkboxes.forEach(chk => { checkboxes.forEach(chk => {
chk.checked = masterCheckbox.checked; chk.checked = masterCheckbox.checked;
updateRowHighlight(chk);
}); });
updateFileActionButtons(); // update buttons based on current selection updateFileActionButtons();
} }
export function updateFileActionButtons() { export function updateFileActionButtons() {
const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox"); const fileCheckboxes = document.querySelectorAll("#fileList .file-checkbox");
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked"); const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const copyBtn = document.getElementById("copySelectedBtn"); const copyBtn = document.getElementById("copySelectedBtn");
const moveBtn = document.getElementById("moveSelectedBtn"); const moveBtn = document.getElementById("moveSelectedBtn");
const deleteBtn = document.getElementById("deleteSelectedBtn");
const zipBtn = document.getElementById("downloadZipBtn"); const zipBtn = document.getElementById("downloadZipBtn");
const extractZipBtn = document.getElementById("extractZipBtn"); const extractZipBtn = document.getElementById("extractZipBtn");
const createBtn = document.getElementById("createBtn");
if (fileCheckboxes.length === 0) { const anyFiles = fileCheckboxes.length > 0;
if (copyBtn) copyBtn.style.display = "none"; const anySelected = selectedCheckboxes.length > 0;
if (moveBtn) moveBtn.style.display = "none"; const anyZip = Array.from(selectedCheckboxes)
if (deleteBtn) deleteBtn.style.display = "none"; .some(cb => cb.value.toLowerCase().endsWith(".zip"));
if (zipBtn) zipBtn.style.display = "none";
if (extractZipBtn) extractZipBtn.style.display = "none";
} else {
if (copyBtn) copyBtn.style.display = "inline-block";
if (moveBtn) moveBtn.style.display = "inline-block";
if (deleteBtn) deleteBtn.style.display = "inline-block";
if (zipBtn) zipBtn.style.display = "inline-block";
if (extractZipBtn) extractZipBtn.style.display = "inline-block";
const anySelected = selectedCheckboxes.length > 0; // — Select All checkbox sync (unchanged) —
if (copyBtn) copyBtn.disabled = !anySelected; const master = document.getElementById("selectAll");
if (moveBtn) moveBtn.disabled = !anySelected; if (master) {
if (deleteBtn) deleteBtn.disabled = !anySelected; if (selectedCheckboxes.length === fileCheckboxes.length) {
if (zipBtn) zipBtn.disabled = !anySelected; master.checked = true;
master.indeterminate = false;
if (extractZipBtn) { } else if (selectedCheckboxes.length === 0) {
// Enable only if at least one selected file ends with .zip (case-insensitive). master.checked = false;
const anyZipSelected = Array.from(selectedCheckboxes).some(chk => master.indeterminate = false;
chk.value.toLowerCase().endsWith(".zip") } else {
); master.checked = false;
extractZipBtn.disabled = !anyZipSelected; master.indeterminate = true;
} }
} }
// Delete / Copy / Move: only show when something is selected
if (deleteBtn) {
deleteBtn.style.display = anySelected ? "" : "none";
}
if (copyBtn) {
copyBtn.style.display = anySelected ? "" : "none";
}
if (moveBtn) {
moveBtn.style.display = anySelected ? "" : "none";
}
// Download ZIP: only show when something is selected
if (zipBtn) {
zipBtn.style.display = anySelected ? "" : "none";
}
// Extract ZIP: only show when a selected file is a .zip
if (extractZipBtn) {
extractZipBtn.style.display = anyZip ? "" : "none";
}
// Create File: only show when nothing is selected
if (createBtn) {
createBtn.style.display = anySelected ? "none" : "";
}
// Finally disable the ones that are shown but shouldnt be clickable
if (deleteBtn) deleteBtn.disabled = !anySelected;
if (copyBtn) copyBtn.disabled = !anySelected;
if (moveBtn) moveBtn.disabled = !anySelected;
if (zipBtn) zipBtn.disabled = !anySelected;
if (extractZipBtn) extractZipBtn.disabled = !anyZip;
} }
export function showToast(message, duration = 3000) { export function showToast(message, duration = 3000) {
@@ -91,7 +119,7 @@ export function showToast(message, duration = 3000) {
export function buildSearchAndPaginationControls({ currentPage, totalPages, searchTerm }) { export function buildSearchAndPaginationControls({ currentPage, totalPages, searchTerm }) {
const safeSearchTerm = escapeHTML(searchTerm); const safeSearchTerm = escapeHTML(searchTerm);
// Choose the placeholder text based on advanced search mode // Choose the placeholder text based on advanced search mode
const placeholderText = window.advancedSearchEnabled const placeholderText = window.advancedSearchEnabled
? t("search_placeholder_advanced") ? t("search_placeholder_advanced")
: t("search_placeholder"); : t("search_placeholder");
@@ -101,7 +129,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
<div class="input-group"> <div class="input-group">
<!-- Advanced Search Toggle Button --> <!-- Advanced Search Toggle Button -->
<div class="input-group-prepend"> <div class="input-group-prepend">
<button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" onclick="toggleAdvancedSearch()" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}"> <button id="advancedSearchToggle" class="btn btn-outline-secondary btn-icon" title="${window.advancedSearchEnabled ? t("basic_search_tooltip") : t("advanced_search_tooltip")}">
<i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i> <i class="material-icons">${window.advancedSearchEnabled ? "filter_alt_off" : "filter_alt"}</i>
</button> </button>
</div> </div>
@@ -117,9 +145,9 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
</div> </div>
<div class="col-12 col-md-4 text-left"> <div class="col-12 col-md-4 text-left">
<div class="d-flex justify-content-center justify-content-md-start align-items-center"> <div class="d-flex justify-content-center justify-content-md-start align-items-center">
<button class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""} onclick="changePage(${currentPage - 1})">${t("prev")}</button> <button id="prevPageBtn" class="custom-prev-next-btn" ${currentPage === 1 ? "disabled" : ""}>${t("prev")}</button>
<span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span> <span class="page-indicator">${t("page")} ${currentPage} ${t("of")} ${totalPages || 1}</span>
<button class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""} onclick="changePage(${currentPage + 1})">${t("next")}</button> <button id="nextPageBtn" class="custom-prev-next-btn" ${currentPage === totalPages ? "disabled" : ""}>${t("next")}</button>
</div> </div>
</div> </div>
</div> </div>
@@ -131,7 +159,7 @@ export function buildFileTableHeader(sortOrder) {
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll" onclick="toggleAllCheckboxes(this)"></th> <th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
<th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="name" class="sortable-col">${t("file_name")} ${sortOrder.column === "name" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="modified" class="hide-small sortable-col">${t("date_modified")} ${sortOrder.column === "modified" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
<th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th> <th data-column="uploaded" class="hide-small hide-medium sortable-col">${t("upload_date")} ${sortOrder.column === "uploaded" ? (sortOrder.ascending ? "▲" : "▼") : ""}</th>
@@ -162,15 +190,20 @@ export function buildFileTableRow(file, folderPath) {
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) { } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
previewIcon = `<i class="material-icons">audiotrack</i>`; previewIcon = `<i class="material-icons">audiotrack</i>`;
} }
previewButton = `<button class="btn btn-sm btn-info preview-btn" onclick="event.stopPropagation(); previewFile('${folderPath + encodeURIComponent(file.name)}', '${safeFileName}')"> previewButton = `<button
${previewIcon} type="button"
</button>`; class="btn btn-sm btn-info preview-btn"
data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
data-preview-name="${safeFileName}"
title="${t('preview')}">
${previewIcon}
</button>`;
} }
return ` return `
<tr onclick="toggleRowSelection(event, '${safeFileName}')" class="clickable-row"> <tr class="clickable-row">
<td> <td>
<input type="checkbox" class="file-checkbox" value="${safeFileName}" onclick="event.stopPropagation(); updateRowHighlight(this);"> <input type="checkbox" class="file-checkbox" value="${safeFileName}">
</td> </td>
<td class="file-name-cell">${safeFileName}</td> <td class="file-name-cell">${safeFileName}</td>
<td class="hide-small nowrap">${safeModified}</td> <td class="hide-small nowrap">${safeModified}</td>
@@ -178,25 +211,44 @@ export function buildFileTableRow(file, folderPath) {
<td class="hide-small nowrap">${safeSize}</td> <td class="hide-small nowrap">${safeSize}</td>
<td class="hide-small hide-medium nowrap">${safeUploader}</td> <td class="hide-small hide-medium nowrap">${safeUploader}</td>
<td> <td>
<div class="button-wrap" style="display: flex; justify-content: left; gap: 5px;"> <div class="btn-group btn-group-sm" role="group" aria-label="File actions">
<button type="button" class="btn btn-sm btn-success download-btn" <button
onclick="openDownloadModal('${file.name}', '${file.folder || 'root'}')" type="button"
class="btn btn-sm btn-success download-btn"
data-download-name="${file.name}"
data-download-folder="${file.folder || 'root'}"
title="${t('download')}"> title="${t('download')}">
<i class="material-icons">file_download</i> <i class="material-icons">file_download</i>
</button> </button>
${file.editable ? ` ${file.editable ? `
<button class="btn btn-sm edit-btn" <button
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' type="button"
title="${t('edit')}"> class="btn btn-sm btn-secondary edit-btn"
<i class="material-icons">edit</i> data-edit-name="${file.name}"
</button> data-edit-folder="${file.folder || 'root'}"
` : ""} title="${t('edit')}">
<i class="material-icons">edit</i>
</button>` : ""}
${previewButton} ${previewButton}
<button class="btn btn-sm btn-warning rename-btn"
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' <button
title="${t('rename')}"> type="button"
class="btn btn-sm btn-warning rename-btn"
data-rename-name="${file.name}"
data-rename-folder="${file.folder || 'root'}"
title="${t('rename')}">
<i class="material-icons">drive_file_rename_outline</i> <i class="material-icons">drive_file_rename_outline</i>
</button> </button>
<!-- share -->
<button
type="button"
class="btn btn-secondary btn-sm share-btn ms-1"
data-file="${safeFileName}"
title="${t('share')}">
<i class="material-icons">share</i>
</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -207,10 +259,10 @@ export function buildBottomControls(itemsPerPageSetting) {
return ` return `
<div class="d-flex align-items-center mt-3 bottom-controls"> <div class="d-flex align-items-center mt-3 bottom-controls">
<label class="label-inline mr-2 mb-0">${t("show")}</label> <label class="label-inline mr-2 mb-0">${t("show")}</label>
<select class="form-control bottom-select" onchange="changeItemsPerPage(this.value)"> <select class="form-control bottom-select" id="itemsPerPageSelect">
${[10, 20, 50, 100] ${[10, 20, 50, 100]
.map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`) .map(num => `<option value="${num}" ${num === itemsPerPageSetting ? "selected" : ""}>${num}</option>`)
.join("")} .join("")}
</select> </select>
<span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span> <span class="items-per-page-text ml-2 mb-0">${t("items_per_page")}</span>
</div> </div>
@@ -277,8 +329,6 @@ export function toggleRowSelection(event, fileName) {
const start = Math.min(currentIndex, lastIndex); const start = Math.min(currentIndex, lastIndex);
const end = Math.max(currentIndex, lastIndex); const end = Math.max(currentIndex, lastIndex);
// If neither CTRL nor Meta is pressed, you might choose
// to clear existing selections. For this example we leave existing selections intact.
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
const cb = allRows[i].querySelector(".file-checkbox"); const cb = allRows[i].querySelector(".file-checkbox");
if (cb) { if (cb) {
@@ -345,4 +395,7 @@ export function showCustomConfirmModal(message) {
yesBtn.addEventListener("click", onYes); yesBtn.addEventListener("click", onYes);
noBtn.addEventListener("click", onNo); noBtn.addEventListener("click", onNo);
}); });
} }
window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight;

View File

@@ -32,23 +32,33 @@ export function loadSidebarOrder() {
updateSidebarVisibility(); updateSidebarVisibility();
} }
// NEW: Load header order from localStorage.
export function loadHeaderOrder() { export function loadHeaderOrder() {
const headerDropArea = document.getElementById('headerDropArea'); const headerDropArea = document.getElementById('headerDropArea');
if (!headerDropArea) return; if (!headerDropArea) return;
const orderStr = localStorage.getItem('headerOrder');
if (orderStr) { // 1) Clear out any icons that might already be in the drop area
const order = JSON.parse(orderStr); headerDropArea.innerHTML = '';
if (order.length > 0) {
order.forEach(id => { // 2) Read the saved array (or empty array if invalid/missing)
const card = document.getElementById(id); let stored;
// Only load if card is not already in header drop zone. try {
if (card && card.parentNode.id !== 'headerDropArea') { stored = JSON.parse(localStorage.getItem('headerOrder') || '[]');
insertCardInHeader(card, null); } catch {
} stored = [];
});
}
} }
// 3) Deduplicate IDs
const uniqueIds = Array.from(new Set(stored));
// 4) Re-insert exactly one icon per saved card ID
uniqueIds.forEach(id => {
const card = document.getElementById(id);
if (card) insertCardInHeader(card, null);
});
// 5) Persist the cleaned, deduped list back to storage
localStorage.setItem('headerOrder', JSON.stringify(uniqueIds));
} }
// Internal helper: update sidebar visibility based on its content. // Internal helper: update sidebar visibility based on its content.

View File

@@ -76,20 +76,86 @@ export function handleDownloadZipSelected(e) {
}, 100); }, 100);
}; };
export function handleCreateFileSelected(e) {
e.preventDefault(); e.stopImmediatePropagation();
const modal = document.getElementById('createFileModal');
modal.style.display = 'block';
setTimeout(() => {
const inp = document.getElementById('newFileCreateName');
if (inp) inp.focus();
}, 100);
}
/**
* Open the “New File” modal
*/
export function openCreateFileModal() {
const modal = document.getElementById('createFileModal');
const input = document.getElementById('createFileNameInput');
if (!modal || !input) {
console.error('Create-file modal or input not found');
return;
}
input.value = '';
modal.style.display = 'block';
setTimeout(() => input.focus(), 0);
}
export async function handleCreateFile(e) {
e.preventDefault();
const input = document.getElementById('createFileNameInput');
if (!input) return console.error('Create-file input missing');
const name = input.value.trim();
if (!name) {
showToast(t('newfile_placeholder')); // or a more explicit error
return;
}
const folder = window.currentFolder || 'root';
try {
const res = await fetch('/api/file/createFile.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type':'application/json',
'X-CSRF-Token': window.csrfToken
},
// ⚠️ must send `name`, not `filename`
body: JSON.stringify({ folder, name })
});
const js = await res.json();
if (!js.success) throw new Error(js.error);
showToast(t('file_created'));
loadFileList(folder);
} catch (err) {
showToast(err.message || t('error_creating_file'));
} finally {
document.getElementById('createFileModal').style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', () => {
const cancel = document.getElementById('cancelCreateFile');
const confirm = document.getElementById('confirmCreateFile');
if (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
if (confirm) confirm.addEventListener('click', handleCreateFile);
});
export function openDownloadModal(fileName, folder) { export function openDownloadModal(fileName, folder) {
// Store file details globally for the download confirmation function. // Store file details globally for the download confirmation function.
window.singleFileToDownload = fileName; window.singleFileToDownload = fileName;
window.currentFolder = folder || "root"; window.currentFolder = folder || "root";
// Optionally pre-fill the file name input in the modal. // Optionally pre-fill the file name input in the modal.
const input = document.getElementById("downloadFileNameInput"); const input = document.getElementById("downloadFileNameInput");
if (input) { if (input) {
input.value = fileName; // Use file name as-is (or modify if desired) input.value = fileName; // Use file name as-is (or modify if desired)
} }
// Show the single file download modal (a new modal element). // Show the single file download modal (a new modal element).
document.getElementById("downloadFileModal").style.display = "block"; document.getElementById("downloadFileModal").style.display = "block";
// Optionally focus the input after a short delay. // Optionally focus the input after a short delay.
setTimeout(() => { setTimeout(() => {
if (input) input.focus(); if (input) input.focus();
@@ -97,58 +163,34 @@ export function openDownloadModal(fileName, folder) {
} }
export function confirmSingleDownload() { export function confirmSingleDownload() {
// Get the file name from the modal. Users can change it if desired. // 1) Get and validate the filename
let fileName = document.getElementById("downloadFileNameInput").value.trim(); const input = document.getElementById("downloadFileNameInput");
const fileName = input.value.trim();
if (!fileName) { if (!fileName) {
showToast("Please enter a name for the file."); showToast("Please enter a name for the file.");
return; return;
} }
// Hide the download modal. // 2) Hide the download-name modal
document.getElementById("downloadFileModal").style.display = "none"; document.getElementById("downloadFileModal").style.display = "none";
// Show the progress modal (same as in your ZIP download flow).
document.getElementById("downloadProgressModal").style.display = "block"; // 3) Build the direct download URL
// Build the URL for download.php using GET parameters.
const folder = window.currentFolder || "root"; const folder = window.currentFolder || "root";
const downloadURL = "/api/file/download.php?folder=" + encodeURIComponent(folder) + const downloadURL = "/api/file/download.php"
"&file=" + encodeURIComponent(window.singleFileToDownload); + "?folder=" + encodeURIComponent(folder)
+ "&file=" + encodeURIComponent(window.singleFileToDownload);
fetch(downloadURL, {
method: "GET", // 4) Trigger native browser download
credentials: "include" const a = document.createElement("a");
}) a.href = downloadURL;
.then(response => { a.download = fileName;
if (!response.ok) { a.style.display = "none";
return response.text().then(text => { document.body.appendChild(a);
throw new Error("Failed to download file: " + text); a.click();
}); document.body.removeChild(a);
}
return response.blob(); // 5) Notify the user
}) showToast("Download started. Check your browsers download manager.");
.then(blob => {
if (!blob || blob.size === 0) {
throw new Error("Received empty file.");
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
// Hide the progress modal.
document.getElementById("downloadProgressModal").style.display = "none";
showToast("Download started.");
})
.catch(error => {
// Hide progress modal and show error.
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error downloading file:", error);
showToast("Error downloading file: " + error.message);
});
} }
export function handleExtractZipSelected(e) { export function handleExtractZipSelected(e) {
@@ -168,16 +210,21 @@ export function handleExtractZipSelected(e) {
showToast("No zip files selected."); showToast("No zip files selected.");
return; return;
} }
// Change progress modal text to "Extracting files..." // Prepare and show the spinner-only modal
const progressText = document.querySelector("#downloadProgressModal p"); const modal = document.getElementById("downloadProgressModal");
if (progressText) { const titleEl = document.getElementById("downloadProgressTitle");
progressText.textContent = "Extracting files..."; const spinner = modal.querySelector(".download-spinner");
} const progressBar = document.getElementById("downloadProgressBar");
const progressPct = document.getElementById("downloadProgressPercent");
// Show the progress modal.
document.getElementById("downloadProgressModal").style.display = "block"; if (titleEl) titleEl.textContent = "Extracting files…";
if (spinner) spinner.style.display = "inline-block";
if (progressBar) progressBar.style.display = "none";
if (progressPct) progressPct.style.display = "none";
modal.style.display = "block";
fetch("/api/file/extractZip.php", { fetch("/api/file/extractZip.php", {
method: "POST", method: "POST",
credentials: "include", credentials: "include",
@@ -192,45 +239,85 @@ export function handleExtractZipSelected(e) {
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(data => {
// Hide the progress modal once the request has completed. modal.style.display = "none";
document.getElementById("downloadProgressModal").style.display = "none";
if (data.success) { if (data.success) {
let toastMessage = "Zip file(s) extracted successfully!"; let msg = "Zip file(s) extracted successfully!";
if (data.extractedFiles && Array.isArray(data.extractedFiles) && data.extractedFiles.length) { if (Array.isArray(data.extractedFiles) && data.extractedFiles.length) {
toastMessage = "Extracted: " + data.extractedFiles.join(", "); msg = "Extracted: " + data.extractedFiles.join(", ");
} }
showToast(toastMessage); showToast(msg);
loadFileList(window.currentFolder); loadFileList(window.currentFolder);
} else { } else {
showToast("Error extracting zip: " + (data.error || "Unknown error")); showToast("Error extracting zip: " + (data.error || "Unknown error"));
} }
}) })
.catch(error => { .catch(error => {
// Hide the progress modal on error. modal.style.display = "none";
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error extracting zip files:", error); console.error("Error extracting zip files:", error);
showToast("Error extracting zip files."); showToast("Error extracting zip files.");
}); });
} }
const extractZipBtn = document.getElementById("extractZipBtn"); document.addEventListener("DOMContentLoaded", () => {
if (extractZipBtn) { const zipNameModal = document.getElementById("downloadZipModal");
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true)); const progressModal = document.getElementById("downloadProgressModal");
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected); const cancelZipBtn = document.getElementById("cancelDownloadZip");
} const confirmZipBtn = document.getElementById("confirmDownloadZip");
const cancelCreate = document.getElementById('cancelCreateFile');
document.addEventListener("DOMContentLoaded", function () {
const cancelDownloadZip = document.getElementById("cancelDownloadZip"); if (cancelCreate) {
if (cancelDownloadZip) { cancelCreate.addEventListener('click', () => {
cancelDownloadZip.addEventListener("click", function () { document.getElementById('createFileModal').style.display = 'none';
document.getElementById("downloadZipModal").style.display = "none";
}); });
} }
// This part remains in your confirmDownloadZip event handler: const confirmCreate = document.getElementById('confirmCreateFile');
const confirmDownloadZip = document.getElementById("confirmDownloadZip"); if (confirmCreate) {
if (confirmDownloadZip) { confirmCreate.addEventListener('click', async () => {
confirmDownloadZip.addEventListener("click", function () { const name = document.getElementById('newFileCreateName').value.trim();
if (!name) {
showToast(t('please_enter_filename'));
return;
}
document.getElementById('createFileModal').style.display = 'none';
try {
const res = await fetch('/api/file/createFile.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({
folder: window.currentFolder || 'root',
filename: name
})
});
const js = await res.json();
if (!res.ok || !js.success) {
throw new Error(js.error || t('error_creating_file'));
}
showToast(t('file_created_successfully'));
loadFileList(window.currentFolder);
} catch (err) {
console.error(err);
showToast(err.message || t('error_creating_file'));
}
});
attachEnterKeyListener('createFileModal','confirmCreateFile');
}
// 1) Cancel button hides the name modal
if (cancelZipBtn) {
cancelZipBtn.addEventListener("click", () => {
zipNameModal.style.display = "none";
});
}
// 2) Confirm button kicks off the zip+download
if (confirmZipBtn) {
confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim(); let zipName = document.getElementById("zipFileNameInput").value.trim();
if (!zipName) { if (!zipName) {
showToast("Please enter a name for the zip file."); showToast("Please enter a name for the zip file.");
@@ -239,52 +326,56 @@ document.addEventListener("DOMContentLoaded", function () {
if (!zipName.toLowerCase().endsWith(".zip")) { if (!zipName.toLowerCase().endsWith(".zip")) {
zipName += ".zip"; zipName += ".zip";
} }
// Hide the ZIP name input modal
document.getElementById("downloadZipModal").style.display = "none"; // b) Hide the nameinput modal, show the spinner modal
// Show the progress modal here only on confirm zipNameModal.style.display = "none";
console.log("Download confirmed. Showing progress modal."); progressModal.style.display = "block";
document.getElementById("downloadProgressModal").style.display = "block";
const folder = window.currentFolder || "root"; // c) (Optional) update the “Preparing…” text if you gave it an ID
fetch("/api/file/downloadZip.php", { const titleEl = document.getElementById("downloadProgressTitle");
method: "POST", if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
credentials: "include",
headers: { try {
"Content-Type": "application/json", // d) POST and await the ZIP blob
"X-CSRF-Token": window.csrfToken const res = await fetch("/api/file/downloadZip.php", {
}, method: "POST",
body: JSON.stringify({ folder: folder, files: window.filesToDownload }) credentials: "include",
}) headers: {
.then(response => { "Content-Type": "application/json",
if (!response.ok) { "X-CSRF-Token": window.csrfToken
return response.text().then(text => { },
throw new Error("Failed to create zip file: " + text); body: JSON.stringify({
}); folder: window.currentFolder || "root",
} files: window.filesToDownload
return response.blob(); })
})
.then(blob => {
if (!blob || blob.size === 0) {
throw new Error("Received empty zip file.");
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
// Hide the progress modal after download starts
document.getElementById("downloadProgressModal").style.display = "none";
showToast("Download started.");
})
.catch(error => {
// Hide the progress modal on error
document.getElementById("downloadProgressModal").style.display = "none";
console.error("Error downloading zip:", error);
showToast("Error downloading selected files as zip: " + error.message);
}); });
if (!res.ok) {
const txt = await res.text();
throw new Error(txt || `Status ${res.status}`);
}
const blob = await res.blob();
if (!blob || blob.size === 0) {
throw new Error("Received empty ZIP file.");
}
// e) Hand off to the browsers download manager
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = zipName;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
} catch (err) {
console.error("Error downloading ZIP:", err);
showToast("Error: " + err.message);
} finally {
// f) Always hide spinner modal
progressModal.style.display = "none";
}
}); });
} }
}); });
@@ -571,6 +662,61 @@ export function initFileActions() {
extractZipBtn.replaceWith(extractZipBtn.cloneNode(true)); extractZipBtn.replaceWith(extractZipBtn.cloneNode(true));
document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected); document.getElementById("extractZipBtn").addEventListener("click", handleExtractZipSelected);
} }
const createBtn = document.getElementById('createFileBtn');
if (createBtn) {
createBtn.replaceWith(createBtn.cloneNode(true));
document.getElementById('createFileBtn').addEventListener('click', openCreateFileModal);
}
} }
// Hook up the singlefile download modal buttons
document.addEventListener("DOMContentLoaded", () => {
const cancelDownloadFileBtn = document.getElementById("cancelDownloadFile");
if (cancelDownloadFileBtn) {
cancelDownloadFileBtn.addEventListener("click", () => {
document.getElementById("downloadFileModal").style.display = "none";
});
}
const confirmSingleDownloadBtn = document.getElementById("confirmSingleDownloadButton");
if (confirmSingleDownloadBtn) {
confirmSingleDownloadBtn.addEventListener("click", confirmSingleDownload);
}
// Make Enter also confirm the download
attachEnterKeyListener("downloadFileModal", "confirmSingleDownloadButton");
});
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu');
const fileOpt = document.getElementById('createFileOption');
const folderOpt= document.getElementById('createFolderOption');
// Toggle dropdown on click
btn.addEventListener('click', (e) => {
e.stopPropagation();
menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
});
// Create File
fileOpt.addEventListener('click', () => {
menu.style.display = 'none';
openCreateFileModal(); // your existing function
});
// Create Folder
folderOpt.addEventListener('click', () => {
menu.style.display = 'none';
document.getElementById('createFolderModal').style.display = 'block';
document.getElementById('newFolderName').focus();
});
// Close if you click anywhere else
document.addEventListener('click', () => {
menu.style.display = 'none';
});
});
window.renameFile = renameFile; window.renameFile = renameFile;

View File

@@ -3,20 +3,143 @@ import { escapeHTML, showToast } from './domUtils.js';
import { loadFileList } from './fileListView.js'; import { loadFileList } from './fileListView.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
// thresholds for editor behavior
const EDITOR_PLAIN_THRESHOLD = 5 * 1024 * 1024; // >5 MiB => force plain text, lighter settings
const EDITOR_BLOCK_THRESHOLD = 10 * 1024 * 1024; // >10 MiB => block editing
// Lazy-load CodeMirror modes on demand
const CM_CDN = "https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.5/";
const MODE_URL = {
// core you've likely already loaded:
"xml": "mode/xml/xml.min.js",
"css": "mode/css/css.min.js",
"javascript": "mode/javascript/javascript.min.js",
// extras you may want on-demand:
"htmlmixed": "mode/htmlmixed/htmlmixed.min.js",
"application/x-httpd-php": "mode/php/php.min.js",
"php": "mode/php/php.min.js",
"markdown": "mode/markdown/markdown.min.js",
"python": "mode/python/python.min.js",
"sql": "mode/sql/sql.min.js",
"shell": "mode/shell/shell.min.js",
"yaml": "mode/yaml/yaml.min.js",
"properties": "mode/properties/properties.min.js",
"text/x-csrc": "mode/clike/clike.min.js",
"text/x-c++src": "mode/clike/clike.min.js",
"text/x-java": "mode/clike/clike.min.js",
"text/x-csharp": "mode/clike/clike.min.js",
"text/x-kotlin": "mode/clike/clike.min.js"
};
function loadScriptOnce(url) {
return new Promise((resolve, reject) => {
const key = `cm:${url}`;
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);
return;
}
s = document.createElement("script");
s.src = url;
s.defer = true;
s.dataset.key = key;
s.addEventListener("load", () => { s.dataset.loaded = "1"; resolve(); });
s.addEventListener("error", reject);
document.head.appendChild(s);
});
}
async function ensureModeLoaded(modeOption) {
if (!window.CodeMirror) return; // CM core must be present
const name = typeof modeOption === "string" ? modeOption : (modeOption && modeOption.name);
if (!name) return;
// Already registered?
if ((CodeMirror.modes && CodeMirror.modes[name]) || (CodeMirror.mimeModes && CodeMirror.mimeModes[name])) {
return;
}
const url = MODE_URL[name];
if (!url) return; // unknown -> fallback to text/plain
// Dependencies (htmlmixed needs xml/css/js; php highlighting with HTML also benefits from htmlmixed)
if (name === "htmlmixed") {
await Promise.all([
ensureModeLoaded("xml"),
ensureModeLoaded("css"),
ensureModeLoaded("javascript")
]);
}
if (name === "application/x-httpd-php") {
await ensureModeLoaded("htmlmixed");
}
await loadScriptOnce(CM_CDN + url);
}
function getModeForFile(fileName) { function getModeForFile(fileName) {
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase(); const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
switch (ext) { switch (ext) {
case "css": // markup
return "css";
case "json":
return { name: "javascript", json: true };
case "js":
return "javascript";
case "html": case "html":
case "htm": case "htm":
return "text/html"; return "text/html"; // ensureModeLoaded will map to htmlmixed
case "xml": case "xml":
return "xml"; return "xml";
case "md":
case "markdown":
return "markdown";
case "yml":
case "yaml":
return "yaml";
// styles & scripts
case "css":
return "css";
case "js":
return "javascript";
case "json":
return { name: "javascript", json: true };
// server / langs
case "php":
return "application/x-httpd-php";
case "py":
return "python";
case "sql":
return "sql";
case "sh":
case "bash":
case "zsh":
case "bat":
return "shell";
// config-y files
case "ini":
case "conf":
case "config":
case "properties":
return "properties";
// C-family / JVM
case "c":
case "h":
return "text/x-csrc";
case "cpp":
case "cxx":
case "hpp":
case "hh":
case "hxx":
return "text/x-c++src";
case "java":
return "text/x-java";
case "cs":
return "text/x-csharp";
case "kt":
case "kts":
return "text/x-kotlin";
default: default:
return "text/plain"; return "text/plain";
} }
@@ -47,6 +170,7 @@ export function editFile(fileName, folder) {
if (existingEditor) { if (existingEditor) {
existingEditor.remove(); existingEditor.remove();
} }
const folderUsed = folder || window.currentFolder || "root"; const folderUsed = folder || window.currentFolder || "root";
const folderPath = folderUsed === "root" const folderPath = folderUsed === "root"
? "uploads/" ? "uploads/"
@@ -55,26 +179,40 @@ export function editFile(fileName, folder) {
fetch(fileUrl, { method: "HEAD" }) fetch(fileUrl, { method: "HEAD" })
.then(response => { .then(response => {
const contentLength = response.headers.get("Content-Length"); const lenHeader =
if (contentLength !== null && parseInt(contentLength) > 10485760) { response.headers.get("content-length") ??
response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
if (sizeBytes !== null && sizeBytes > EDITOR_BLOCK_THRESHOLD) {
showToast("This file is larger than 10 MB and cannot be edited in the browser."); showToast("This file is larger than 10 MB and cannot be edited in the browser.");
throw new Error("File too large."); throw new Error("File too large.");
} }
return fetch(fileUrl); return response;
}) })
.then(() => fetch(fileUrl))
.then(response => { .then(response => {
if (!response.ok) { if (!response.ok) {
throw new Error("HTTP error! Status: " + response.status); throw new Error("HTTP error! Status: " + response.status);
} }
return response.text(); const lenHeader =
response.headers.get("content-length") ??
response.headers.get("Content-Length");
const sizeBytes = lenHeader ? parseInt(lenHeader, 10) : null;
return Promise.all([response.text(), sizeBytes]);
}) })
.then(content => { .then(([content, sizeBytes]) => {
const forcePlainText =
sizeBytes !== null && sizeBytes > EDITOR_PLAIN_THRESHOLD;
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.id = "editorContainer"; modal.id = "editorContainer";
modal.classList.add("modal", "editor-modal"); modal.classList.add("modal", "editor-modal");
modal.innerHTML = ` modal.innerHTML = `
<div class="editor-header"> <div class="editor-header">
<h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}</h3> <h3 class="editor-title">${t("editing")}: ${escapeHTML(fileName)}${
forcePlainText ? " <span style='font-size:.8em;opacity:.7'>(plain text mode)</span>" : ""
}</h3>
<div class="editor-controls"> <div class="editor-controls">
<button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button> <button id="decreaseFont" class="btn btn-sm btn-secondary">${t("decrease_font")}</button>
<button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button> <button id="increaseFont" class="btn btn-sm btn-secondary">${t("increase_font")}</button>
@@ -90,61 +228,74 @@ export function editFile(fileName, folder) {
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = "block"; modal.style.display = "block";
const mode = getModeForFile(fileName);
const isDarkMode = document.body.classList.contains("dark-mode"); const isDarkMode = document.body.classList.contains("dark-mode");
const theme = isDarkMode ? "material-darker" : "default"; const theme = isDarkMode ? "material-darker" : "default";
const editor = CodeMirror.fromTextArea(document.getElementById("fileEditor"), { // choose mode + lighter settings for large files
lineNumbers: true, const mode = forcePlainText ? "text/plain" : getModeForFile(fileName);
const cmOptions = {
lineNumbers: !forcePlainText,
mode: mode, mode: mode,
theme: theme, theme: theme,
viewportMargin: Infinity viewportMargin: forcePlainText ? 20 : Infinity,
}); lineWrapping: false,
};
window.currentEditor = editor; // ✅ LOAD MODE FIRST, THEN INSTANTIATE CODEMIRROR
ensureModeLoaded(mode).finally(() => {
const editor = CodeMirror.fromTextArea(
document.getElementById("fileEditor"),
cmOptions
);
setTimeout(() => { window.currentEditor = editor;
adjustEditorSize();
}, 50);
observeModalResize(modal); setTimeout(() => {
adjustEditorSize();
}, 50);
let currentFontSize = 14; observeModalResize(modal);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
document.getElementById("closeEditorX").addEventListener("click", function () { let currentFontSize = 14;
modal.remove();
});
document.getElementById("decreaseFont").addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px"; editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh(); editor.refresh();
document.getElementById("closeEditorX").addEventListener("click", function () {
modal.remove();
});
document.getElementById("decreaseFont").addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("increaseFont").addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("saveBtn").addEventListener("click", function () {
saveFile(fileName, folderUsed);
});
document.getElementById("closeBtn").addEventListener("click", function () {
modal.remove();
});
function updateEditorTheme() {
const isDark = document.body.classList.contains("dark-mode");
editor.setOption("theme", isDark ? "material-darker" : "default");
}
const toggle = document.getElementById("darkModeToggle");
if (toggle) toggle.addEventListener("click", updateEditorTheme);
}); });
document.getElementById("increaseFont").addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2);
editor.getWrapperElement().style.fontSize = currentFontSize + "px";
editor.refresh();
});
document.getElementById("saveBtn").addEventListener("click", function () {
saveFile(fileName, folderUsed);
});
document.getElementById("closeBtn").addEventListener("click", function () {
modal.remove();
});
function updateEditorTheme() {
const isDarkMode = document.body.classList.contains("dark-mode");
editor.setOption("theme", isDarkMode ? "material-darker" : "default");
}
document.getElementById("darkModeToggle").addEventListener("click", updateEditorTheme);
}) })
.catch(error => console.error("Error loading file:", error)); .catch(error => {
if (error && error.name === "AbortError") return;
console.error("Error loading file:", error);
});
} }

View File

@@ -16,10 +16,31 @@ import { t } from './i18n.js';
import { bindFileListContextMenu } from './fileMenu.js'; import { bindFileListContextMenu } from './fileMenu.js';
import { openDownloadModal } from './fileActions.js'; import { openDownloadModal } from './fileActions.js';
import { openTagModal, openMultiTagModal } from './fileTags.js'; import { openTagModal, openMultiTagModal } from './fileTags.js';
import {
getParentFolder,
updateBreadcrumbTitle,
setupBreadcrumbDelegation,
showFolderManagerContextMenu,
hideFolderManagerContextMenu,
openRenameFolderModal,
openDeleteFolderModal
} from './folderManager.js';
import { openFolderShareModal } from './folderShareModal.js';
import {
folderDragOverHandler,
folderDragLeaveHandler,
folderDropHandler
} from './fileDragDrop.js';
export let fileData = []; export let fileData = [];
export let sortOrder = { column: "uploaded", ascending: true }; export let sortOrder = { column: "uploaded", ascending: true };
// Hide "Edit" for files >10 MiB
const MAX_EDIT_BYTES = 10 * 1024 * 1024;
// Latest-response-wins guard (prevents double render/flicker if loadFileList gets called twice)
let __fileListReqSeq = 0;
window.itemsPerPage = parseInt( window.itemsPerPage = parseInt(
localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10', localStorage.getItem('itemsPerPage') || window.itemsPerPage || '10',
10 10
@@ -186,100 +207,323 @@ export function formatFolderName(folder) {
window.toggleRowSelection = toggleRowSelection; window.toggleRowSelection = toggleRowSelection;
window.updateRowHighlight = updateRowHighlight; window.updateRowHighlight = updateRowHighlight;
/** export async function loadFileList(folderParam) {
* --- FILE LIST & VIEW RENDERING --- const reqId = ++__fileListReqSeq; // latest call wins
*/
export function loadFileList(folderParam) {
const folder = folderParam || "root"; const folder = folderParam || "root";
const fileListContainer = document.getElementById("fileList"); const fileListContainer = document.getElementById("fileList");
const actionsContainer = document.getElementById("fileListActions");
// 1) show loader (only this request is allowed to render)
fileListContainer.style.visibility = "hidden"; fileListContainer.style.visibility = "hidden";
fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>"; fileListContainer.innerHTML = "<div class='loader'>Loading files...</div>";
return fetch("/api/file/getFileList.php?folder=" + encodeURIComponent(folder) + "&recursive=1&t=" + new Date().getTime()) try {
.then(response => { // Kick off both in parallel, but we'll render as soon as FILES are ready
if (response.status === 401) { const filesPromise = fetch(`/api/file/getFileList.php?folder=${encodeURIComponent(folder)}&recursive=1&t=${Date.now()}`);
showToast("Session expired. Please log in again."); const foldersPromise = fetch(`/api/folder/getFolderList.php?folder=${encodeURIComponent(folder)}`);
window.location.href = "/api/auth/logout.php";
throw new Error("Unauthorized");
}
return response.json();
})
.then(data => {
fileListContainer.innerHTML = ""; // Clear loading message.
if (data.files && Object.keys(data.files).length > 0) {
// If the returned "files" is an object instead of an array, transform it.
if (!Array.isArray(data.files)) {
data.files = Object.entries(data.files).map(([name, meta]) => {
meta.name = name;
return meta;
});
}
// Process each file add computed properties.
data.files = data.files.map(file => {
file.fullName = (file.path || file.name).trim().toLowerCase();
file.editable = canEditFile(file.name);
file.folder = folder;
if (!file.type && /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
file.type = "image";
}
// OPTIONAL: For text documents, preload content (if available from backend)
// Example: if (/\.txt|html|md|js|css|json|xml$/i.test(file.name)) { file.content = file.content || ""; }
return file;
});
fileData = data.files;
// Update file summary. // ----- FILES FIRST -----
const actionsContainer = document.getElementById("fileListActions"); const filesRes = await filesPromise;
if (actionsContainer) {
let summaryElem = document.getElementById("fileSummary");
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.float = "right";
summaryElem.style.marginLeft = "auto";
summaryElem.style.marginRight = "60px";
summaryElem.style.fontSize = "0.9em";
actionsContainer.appendChild(summaryElem);
} else {
summaryElem.style.display = "block";
}
summaryElem.innerHTML = buildFolderSummary(fileData);
}
// Render view based on the view mode. if (filesRes.status === 401) {
if (window.viewMode === "gallery") { window.location.href = "/api/auth/logout.php";
renderGalleryView(folder); throw new Error("Unauthorized");
updateFileActionButtons(); }
} else {
renderFileTable(folder); const data = await filesRes.json();
}
} else { // If another loadFileList ran after this one, bail before touching the DOM
fileListContainer.textContent = t("no_files_found"); if (reqId !== __fileListReqSeq) return [];
const summaryElem = document.getElementById("fileSummary");
if (summaryElem) { // 3) clear loader (still only if this request is the latest)
summaryElem.style.display = "none"; fileListContainer.innerHTML = "";
}
updateFileActionButtons(); // 4) handle “no files” case
} if (!data.files || Object.keys(data.files).length === 0) {
return data.files || []; if (reqId !== __fileListReqSeq) return [];
}) fileListContainer.textContent = t("no_files_found");
.catch(error => {
console.error("Error loading file list:", error); // hide summary + slider
if (error.message !== "Unauthorized") { const summaryElem = document.getElementById("fileSummary");
fileListContainer.textContent = "Error loading files."; if (summaryElem) summaryElem.style.display = "none";
} const sliderContainer = document.getElementById("viewSliderContainer");
return []; if (sliderContainer) sliderContainer.style.display = "none";
})
.finally(() => { // hide folder strip for now; well re-show it after folders load (below)
const strip = document.getElementById("folderStripContainer");
if (strip) strip.style.display = "none";
updateFileActionButtons();
fileListContainer.style.visibility = "visible"; fileListContainer.style.visibility = "visible";
return [];
}
// 5) normalize files array
if (!Array.isArray(data.files)) {
data.files = Object.entries(data.files).map(([name, meta]) => {
meta.name = name;
return meta;
});
}
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
// Prefer numeric size if your API provides it; otherwise parse the "1.2 MB" string
let bytes = Number.isFinite(f.sizeBytes)
? f.sizeBytes
: parseSizeToBytes(String(f.size || ""));
if (!Number.isFinite(bytes)) bytes = Infinity;
// extension policy + size policy
f.editable = canEditFile(f.name) && (bytes <= MAX_EDIT_BYTES);
f.folder = folder;
return f;
}); });
fileData = data.files;
// Decide editability BEFORE render to avoid any post-render “blink”
data.files = data.files.map(f => {
f.fullName = (f.path || f.name).trim().toLowerCase();
// extension policy
const extOk = canEditFile(f.name);
// prefer numeric byte size if API provides it; otherwise parse "12.3 MB" strings
let bytes = Infinity;
if (Number.isFinite(f.sizeBytes)) {
bytes = f.sizeBytes;
} else if (f.size != null && String(f.size).trim() !== "") {
bytes = parseSizeToBytes(String(f.size));
}
f.editable = extOk && (bytes <= MAX_EDIT_BYTES);
f.folder = folder;
return f;
});
fileData = data.files;
// If stale, stop before any DOM updates
if (reqId !== __fileListReqSeq) return [];
// 6) inject summary + slider
if (actionsContainer) {
// a) summary
let summaryElem = document.getElementById("fileSummary");
if (!summaryElem) {
summaryElem = document.createElement("div");
summaryElem.id = "fileSummary";
summaryElem.style.cssText = "float:right; margin:0 60px 0 auto; font-size:0.9em;";
actionsContainer.appendChild(summaryElem);
}
summaryElem.style.display = "block";
summaryElem.innerHTML = buildFolderSummary(fileData);
// b) slider
const viewMode = window.viewMode || "table";
let sliderContainer = document.getElementById("viewSliderContainer");
if (!sliderContainer) {
sliderContainer = document.createElement("div");
sliderContainer.id = "viewSliderContainer";
sliderContainer.style.cssText = "display:inline-flex; align-items:center; margin-right:auto; font-size:0.9em;";
actionsContainer.insertBefore(sliderContainer, summaryElem);
} else {
sliderContainer.style.display = "inline-flex";
}
if (viewMode === "gallery") {
const w = window.innerWidth;
let maxCols;
if (w < 600) maxCols = 1;
else if (w < 900) maxCols = 2;
else if (w < 1200) maxCols = 4;
else maxCols = 6;
const currentCols = Math.min(
parseInt(localStorage.getItem("galleryColumns") || "3", 10),
maxCols
);
sliderContainer.innerHTML = `
<label for="galleryColumnsSlider" style="margin-right:8px;line-height:1;">
${t("columns")}:
</label>
<input
type="range"
id="galleryColumnsSlider"
min="1"
max="${maxCols}"
value="${currentCols}"
style="vertical-align:middle;"
>
<span id="galleryColumnsValue" style="margin-left:6px;line-height:1;">${currentCols}</span>
`;
const gallerySlider = document.getElementById("galleryColumnsSlider");
const galleryValue = document.getElementById("galleryColumnsValue");
gallerySlider.oninput = e => {
const v = +e.target.value;
localStorage.setItem("galleryColumns", v);
galleryValue.textContent = v;
document.querySelector(".gallery-container")
?.style.setProperty("grid-template-columns", `repeat(${v},1fr)`);
};
} else {
const currentHeight = parseInt(localStorage.getItem("rowHeight") || "48", 10);
sliderContainer.innerHTML = `
<label for="rowHeightSlider" style="margin-right:8px;line-height:1;">
${t("row_height")}:
</label>
<input type="range" id="rowHeightSlider" min="30" max="60" value="${currentHeight}" style="vertical-align:middle;">
<span id="rowHeightValue" style="margin-left:6px;line-height:1;">${currentHeight}px</span>
`;
const rowSlider = document.getElementById("rowHeightSlider");
const rowValue = document.getElementById("rowHeightValue");
rowSlider.oninput = e => {
const v = e.target.value;
document.documentElement.style.setProperty("--file-row-height", v + "px");
localStorage.setItem("rowHeight", v);
rowValue.textContent = v + "px";
};
}
}
// 7) render files (only if still latest)
if (reqId !== __fileListReqSeq) return [];
if (window.viewMode === "gallery") {
renderGalleryView(folder);
} else {
renderFileTable(folder);
}
updateFileActionButtons();
fileListContainer.style.visibility = "visible";
// ----- FOLDERS NEXT (populate strip when ready; doesn't block rows) -----
try {
const foldersRes = await foldersPromise;
const folderRaw = await foldersRes.json();
if (reqId !== __fileListReqSeq) return data.files;
// --- build ONLY the *direct* children of current folder ---
let subfolders = [];
const hidden = new Set(["profile_pics", "trash"]);
if (Array.isArray(folderRaw)) {
const allPaths = folderRaw.map(item => item.folder ?? item);
const depth = folder === "root" ? 1 : folder.split("/").length + 1;
subfolders = allPaths
.filter(p => {
if (folder === "root") return p.indexOf("/") === -1;
if (!p.startsWith(folder + "/")) return false;
return p.split("/").length === depth;
})
.map(p => ({ name: p.split("/").pop(), full: p }));
}
subfolders = subfolders.filter(sf => !hidden.has(sf.name));
// inject folder strip below actions, above file list
let strip = document.getElementById("folderStripContainer");
if (!strip) {
strip = document.createElement("div");
strip.id = "folderStripContainer";
strip.className = "folder-strip-container";
actionsContainer.parentNode.insertBefore(strip, actionsContainer);
}
if (window.showFoldersInList && subfolders.length) {
strip.innerHTML = subfolders.map(sf => `
<div class="folder-item" data-folder="${sf.full}" draggable="true">
<i class="material-icons">folder</i>
<div class="folder-name">${escapeHTML(sf.name)}</div>
</div>
`).join("");
strip.style.display = "flex";
// wire up each foldertile
strip.querySelectorAll(".folder-item").forEach(el => {
// 1) click to navigate
el.addEventListener("click", () => {
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
updateBreadcrumbTitle(dest);
document.querySelectorAll(".folder-option.selected").forEach(o => o.classList.remove("selected"));
document.querySelector(`.folder-option[data-folder="${dest}"]`)?.classList.add("selected");
loadFileList(dest);
});
// 2) drag & drop
el.addEventListener("dragover", folderDragOverHandler);
el.addEventListener("dragleave", folderDragLeaveHandler);
el.addEventListener("drop", folderDropHandler);
// 3) right-click context menu
el.addEventListener("contextmenu", e => {
e.preventDefault();
e.stopPropagation();
const dest = el.dataset.folder;
window.currentFolder = dest;
localStorage.setItem("lastOpenedFolder", dest);
// highlight the strip tile
strip.querySelectorAll(".folder-item.selected").forEach(i => i.classList.remove("selected"));
el.classList.add("selected");
// reuse folderManager menu
const menuItems = [
{
label: t("create_folder"),
action: () => document.getElementById("createFolderModal").style.display = "block"
},
{
label: t("rename_folder"),
action: () => openRenameFolderModal()
},
{
label: t("folder_share"),
action: () => openFolderShareModal(dest)
},
{
label: t("delete_folder"),
action: () => openDeleteFolderModal()
}
];
showFolderManagerContextMenu(e.pageX, e.pageY, menuItems);
});
});
// one global click to hide any open context menu
document.addEventListener("click", hideFolderManagerContextMenu);
} else {
strip.style.display = "none";
}
} catch {
// ignore folder errors; rows already rendered
}
return data.files;
} catch (err) {
console.error("Error loading file list:", err);
if (err.message !== "Unauthorized") {
fileListContainer.textContent = "Error loading files.";
}
return [];
} finally {
// Only the latest call should restore visibility
if (reqId === __fileListReqSeq) {
fileListContainer.style.visibility = "visible";
}
}
} }
/** /**
* Update renderFileTable so it writes its content into the provided container. * Update renderFileTable so it writes its content into the provided container.
*/ */
export function renderFileTable(folder, container) { export function renderFileTable(folder, container, subfolders) {
const fileListContent = container || document.getElementById("fileList"); const fileListContent = container || document.getElementById("fileList");
const searchTerm = (window.currentSearchTerm || "").toLowerCase(); const searchTerm = (window.currentSearchTerm || "").toLowerCase();
const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10); const itemsPerPageSetting = parseInt(localStorage.getItem("itemsPerPage") || "10", 10);
@@ -327,9 +571,6 @@ export function renderFileTable(folder, container) {
rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => { rowHTML = rowHTML.replace(/(<td class="file-name-cell">)(.*?)(<\/td>)/, (match, p1, p2, p3) => {
return p1 + p2 + tagBadgesHTML + p3; return p1 + p2 + tagBadgesHTML + p3;
}); });
rowHTML = rowHTML.replace(/(<\/div>\s*<\/td>\s*<\/tr>)/, `<button class="share-btn btn btn-sm btn-secondary" data-file="${escapeHTML(file.name)}" title="${t('share')}">
<i class="material-icons">share</i>
</button>$1`);
rowsHTML += rowHTML; rowsHTML += rowHTML;
}); });
} else { } else {
@@ -340,6 +581,92 @@ export function renderFileTable(folder, container) {
fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML; fileListContent.innerHTML = combinedTopHTML + headerHTML + rowsHTML + bottomControlsHTML;
fileListContent.querySelectorAll('.folder-item').forEach(el => {
el.addEventListener('click', () => loadFileList(el.dataset.folder));
});
// pagination clicks
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderFileTable(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
// totalPages is computed above in this scope
if (window.currentPage < totalPages) {
window.currentPage++;
renderFileTable(folder, container);
}
});
// ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// items-per-page selector
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderFileTable(folder, container);
});
// hook up the master checkbox
const selectAll = document.getElementById("selectAll");
if (selectAll) {
selectAll.addEventListener("change", () => {
toggleAllCheckboxes(selectAll);
});
}
// 1) Row-click selects the row
fileListContent.querySelectorAll("tbody tr").forEach(row => {
row.addEventListener("click", e => {
// grab the underlying checkbox value
const cb = row.querySelector(".file-checkbox");
if (!cb) return;
toggleRowSelection(e, cb.value);
});
});
// 2) Download buttons
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
});
});
// 3) Edit buttons
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder);
});
});
// 4) Rename buttons
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
});
});
// 5) Preview buttons
fileListContent.querySelectorAll(".preview-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
previewFile(btn.dataset.previewUrl, btn.dataset.previewName);
});
});
createViewToggleButton(); createViewToggleButton();
// Setup event listeners. // Setup event listeners.
@@ -359,6 +686,17 @@ export function renderFileTable(folder, container) {
}, 0); }, 0);
}, 300)); }, 300));
} }
const slider = document.getElementById('rowHeightSlider');
const valueDisplay = document.getElementById('rowHeightValue');
if (slider) {
slider.addEventListener('input', e => {
const v = +e.target.value; // slider value in px
document.documentElement.style.setProperty('--file-row-height', v + 'px');
localStorage.setItem('rowHeight', v);
valueDisplay.textContent = v + 'px';
});
}
document.querySelectorAll("table.table thead th[data-column]").forEach(cell => { document.querySelectorAll("table.table thead th[data-column]").forEach(cell => {
cell.addEventListener("click", function () { cell.addEventListener("click", function () {
const column = this.getAttribute("data-column"); const column = this.getAttribute("data-column");
@@ -448,18 +786,17 @@ export function renderGalleryView(folder, container) {
} }
}, 0); }, 0);
// --- Column slider --- // --- Column slider with responsive max ---
const numColumns = window.galleryColumns || 3; const numColumns = window.galleryColumns || 3;
galleryHTML += ` // clamp slider max to 1 on small (<600px), 2 on medium (<900px), else up to 6
<div class="gallery-slider" style="margin:10px; text-align:center;"> const w = window.innerWidth;
<label for="galleryColumnsSlider" style="margin-right:5px;"> let maxCols = 6;
${t('columns')}: if (w < 600) maxCols = 1;
</label> else if (w < 900) maxCols = 2;
<input type="range" id="galleryColumnsSlider" min="1" max="6"
value="${numColumns}" style="vertical-align:middle;"> // ensure current value doesnt exceed the new max
<span id="galleryColumnsValue">${numColumns}</span> const startCols = Math.min(numColumns, maxCols);
</div> window.galleryColumns = startCols;
`;
// --- Start gallery grid --- // --- Start gallery grid ---
galleryHTML += ` galleryHTML += `
@@ -476,23 +813,26 @@ export function renderGalleryView(folder, container) {
pageFiles.forEach((file, idx) => { pageFiles.forEach((file, idx) => {
const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx); const idSafe = encodeURIComponent(file.name) + "-" + (startIdx + idx);
const cacheKey = folderPath + encodeURIComponent(file.name);
// thumbnail // thumbnail
let thumbnail; let thumbnail;
if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) { if (/\.(jpe?g|png|gif|bmp|webp|svg|ico)$/i.test(file.name)) {
const cacheKey = folderPath + encodeURIComponent(file.name);
if (window.imageCache && window.imageCache[cacheKey]) { if (window.imageCache && window.imageCache[cacheKey]) {
thumbnail = `<img src="${window.imageCache[cacheKey]}" thumbnail = `<img
class="gallery-thumbnail" src="${window.imageCache[cacheKey]}"
alt="${escapeHTML(file.name)}" class="gallery-thumbnail"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`; data-cache-key="${cacheKey}"
alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} else { } else {
const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now(); const imageUrl = folderPath + encodeURIComponent(file.name) + "?t=" + Date.now();
thumbnail = `<img src="${imageUrl}" thumbnail = `<img
onload="cacheImage(this,'${cacheKey}')" src="${imageUrl}"
class="gallery-thumbnail" class="gallery-thumbnail"
alt="${escapeHTML(file.name)}" data-cache-key="${cacheKey}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`; alt="${escapeHTML(file.name)}"
style="max-width:100%; max-height:${getMaxImageHeight()}px; display:block; margin:0 auto;">`;
} }
} else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) { } else if (/\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i.test(file.name)) {
thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`; thumbnail = `<span class="material-icons gallery-icon">audiotrack</span>`;
@@ -529,9 +869,9 @@ export function renderGalleryView(folder, container) {
<label for="cb-${idSafe}" <label for="cb-${idSafe}"
style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label> style="position:absolute; top:5px; left:5px; width:16px; height:16px;"></label>
<div class="gallery-preview" <div class="gallery-preview" style="cursor:pointer;"
style="cursor:pointer;" data-preview-url="${folderPath + encodeURIComponent(file.name)}?t=${Date.now()}"
onclick="previewFile('${folderPath + encodeURIComponent(file.name)}?t='+Date.now(), '${file.name}')"> data-preview-name="${file.name}">
${thumbnail} ${thumbnail}
</div> </div>
@@ -542,29 +882,52 @@ export function renderGalleryView(folder, container) {
</span> </span>
${tagBadgesHTML} ${tagBadgesHTML}
<div class="button-wrap" style="display:flex; justify-content:center; gap:5px; margin-top:5px;"> <div
<button type="button" class="btn btn-sm btn-success download-btn" class="btn-group btn-group-sm btn-group-hover"
onclick="openDownloadModal('${file.name}', '${file.folder || "root"}')" role="group"
title="${t('download')}"> aria-label="File actions"
<i class="material-icons">file_download</i> style="margin-top:5px;"
</button> >
${file.editable ? ` <button
<button class="btn btn-sm edit-btn" type="button"
onclick='editFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' class="btn btn-success py-1 download-btn"
title="${t('Edit')}"> data-download-name="${escapeHTML(file.name)}"
<i class="material-icons">edit</i> data-download-folder="${file.folder || "root"}"
</button>` : ""} title="${t('download')}"
<button class="btn btn-sm btn-warning rename-btn" >
onclick='renameFile(${JSON.stringify(file.name)}, ${JSON.stringify(file.folder || "root")})' <i class="material-icons">file_download</i>
title="${t('rename')}"> </button>
<i class="material-icons">drive_file_rename_outline</i>
</button> ${file.editable ? `
<button class="btn btn-sm btn-secondary share-btn" <button
data-file="${escapeHTML(file.name)}" type="button"
title="${t('share')}"> class="btn btn-secondary py-1 edit-btn"
<i class="material-icons">share</i> data-edit-name="${escapeHTML(file.name)}"
</button> data-edit-folder="${file.folder || "root"}"
</div> title="${t('edit')}"
>
<i class="material-icons">edit</i>
</button>` : ""}
<button
type="button"
class="btn btn-warning py-1 rename-btn"
data-rename-name="${escapeHTML(file.name)}"
data-rename-folder="${file.folder || "root"}"
title="${t('rename')}"
>
<i class="material-icons">drive_file_rename_outline</i>
</button>
<button
type="button"
class="btn btn-secondary py-1 share-btn"
data-file="${escapeHTML(file.name)}"
title="${t('share')}"
>
<i class="material-icons">share</i>
</button>
</div>
</div> </div>
</div> </div>
@@ -579,13 +942,93 @@ export function renderGalleryView(folder, container) {
// render // render
fileListContent.innerHTML = galleryHTML; fileListContent.innerHTML = galleryHTML;
// ensure toggle button // --- Now wire up all behaviors without inline handlers ---
createViewToggleButton();
// attach listeners // ADD: pagination buttons for gallery
const prevBtn = document.getElementById("prevPageBtn");
if (prevBtn) prevBtn.addEventListener("click", () => {
if (window.currentPage > 1) {
window.currentPage--;
renderGalleryView(folder, container);
}
});
const nextBtn = document.getElementById("nextPageBtn");
if (nextBtn) nextBtn.addEventListener("click", () => {
if (window.currentPage < totalPages) {
window.currentPage++;
renderGalleryView(folder, container);
}
});
// ←— ADD: advanced search toggle
const advToggle = document.getElementById("advancedSearchToggle");
if (advToggle) advToggle.addEventListener("click", () => {
toggleAdvancedSearch();
});
// ←— ADD: wire up context-menu in gallery
bindFileListContextMenu();
// ADD: items-per-page selector for gallery
const itemsSelect = document.getElementById("itemsPerPageSelect");
if (itemsSelect) itemsSelect.addEventListener("change", e => {
window.itemsPerPage = parseInt(e.target.value, 10);
localStorage.setItem("itemsPerPage", window.itemsPerPage);
window.currentPage = 1;
renderGalleryView(folder, container);
});
// cache images on load
fileListContent.querySelectorAll('.gallery-thumbnail').forEach(img => {
const key = img.dataset.cacheKey;
img.addEventListener('load', () => cacheImage(img, key));
});
// preview clicks
fileListContent.querySelectorAll(".gallery-preview").forEach(el => {
el.addEventListener("click", () => {
previewFile(el.dataset.previewUrl, el.dataset.previewName);
});
});
// download clicks
fileListContent.querySelectorAll(".download-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
openDownloadModal(btn.dataset.downloadName, btn.dataset.downloadFolder);
});
});
// edit clicks
fileListContent.querySelectorAll(".edit-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
editFile(btn.dataset.editName, btn.dataset.editFolder);
});
});
// rename clicks
fileListContent.querySelectorAll(".rename-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
renameFile(btn.dataset.renameName, btn.dataset.renameFolder);
});
});
// share clicks
fileListContent.querySelectorAll(".share-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
const fileName = btn.dataset.file;
const fileObj = fileData.find(f => f.name === fileName);
if (fileObj) {
import('./filePreview.js').then(m => m.openShareModal(fileObj, folder));
}
});
});
// checkboxes // checkboxes
document.querySelectorAll(".file-checkbox").forEach(cb => { fileListContent.querySelectorAll(".file-checkbox").forEach(cb => {
cb.addEventListener("change", () => updateFileActionButtons()); cb.addEventListener("change", () => updateFileActionButtons());
}); });
@@ -603,14 +1046,13 @@ export function renderGalleryView(folder, container) {
}); });
} }
// pagination // pagination functions
window.changePage = newPage => { window.changePage = newPage => {
window.currentPage = newPage; window.currentPage = newPage;
if (window.viewMode === "gallery") renderGalleryView(folder); if (window.viewMode === "gallery") renderGalleryView(folder);
else renderFileTable(folder); else renderFileTable(folder);
}; };
// items per page
window.changeItemsPerPage = cnt => { window.changeItemsPerPage = cnt => {
window.itemsPerPage = +cnt; window.itemsPerPage = +cnt;
localStorage.setItem("itemsPerPage", cnt); localStorage.setItem("itemsPerPage", cnt);
@@ -619,8 +1061,9 @@ export function renderGalleryView(folder, container) {
else renderFileTable(folder); else renderFileTable(folder);
}; };
// update toolbar buttons // update toolbar and toggle button
updateFileActionButtons(); updateFileActionButtons();
createViewToggleButton();
} }
// Responsive slider constraints based on screen size. // Responsive slider constraints based on screen size.
@@ -730,12 +1173,64 @@ function parseCustomDate(dateStr) {
} }
export function canEditFile(fileName) { export function canEditFile(fileName) {
if (!fileName || typeof fileName !== "string") return false;
const dot = fileName.lastIndexOf(".");
if (dot < 0) return false;
const ext = fileName.slice(dot + 1).toLowerCase();
// Text/code-only. Intentionally exclude php/phtml/phar/etc.
const allowedExtensions = [ const allowedExtensions = [
"txt", "html", "htm", "css", "js", "json", "xml", // Plain text & docs (text)
"md", "py", "ini", "csv", "log", "conf", "config", "bat", "txt", "text", "md", "markdown", "rst",
"rtf", "doc", "docx"
// Web
"html", "htm", "xhtml", "shtml",
"css", "scss", "sass", "less",
// JS/TS
"js", "mjs", "cjs", "jsx",
"ts", "tsx",
// Data & config formats
"json", "jsonc", "ndjson",
"yml", "yaml", "toml", "xml", "plist",
"ini", "conf", "config", "cfg", "cnf", "properties", "props", "rc",
"env", "dotenv",
"csv", "tsv", "tab",
"log",
// Shell / scripts
"sh", "bash", "zsh", "ksh", "fish",
"bat", "cmd",
"ps1", "psm1", "psd1",
// Languages
"py", "pyw", // Python
"rb", // Ruby
"pl", "pm", // Perl
"go", // Go
"rs", // Rust
"java", // Java
"kt", "kts", // Kotlin
"scala", "sc", // Scala
"groovy", "gradle", // Groovy/Gradle
"c", "h", "cpp", "cxx", "cc", "hpp", "hh", "hxx", // C/C++
"m", "mm", // Obj-C / Obj-C++
"swift", // Swift
"cs", "fs", "fsx", // C#, F#
"dart",
"lua",
"r", "rmd",
// SQL
"sql",
// Front-end SFC/templates
"vue", "svelte",
"twig", "mustache", "hbs", "handlebars", "ejs", "pug", "jade"
]; ];
const ext = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase();
return allowedExtensions.includes(ext); return allowedExtensions.includes(ext);
} }

View File

@@ -1,6 +1,6 @@
// fileMenu.js // fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js'; import { updateRowHighlight, showToast } from './domUtils.js';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile } from './fileActions.js'; import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js';
import { previewFile } from './filePreview.js'; import { previewFile } from './filePreview.js';
import { editFile } from './fileEditor.js'; import { editFile } from './fileEditor.js';
import { canEditFile, fileData } from './fileListView.js'; import { canEditFile, fileData } from './fileListView.js';
@@ -75,6 +75,7 @@ export function fileListContextMenuHandler(e) {
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value); const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
let menuItems = [ let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } }, { label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } }, { label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } }, { label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },

View File

@@ -4,36 +4,68 @@ import { fileData } from './fileListView.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
export function openShareModal(file, folder) { export function openShareModal(file, folder) {
// Remove any existing modal
const existing = document.getElementById("shareModal"); const existing = document.getElementById("shareModal");
if (existing) existing.remove(); if (existing) existing.remove();
// Build the modal
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.id = "shareModal"; modal.id = "shareModal";
modal.classList.add("modal"); modal.classList.add("modal");
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content share-modal-content" style="width: 600px; max-width:90vw;"> <div class="modal-content share-modal-content" style="width:600px;max-width:90vw;">
<div class="modal-header"> <div class="modal-header">
<h3>${t("share_file")}: ${escapeHTML(file.name)}</h3> <h3>${t("share_file")}: ${escapeHTML(file.name)}</h3>
<span class="close-image-modal" id="closeShareModal" title="Close">&times;</span> <span id="closeShareModal" title="${t("close")}" class="close-image-modal">&times;</span>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>${t("set_expiration")}</p> <p>${t("set_expiration")}</p>
<select id="shareExpiration"> <select id="shareExpiration" style="width:100%;padding:5px;">
<option value="30">30 minutes</option> <option value="30">30 ${t("minutes")}</option>
<option value="60" selected>60 minutes</option> <option value="60" selected>60 ${t("minutes")}</option>
<option value="120">120 minutes</option> <option value="120">120 ${t("minutes")}</option>
<option value="180">180 minutes</option> <option value="180">180 ${t("minutes")}</option>
<option value="240">240 minutes</option> <option value="240">240 ${t("minutes")}</option>
<option value="1440">1 Day</option> <option value="1440">1 ${t("day")}</option>
<option value="custom">${t("custom")}&hellip;</option>
</select> </select>
<p>${t("password_optional")}</p>
<input type="text" id="sharePassword" placeholder=${t("password_optional")} style="width: 100%;"/> <div id="customExpirationContainer" style="display:none;margin-top:10px;">
<br> <label for="customExpirationValue">${t("duration")}:</label>
<button id="generateShareLinkBtn" class="btn btn-primary" style="margin-top:10px;">${t("generate_share_link")}</button> <input type="number" id="customExpirationValue" min="1" value="1" style="width:60px;margin:0 8px;"/>
<div id="shareLinkDisplay" style="margin-top: 10px; display:none;"> <select id="customExpirationUnit">
<option value="seconds">${t("seconds")}</option>
<option value="minutes" selected>${t("minutes")}</option>
<option value="hours">${t("hours")}</option>
<option value="days">${t("days")}</option>
</select>
<p class="share-warning" style="color:#a33;font-size:0.9em;margin-top:5px;">
${t("custom_duration_warning")}
</p>
</div>
<p style="margin-top:15px;">${t("password_optional")}</p>
<input
type="text"
id="sharePassword"
placeholder="${t("password_optional")}"
style="width:100%;padding:5px;"
/>
<button
id="generateShareLinkBtn"
class="btn btn-primary"
style="margin-top:15px;"
>
${t("generate_share_link")}
</button>
<div id="shareLinkDisplay" style="margin-top:15px;display:none;">
<p>${t("shareable_link")}</p> <p>${t("shareable_link")}</p>
<input type="text" id="shareLinkInput" readonly style="width:100%;"/> <input type="text" id="shareLinkInput" readonly style="width:100%;padding:5px;"/>
<button id="copyShareLinkBtn" class="btn btn-primary" style="margin-top:5px;">${t("copy_link")}</button> <button id="copyShareLinkBtn" class="btn btn-secondary" style="margin-top:5px;">
${t("copy_link")}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -41,52 +73,72 @@ export function openShareModal(file, folder) {
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = "block"; modal.style.display = "block";
document.getElementById("closeShareModal").addEventListener("click", () => { // Close handler
modal.remove(); document.getElementById("closeShareModal")
}); .addEventListener("click", () => modal.remove());
document.getElementById("generateShareLinkBtn").addEventListener("click", () => { // Show/hide custom-duration inputs
const expiration = document.getElementById("shareExpiration").value; document.getElementById("shareExpiration")
const password = document.getElementById("sharePassword").value; .addEventListener("change", e => {
fetch("/api/file/createShareLink.php", { const container = document.getElementById("customExpirationContainer");
method: "POST", container.style.display = e.target.value === "custom" ? "block" : "none";
credentials: "include", });
headers: {
"Content-Type": "application/json", // Generate share link
"X-CSRF-Token": window.csrfToken document.getElementById("generateShareLinkBtn")
}, .addEventListener("click", () => {
body: JSON.stringify({ const sel = document.getElementById("shareExpiration");
folder: folder, let value, unit;
file: file.name,
expirationMinutes: parseInt(expiration), if (sel.value === "custom") {
password: password value = parseInt(document.getElementById("customExpirationValue").value, 10);
unit = document.getElementById("customExpirationUnit").value;
} else {
value = parseInt(sel.value, 10);
unit = "minutes";
}
const password = document.getElementById("sharePassword").value;
fetch("/api/file/createShareLink.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder,
file: file.name,
expirationValue: value,
expirationUnit: unit,
password
})
}) })
}) .then(res => res.json())
.then(response => response.json())
.then(data => { .then(data => {
if (data.token) { if (data.token) {
const shareEndpoint = `${window.location.origin}/api/file/share.php`; const url = `${window.location.origin}/api/file/share.php?token=${encodeURIComponent(data.token)}`;
const shareUrl = `${shareEndpoint}?token=${encodeURIComponent(data.token)}`; document.getElementById("shareLinkInput").value = url;
const displayDiv = document.getElementById("shareLinkDisplay"); document.getElementById("shareLinkDisplay").style.display = "block";
const inputField = document.getElementById("shareLinkInput");
inputField.value = shareUrl;
displayDiv.style.display = "block";
} else { } else {
showToast("Error generating share link: " + (data.error || "Unknown error")); showToast(t("error_generating_share") + ": " + (data.error||"Unknown"));
} }
}) })
.catch(err => { .catch(err => {
console.error("Error generating share link:", err); console.error(err);
showToast("Error generating share link."); showToast(t("error_generating_share"));
}); });
}); });
document.getElementById("copyShareLinkBtn").addEventListener("click", () => { // Copy to clipboard
const input = document.getElementById("shareLinkInput"); document.getElementById("copyShareLinkBtn")
input.select(); .addEventListener("click", () => {
document.execCommand("copy"); const input = document.getElementById("shareLinkInput");
showToast("Link copied to clipboard!"); input.select();
}); document.execCommand("copy");
showToast(t("link_copied"));
});
} }
export function previewFile(fileUrl, fileName) { export function previewFile(fileUrl, fileName) {
@@ -364,16 +416,21 @@ export function previewFile(fileUrl, fileName) {
} }
} else { } else {
// Handle non-image file previews. // Handle non-image file previews.
if (extension === "pdf") { if (extension === "pdf") {
const embed = document.createElement("embed"); // build a cachebusted URL
const separator = fileUrl.indexOf('?') === -1 ? '?' : '&'; const separator = fileUrl.includes('?') ? '&' : '?';
embed.src = fileUrl + separator + 't=' + new Date().getTime(); const urlWithTs = fileUrl + separator + 't=' + Date.now();
embed.type = "application/pdf";
embed.style.width = "80vw"; // open in a new tab (avoids CSP frame-ancestors)
embed.style.height = "80vh"; window.open(urlWithTs, "_blank");
embed.style.border = "none";
container.appendChild(embed); // tear down the just-created modal
} else if (/\.(mp4|mkv|webm|mov|ogv)$/i.test(fileName)) { 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"); const video = document.createElement("video");
video.src = fileUrl; video.src = fileUrl;
video.controls = true; video.controls = true;

View File

@@ -13,10 +13,19 @@ export function openTagModal(file) {
modal.id = 'tagModal'; modal.id = 'tagModal';
modal.className = 'modal'; modal.className = 'modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-width:90vw;"> <div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">${t("tag_file")}: ${file.name}</h3> <h3 style="
<span id="closeTagModal" style="cursor:pointer; font-size:24px;">&times;</span> margin:0;
display:inline-block;
max-width: calc(100% - 40px);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
">
${t("tag_file")}: ${escapeHTML(file.name)}
</h3>
<span id="closeTagModal" class="editor-close-btn">&times;</span>
</div> </div>
<div class="modal-body" style="margin-top:10px;"> <div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t("tag_name")}</label> <label for="tagNameInput">${t("tag_name")}</label>
@@ -83,10 +92,10 @@ export function openMultiTagModal(files) {
modal.id = 'multiTagModal'; modal.id = 'multiTagModal';
modal.className = 'modal'; modal.className = 'modal';
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-width:90vw;"> <div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3> <h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
<span id="closeMultiTagModal" style="cursor:pointer; font-size:24px;">&times;</span> <span id="closeMultiTagModal" class="editor-close-btn">&times;</span>
</div> </div>
<div class="modal-body" style="margin-top:10px;"> <div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">Tag Name:</label> <label for="multiTagNameInput">Tag Name:</label>

View File

@@ -56,7 +56,7 @@ function saveFolderTreeState(state) {
} }
// Helper for getting the parent folder. // Helper for getting the parent folder.
function getParentFolder(folder) { export function getParentFolder(folder) {
if (folder === "root") return "root"; if (folder === "root") return "root";
const lastSlash = folder.lastIndexOf("/"); const lastSlash = folder.lastIndexOf("/");
return lastSlash === -1 ? "root" : folder.substring(0, lastSlash); return lastSlash === -1 ? "root" : folder.substring(0, lastSlash);
@@ -236,7 +236,8 @@ function renderFolderTree(tree, parentPath = "", defaultDisplay = "block") {
const state = loadFolderTreeState(); const state = loadFolderTreeState();
let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`; let html = `<ul class="folder-tree ${defaultDisplay === 'none' ? 'collapsed' : 'expanded'}">`;
for (const folder in tree) { for (const folder in tree) {
if (folder.toLowerCase() === "trash") continue; const name = folder.toLowerCase();
if (name === "trash" || name === "profile_pics") continue;
const fullPath = parentPath ? parentPath + "/" + folder : folder; const fullPath = parentPath ? parentPath + "/" + folder : folder;
const hasChildren = Object.keys(tree[folder]).length > 0; const hasChildren = Object.keys(tree[folder]).length > 0;
const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay; const displayState = state[fullPath] !== undefined ? state[fullPath] : defaultDisplay;
@@ -360,7 +361,7 @@ function renderBreadcrumbFragment(folderPath) {
return frag; return frag;
} }
function updateBreadcrumbTitle(folder) { export function updateBreadcrumbTitle(folder) {
const titleEl = document.getElementById("fileListTitle"); const titleEl = document.getElementById("fileListTitle");
titleEl.textContent = ""; titleEl.textContent = "";
titleEl.appendChild(document.createTextNode(t("files_in") + " (")); titleEl.appendChild(document.createTextNode(t("files_in") + " ("));
@@ -550,7 +551,7 @@ export function loadFolderList(selectedFolder) {
document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal); document.getElementById("renameFolderBtn").addEventListener("click", openRenameFolderModal);
document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal); document.getElementById("deleteFolderBtn").addEventListener("click", openDeleteFolderModal);
function openRenameFolderModal() { export function openRenameFolderModal() {
const selectedFolder = window.currentFolder || "root"; const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { if (!selectedFolder || selectedFolder === "root") {
showToast("Please select a valid folder to rename."); showToast("Please select a valid folder to rename.");
@@ -613,7 +614,7 @@ document.getElementById("submitRenameFolder").addEventListener("click", function
}); });
}); });
function openDeleteFolderModal() { export function openDeleteFolderModal() {
const selectedFolder = window.currentFolder || "root"; const selectedFolder = window.currentFolder || "root";
if (!selectedFolder || selectedFolder === "root") { if (!selectedFolder || selectedFolder === "root") {
showToast("Please select a valid folder to delete."); showToast("Please select a valid folder to delete.");
@@ -717,7 +718,7 @@ document.getElementById("submitCreateFolder").addEventListener("click", async ()
}); });
// ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ---------- // ---------- CONTEXT MENU SUPPORT FOR FOLDER MANAGER ----------
function showFolderManagerContextMenu(x, y, menuItems) { export function showFolderManagerContextMenu(x, y, menuItems) {
let menu = document.getElementById("folderManagerContextMenu"); let menu = document.getElementById("folderManagerContextMenu");
if (!menu) { if (!menu) {
menu = document.createElement("div"); menu = document.createElement("div");
@@ -764,7 +765,7 @@ function showFolderManagerContextMenu(x, y, menuItems) {
menu.style.display = "block"; menu.style.display = "block";
} }
function hideFolderManagerContextMenu() { export function hideFolderManagerContextMenu() {
const menu = document.getElementById("folderManagerContextMenu"); const menu = document.getElementById("folderManagerContextMenu");
if (menu) { if (menu) {
menu.style.display = "none"; menu.style.display = "none";
@@ -795,7 +796,7 @@ function folderManagerContextMenuHandler(e) {
}, },
{ {
label: t("folder_share"), label: t("folder_share"),
action: () => { openFolderShareModal(); } action: () => { openFolderShareModal(folder); }
}, },
{ {
label: t("delete_folder"), label: t("delete_folder"),

View File

@@ -1,44 +1,75 @@
// folderShareModal.js // js/folderShareModal.js
import { escapeHTML, showToast } from './domUtils.js'; import { escapeHTML, showToast } from './domUtils.js';
import { t } from './i18n.js'; import { t } from './i18n.js';
export function openFolderShareModal(folder) { export function openFolderShareModal(folder) {
// Remove any existing folder share modal // Remove any existing modal
const existing = document.getElementById("folderShareModal"); const existing = document.getElementById("folderShareModal");
if (existing) existing.remove(); if (existing) existing.remove();
// Create the modal container // Build modal
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.id = "folderShareModal"; modal.id = "folderShareModal";
modal.classList.add("modal"); modal.classList.add("modal");
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-content share-modal-content" style="width: 600px; max-width: 90vw;"> <div class="modal-content share-modal-content" style="width:600px;max-width:90vw;">
<div class="modal-header"> <div class="modal-header">
<h3>${t("share_folder")}: ${escapeHTML(folder)}</h3> <h3>${t("share_folder")}: ${escapeHTML(folder)}</h3>
<span class="close-image-modal" id="closeFolderShareModal" title="Close">&times;</span> <span id="closeFolderShareModal" title="${t("close")}" class="close-image-modal">&times;</span>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>${t("set_expiration")}</p> <p>${t("set_expiration")}</p>
<select id="folderShareExpiration"> <select id="folderShareExpiration" style="width:100%;padding:5px;">
<option value="30">30 ${t("minutes")}</option> <option value="30">30 ${t("minutes")}</option>
<option value="60" selected>60 ${t("minutes")}</option> <option value="60" selected>60 ${t("minutes")}</option>
<option value="120">120 ${t("minutes")}</option> <option value="120">120 ${t("minutes")}</option>
<option value="180">180 ${t("minutes")}</option> <option value="180">180 ${t("minutes")}</option>
<option value="240">240 ${t("minutes")}</option> <option value="240">240 ${t("minutes")}</option>
<option value="1440">1 ${t("day")}</option> <option value="1440">1 ${t("day")}</option>
<option value="custom">${t("custom")}&hellip;</option>
</select> </select>
<p>${t("password_optional")}</p>
<input type="text" id="folderSharePassword" placeholder="${t("enter_password")}" style="width: 100%;"/> <div id="customFolderExpirationContainer" style="display:none;margin-top:10px;">
<br> <label for="customFolderExpirationValue">${t("duration")}:</label>
<label> <input type="number" id="customFolderExpirationValue" min="1" value="1" style="width:60px;margin:0 8px;"/>
<input type="checkbox" id="folderShareAllowUpload"> ${t("allow_uploads")} <select id="customFolderExpirationUnit">
<option value="seconds">${t("seconds")}</option>
<option value="minutes" selected>${t("minutes")}</option>
<option value="hours">${t("hours")}</option>
<option value="days">${t("days")}</option>
</select>
<p class="share-warning" style="color:#a33;font-size:0.9em;margin-top:5px;">
${t("custom_duration_warning")}
</p>
</div>
<p style="margin-top:15px;">${t("password_optional")}</p>
<input
type="text"
id="folderSharePassword"
placeholder="${t("enter_password")}"
style="width:100%;padding:5px;"
/>
<label style="margin-top:10px;display:block;">
<input type="checkbox" id="folderShareAllowUpload" />
${t("allow_uploads")}
</label> </label>
<br><br>
<button id="generateFolderShareLinkBtn" class="btn btn-primary" style="margin-top: 10px;">${t("generate_share_link")}</button> <button
<div id="folderShareLinkDisplay" style="margin-top: 10px; display: none;"> id="generateFolderShareLinkBtn"
class="btn btn-primary"
style="margin-top:15px;"
>
${t("generate_share_link")}
</button>
<div id="folderShareLinkDisplay" style="margin-top:15px;display:none;">
<p>${t("shareable_link")}</p> <p>${t("shareable_link")}</p>
<input type="text" id="folderShareLinkInput" readonly style="width: 100%;"/> <input type="text" id="folderShareLinkInput" readonly style="width:100%;padding:5px;"/>
<button id="copyFolderShareLinkBtn" class="btn btn-primary" style="margin-top: 5px;">${t("copy_link")}</button> <button id="copyFolderShareLinkBtn" class="btn btn-secondary" style="margin-top:5px;">
${t("copy_link")}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -46,62 +77,75 @@ export function openFolderShareModal(folder) {
document.body.appendChild(modal); document.body.appendChild(modal);
modal.style.display = "block"; modal.style.display = "block";
// Close button handler // Close
document.getElementById("closeFolderShareModal").addEventListener("click", () => { document.getElementById("closeFolderShareModal")
modal.remove(); .addEventListener("click", () => modal.remove());
});
// Handler for generating the share link // Toggle custom inputs
document.getElementById("generateFolderShareLinkBtn").addEventListener("click", () => { document.getElementById("folderShareExpiration")
const expiration = document.getElementById("folderShareExpiration").value; .addEventListener("change", e => {
const password = document.getElementById("folderSharePassword").value; document.getElementById("customFolderExpirationContainer")
const allowUpload = document.getElementById("folderShareAllowUpload").checked ? 1 : 0; .style.display = e.target.value === "custom" ? "block" : "none";
});
// Retrieve the CSRF token from the meta tag.
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content"); // Generate link
if (!csrfToken) { document.getElementById("generateFolderShareLinkBtn")
showToast(t("csrf_error")); .addEventListener("click", () => {
return; const sel = document.getElementById("folderShareExpiration");
} let value, unit;
// Post to the createFolderShareLink endpoint. if (sel.value === "custom") {
fetch("/api/folder/createShareFolderLink.php", { value = parseInt(document.getElementById("customFolderExpirationValue").value, 10);
method: "POST", unit = document.getElementById("customFolderExpirationUnit").value;
credentials: "include", } else {
headers: { value = parseInt(sel.value, 10);
"Content-Type": "application/json", unit = "minutes";
"X-CSRF-Token": csrfToken }
},
body: JSON.stringify({ const password = document.getElementById("folderSharePassword").value;
folder: folder, const allowUpload = document.getElementById("folderShareAllowUpload").checked ? 1 : 0;
expirationMinutes: parseInt(expiration, 10), const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
password: password, if (!csrfToken) {
allowUpload: allowUpload showToast(t("csrf_error"));
return;
}
fetch("/api/folder/createShareFolderLink.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken
},
body: JSON.stringify({
folder,
expirationValue: value,
expirationUnit: unit,
password,
allowUpload
})
}) })
}) .then(r => r.json())
.then(response => response.json())
.then(data => { .then(data => {
if (data.token && data.link) { if (data.token && data.link) {
const shareUrl = data.link; document.getElementById("folderShareLinkInput").value = data.link;
const displayDiv = document.getElementById("folderShareLinkDisplay"); document.getElementById("folderShareLinkDisplay").style.display = "block";
const inputField = document.getElementById("folderShareLinkInput");
inputField.value = shareUrl;
displayDiv.style.display = "block";
showToast(t("share_link_generated")); showToast(t("share_link_generated"));
} else { } else {
showToast(t("error_generating_share_link") + ": " + (data.error || t("unknown_error"))); showToast(t("error_generating_share_link") + ": " + (data.error||t("unknown_error")));
} }
}) })
.catch(err => { .catch(err => {
console.error("Error generating folder share link:", err); console.error(err);
showToast(t("error_generating_share_link") + ": " + (err.error || t("unknown_error"))); showToast(t("error_generating_share_link") + ": " + t("unknown_error"));
}); });
}); });
// Copy share link button handler // Copy
document.getElementById("copyFolderShareLinkBtn").addEventListener("click", () => { document.getElementById("copyFolderShareLinkBtn")
const input = document.getElementById("folderShareLinkInput"); .addEventListener("click", () => {
input.select(); const inp = document.getElementById("folderShareLinkInput");
document.execCommand("copy"); inp.select();
showToast(t("link_copied")); document.execCommand("copy");
}); showToast(t("link_copied"));
});
} }

View File

@@ -55,6 +55,7 @@ const translations = {
// Additional keys for HTML translations: // Additional keys for HTML translations:
"title": "FileRise", "title": "FileRise",
"header_title": "FileRise", "header_title": "FileRise",
"header_title_text": "Header Title",
"logout": "Logout", "logout": "Logout",
"change_password": "Change Password", "change_password": "Change Password",
"restore_text": "Restore or", "restore_text": "Restore or",
@@ -150,6 +151,13 @@ const translations = {
"allow_uploads": "Allow Uploads", "allow_uploads": "Allow Uploads",
"share_link_generated": "Share Link Generated", "share_link_generated": "Share Link Generated",
"error_generating_share_link": "Error Generating Share Link", "error_generating_share_link": "Error Generating Share Link",
"custom": "Custom",
"duration": "Duration",
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours",
"days": "Days",
"custom_duration_warning": "⚠️ Using a long expiration may pose security risks. Use with caution.",
// Folder // Folder
"folder_share": "Share Folder", "folder_share": "Share Folder",
@@ -166,20 +174,39 @@ const translations = {
"user": "User:", "user": "User:",
"unknown_error": "Unknown Error", "unknown_error": "Unknown Error",
"link_copied": "Link Copied to Clipboard", "link_copied": "Link Copied to Clipboard",
"minutes": "minutes",
"hours": "hours",
"days": "days",
"weeks": "weeks", "weeks": "weeks",
"months": "months", "months": "months",
"seconds": "seconds",
// Dark Mode Toggle // Dark Mode Toggle
"dark_mode_toggle": "Dark Mode", "dark_mode_toggle": "Dark Mode",
"light_mode_toggle": "Light Mode", "light_mode_toggle": "Light Mode",
"switch_to_light_mode": "Switch to light mode",
"switch_to_dark_mode": "Switch to dark mode",
// Admin Panel
"header_settings": "Header Settings",
"shared_max_upload_size_bytes_title": "Shared Max Upload Size",
"shared_max_upload_size_bytes": "Shared Max Upload Size (bytes)",
"max_bytes_shared_uploads_note": "Enter maximum bytes allowed for shared-folder uploads",
"manage_shared_links": "Manage Shared Links",
"folder_shares": "Folder Shares",
"file_shares": "File Shares",
"loading": "Loading…",
"error_loading_share_links": "Error loading share links",
"share_deleted_successfully": "Share deleted successfully",
"error_deleting_share": "Error deleting share",
"password_protected": "Password protected",
"no_shared_links_available": "No shared links available",
// NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS: // NEW KEYS ADDED FOR ADMIN, USER PANELS, AND TOTP MODALS:
"admin_panel": "Admin Panel", "admin_panel": "Admin Panel",
"user_panel": "User Panel", "user_panel": "User Panel",
"user_settings": "User Settings",
"save_profile_picture": "Save Profile Picture",
"please_select_picture": "Please select a picture",
"profile_picture_updated": "Profile picture updated",
"error_updating_picture": "Error updating profile picture",
"trash_restore_delete": "Trash Restore/Delete", "trash_restore_delete": "Trash Restore/Delete",
"totp_settings": "TOTP Settings", "totp_settings": "TOTP Settings",
"enable_totp": "Enable TOTP", "enable_totp": "Enable TOTP",
@@ -237,8 +264,18 @@ const translations = {
"ok": "OK", "ok": "OK",
"show": "Show", "show": "Show",
"items_per_page": "items per page", "items_per_page": "items per page",
"columns":"Columns", "columns": "Columns",
"api_docs": "API Docs" "row_height": "Row Height",
"api_docs": "API Docs",
"show_folders_above_files": "Show folders above files",
"display": "Display",
"create_file": "Create File",
"create_new_file": "Create New File",
"enter_file_name": "Enter file name",
"newfile_placeholder": "New file name",
"file_created_successfully": "File created successfully!",
"error_creating_file": "Error creating file",
"file_created": "File created successfully!"
}, },
es: { es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.", "please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
@@ -295,6 +332,7 @@ const translations = {
// Additional keys for HTML translations: // Additional keys for HTML translations:
"title": "FileRise", "title": "FileRise",
"header_title": "FileRise", "header_title": "FileRise",
"header_title_text": "Header Title",
"logout": "Cerrar sesión", "logout": "Cerrar sesión",
"change_password": "Cambiar contraseña", "change_password": "Cambiar contraseña",
"restore_text": "Restaurar o", "restore_text": "Restaurar o",
@@ -804,7 +842,7 @@ const translations = {
"prev": "Zurück", "prev": "Zurück",
"next": "Weiter", "next": "Weiter",
"page": "Seite", "page": "Seite",
"of": "von", "of": "von",
// Login Form keys: // Login Form keys:
"login": "Anmelden", "login": "Anmelden",

View File

@@ -14,40 +14,96 @@ import { initFileActions, renameFile, openDownloadModal, confirmSingleDownload }
import { editFile, saveFile } from './fileEditor.js'; import { editFile, saveFile } from './fileEditor.js';
import { t, applyTranslations, setLocale } from './i18n.js'; import { t, applyTranslations, setLocale } from './i18n.js';
export function initializeApp() {
const saved = parseInt(localStorage.getItem('rowHeight') || '48', 10);
document.documentElement.style.setProperty('--file-row-height', saved + 'px');
window.currentFolder = "root";
initTagSearch();
loadFileList(window.currentFolder);
const stored = localStorage.getItem('showFoldersInList');
window.showFoldersInList = stored === null ? true : stored === 'true';
const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) {
fileListArea.addEventListener('dragover', e => {
e.preventDefault();
fileListArea.classList.add('drop-hover');
});
fileListArea.addEventListener('dragleave', () => {
fileListArea.classList.remove('drop-hover');
});
fileListArea.addEventListener('drop', e => {
e.preventDefault();
fileListArea.classList.remove('drop-hover');
// re-dispatch the same drop into the real upload card
uploadArea.dispatchEvent(new DragEvent('drop', {
dataTransfer: e.dataTransfer,
bubbles: true,
cancelable: true
}));
});
}
initDragAndDrop();
loadSidebarOrder();
loadHeaderOrder();
initFileActions();
initUpload();
loadFolderTree();
setupTrashRestoreDelete();
loadAdminConfigFunc();
const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip");
if (helpBtn && helpTooltip) {
helpBtn.addEventListener("click", () => {
helpTooltip.style.display =
helpTooltip.style.display === "block" ? "none" : "block";
});
}
}
export function loadCsrfToken() { export function loadCsrfToken() {
return fetchWithCsrf('/api/auth/token.php', { return fetchWithCsrf('/api/auth/token.php', { method: 'GET' })
method: 'GET'
})
.then(res => { .then(res => {
if (!res.ok) { if (!res.ok) throw new Error(`Token fetch failed with status ${res.status}`);
throw new Error(`Token fetch failed with status ${res.status}`);
}
return res.json(); return res.json();
}) })
.then(({ csrf_token, share_url }) => { .then(({ csrf_token, share_url }) => {
// Update global and <meta>
window.csrfToken = csrf_token; window.csrfToken = csrf_token;
let meta = document.querySelector('meta[name="csrf-token"]');
if (!meta) { // update CSRF meta
meta = document.createElement('meta'); let meta = document.querySelector('meta[name="csrf-token"]') ||
meta.name = 'csrf-token'; Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'csrf-token' });
document.head.appendChild(meta);
}
meta.content = csrf_token; meta.content = csrf_token;
let shareMeta = document.querySelector('meta[name="share-url"]'); // force share_url to match wherever we're browsing
if (!shareMeta) { const actualShare = window.location.origin;
shareMeta = document.createElement('meta'); let shareMeta = document.querySelector('meta[name="share-url"]') ||
shareMeta.name = 'share-url'; Object.assign(document.head.appendChild(document.createElement('meta')), { name: 'share-url' });
document.head.appendChild(shareMeta); shareMeta.content = actualShare;
}
shareMeta.content = share_url;
return { csrf_token, share_url }; return { csrf_token, share_url: actualShare };
}); });
} }
// 1) Immediately clear “?logout=1” flag
const params = new URLSearchParams(window.location.search);
if (params.get('logout') === '1') {
localStorage.removeItem("username");
localStorage.removeItem("userTOTPEnabled");
}
export function triggerLogout() {
fetch("/api/auth/logout.php", {
method: "POST",
credentials: "include",
headers: { "X-CSRF-Token": window.csrfToken }
})
.then(() => window.location.reload(true))
.catch(() => { });
}
// Expose functions for inline handlers. // Expose functions for inline handlers.
window.sendRequest = sendRequest; window.sendRequest = sendRequest;
@@ -79,30 +135,9 @@ document.addEventListener("DOMContentLoaded", function () {
// Continue with initializations that rely on a valid CSRF token: // Continue with initializations that rely on a valid CSRF token:
checkAuthentication().then(authenticated => { checkAuthentication().then(authenticated => {
if (authenticated) { if (authenticated) {
window.currentFolder = "root"; const overlay = document.getElementById('loadingOverlay');
initTagSearch(); if (overlay) overlay.remove();
loadFileList(window.currentFolder); initializeApp();
initDragAndDrop();
loadSidebarOrder();
loadHeaderOrder();
initFileActions();
initUpload();
loadFolderTree();
setupTrashRestoreDelete();
loadAdminConfigFunc();
const helpBtn = document.getElementById("folderHelpBtn");
const helpTooltip = document.getElementById("folderHelpTooltip");
helpBtn.addEventListener("click", function () {
// Toggle display of the tooltip.
if (helpTooltip.style.display === "none" || helpTooltip.style.display === "") {
helpTooltip.style.display = "block";
} else {
helpTooltip.style.display = "none";
}
});
} else {
console.warn("User not authenticated. Data loading deferred.");
} }
}); });
@@ -118,48 +153,55 @@ document.addEventListener("DOMContentLoaded", function () {
// --- Dark Mode Persistence --- // --- Dark Mode Persistence ---
const darkModeToggle = document.getElementById("darkModeToggle"); const darkModeToggle = document.getElementById("darkModeToggle");
const storedDarkMode = localStorage.getItem("darkMode"); const darkModeIcon = document.getElementById("darkModeIcon");
if (storedDarkMode === "true") { if (darkModeToggle && darkModeIcon) {
document.body.classList.add("dark-mode"); // 1) Load stored preference (or null)
} else if (storedDarkMode === "false") { let stored = localStorage.getItem("darkMode");
document.body.classList.remove("dark-mode"); const hasStored = stored !== null;
} else {
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { // 2) Determine initial mode
document.body.classList.add("dark-mode"); const isDark = hasStored
} else { ? (stored === "true")
document.body.classList.remove("dark-mode"); : (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.body.classList.toggle("dark-mode", isDark);
darkModeToggle.classList.toggle("active", isDark);
// 3) Helper to update icon & aria-label
function updateIcon() {
const dark = document.body.classList.contains("dark-mode");
darkModeIcon.textContent = dark ? "light_mode" : "dark_mode";
darkModeToggle.setAttribute(
"aria-label",
dark ? t("light_mode") : t("dark_mode")
);
darkModeToggle.setAttribute(
"title",
dark
? t("switch_to_light_mode")
: t("switch_to_dark_mode")
);
} }
}
if (darkModeToggle) { updateIcon();
darkModeToggle.textContent = document.body.classList.contains("dark-mode")
? t("light_mode")
: t("dark_mode");
darkModeToggle.addEventListener("click", function () { // 4) Click handler: always override and store preference
if (document.body.classList.contains("dark-mode")) { darkModeToggle.addEventListener("click", () => {
document.body.classList.remove("dark-mode"); const nowDark = document.body.classList.toggle("dark-mode");
localStorage.setItem("darkMode", "false"); localStorage.setItem("darkMode", nowDark ? "true" : "false");
darkModeToggle.textContent = t("dark_mode"); updateIcon();
} else {
document.body.classList.add("dark-mode");
localStorage.setItem("darkMode", "true");
darkModeToggle.textContent = t("light_mode");
}
}); });
}
if (localStorage.getItem("darkMode") === null && window.matchMedia) { // 5) OSlevel change: only if no stored pref at load
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (event) => { if (!hasStored && window.matchMedia) {
if (event.matches) { window
document.body.classList.add("dark-mode"); .matchMedia("(prefers-color-scheme: dark)")
if (darkModeToggle) darkModeToggle.textContent = t("light_mode"); .addEventListener("change", e => {
} else { document.body.classList.toggle("dark-mode", e.matches);
document.body.classList.remove("dark-mode"); updateIcon();
if (darkModeToggle) darkModeToggle.textContent = t("dark_mode"); });
} }
});
} }
// --- End Dark Mode Persistence --- // --- End Dark Mode Persistence ---
@@ -173,7 +215,6 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
// --- Auto-scroll During Drag --- // --- Auto-scroll During Drag ---
// Adjust these values as needed:
const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling const SCROLL_THRESHOLD = 50; // pixels from edge to start scrolling
const SCROLL_SPEED = 20; // pixels to scroll per event const SCROLL_SPEED = 20; // pixels to scroll per event

6
public/js/redoc-init.js Normal file
View File

@@ -0,0 +1,6 @@
// public/js/redoc-init.js
if (!customElements.get('redoc')) {
Redoc.init(window.location.origin + '/api.php?spec=1',
{},
document.getElementById('redoc-container'));
}

View File

@@ -0,0 +1,90 @@
// sharedFolderView.js
document.addEventListener('DOMContentLoaded', function() {
let viewMode = 'list';
const payload = JSON.parse(
document.getElementById('shared-data').textContent
);
const token = payload.token;
const filesData = payload.files;
const downloadBase = `${window.location.origin}/api/folder/downloadSharedFile.php?token=${encodeURIComponent(token)}&file=`;
const btn = document.getElementById('toggleBtn');
if (btn) btn.classList.add('toggle-btn');
function toggleViewMode() {
const listEl = document.getElementById('listViewContainer');
const galleryEl = document.getElementById('galleryViewContainer');
if (viewMode === 'list') {
viewMode = 'gallery';
listEl.style.display = 'none';
renderGalleryView();
galleryEl.style.display = 'block';
btn.textContent = 'Switch to List View';
} else {
viewMode = 'list';
galleryEl.style.display = 'none';
listEl.style.display = 'block';
btn.textContent = 'Switch to Gallery View';
}
}
btn.addEventListener('click', toggleViewMode);
function renderGalleryView() {
const container = document.getElementById('galleryViewContainer');
// clear previous
while (container.firstChild) {
container.removeChild(container.firstChild);
}
const grid = document.createElement('div');
grid.className = 'shared-gallery-container';
filesData.forEach(file => {
const url = downloadBase + encodeURIComponent(file);
const ext = file.split('.').pop().toLowerCase();
const isImg = /^(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/.test(ext);
// card
const card = document.createElement('div');
card.className = 'shared-gallery-card';
// preview
const preview = document.createElement('div');
preview.className = 'gallery-preview';
preview.style.cursor = 'pointer';
preview.dataset.url = url;
if (isImg) {
const img = document.createElement('img');
img.src = url;
img.alt = file; // safe, file is not HTML
preview.appendChild(img);
} else {
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.textContent = 'insert_drive_file';
preview.appendChild(icon);
}
card.appendChild(preview);
// info
const info = document.createElement('div');
info.className = 'gallery-info';
const nameSpan = document.createElement('span');
nameSpan.className = 'gallery-file-name';
nameSpan.textContent = file; // textContent escapes any HTML
info.appendChild(nameSpan);
card.appendChild(info);
grid.appendChild(card);
preview.addEventListener('click', () => {
window.location.href = preview.dataset.url;
});
});
container.appendChild(grid);
}
window.renderGalleryView = renderGalleryView;
});

View File

@@ -79,15 +79,16 @@ export function setupTrashRestoreDelete() {
body: JSON.stringify({ files }) body: JSON.stringify({ files })
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(() => {
if (data.success) { // Always report what we actually restored
showToast(data.success); if (files.length === 1) {
toggleVisibility("restoreFilesModal", false); showToast(`Restored file: ${files[0]}`);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
} else { } else {
showToast(data.error); showToast(`Restored files: ${files.join(", ")}`);
} }
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
}) })
.catch(err => { .catch(err => {
console.error("Error restoring files:", err); console.error("Error restoring files:", err);
@@ -119,16 +120,15 @@ export function setupTrashRestoreDelete() {
body: JSON.stringify({ files }) body: JSON.stringify({ files })
}) })
.then(response => response.json()) .then(response => response.json())
.then(data => { .then(() => {
if (data.success) { if (files.length === 1) {
showToast(data.success); showToast(`Restored file: ${files[0]}`);
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
} else { } else {
showToast(data.error); showToast(`Restored files: ${files.join(", ")}`);
} }
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
}) })
.catch(err => { .catch(err => {
console.error("Error restoring files:", err); console.error("Error restoring files:", err);

View File

@@ -669,6 +669,18 @@ function submitFiles(allFiles) {
} }
allSucceeded = false; allSucceeded = false;
} }
if (file.isClipboard) {
setTimeout(() => {
window.selectedFiles = [];
updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer");
if (progressContainer) progressContainer.innerHTML = "";
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
}, 5000);
}
// ─── Only now count this chunk as finished ─────────────────── // ─── Only now count this chunk as finished ───────────────────
finishedCount++; finishedCount++;
@@ -847,4 +859,39 @@ function initUpload() {
} }
} }
export { initUpload }; export { initUpload };
// -------------------------
// Clipboard Paste Handler (Mimics Drag-and-Drop)
// -------------------------
document.addEventListener('paste', function handlePasteUpload(e) {
const items = e.clipboardData?.items;
if (!items) return;
const files = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
const ext = file.name.split('.').pop() || 'png';
const renamedFile = new File([file], `image${Date.now()}.${ext}`, { type: file.type });
renamedFile.isClipboard = true;
Object.defineProperty(renamedFile, 'customRelativePath', {
value: renamedFile.name,
writable: true,
configurable: true
});
files.push(renamedFile);
}
}
}
if (files.length > 0) {
processFiles(files);
showToast('Pasted file added to upload list.', 'success');
}
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 626 KiB

After

Width:  |  Height:  |  Size: 764 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 662 KiB

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 146 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 438 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 330 KiB

After

Width:  |  Height:  |  Size: 330 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 370 KiB

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 MiB

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 412 KiB

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 403 KiB

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

143
scripts/scan_uploads.php Normal file
View File

@@ -0,0 +1,143 @@
<?php
/**
* scan_uploads.php
* Rebuild/repair per-folder metadata used by FileRise models.
* - Uses UPLOAD_DIR / META_DIR / DATE_TIME_FORMAT from config.php
* - Per-folder metadata naming matches FileModel/FolderModel:
* "root" -> root_metadata.json
* "<sub/dir>" -> str_replace(['/', '\\', ' '], '-', '<sub/dir>') . '_metadata.json'
*/
require_once __DIR__ . '/../config/config.php';
// ---------- helpers that mirror model behavior ----------
/** Compute the metadata JSON path for a folder key (e.g., "root", "invoices/2025"). */
function folder_metadata_path(string $folderKey): string {
if (strtolower(trim($folderKey)) === 'root' || trim($folderKey) === '') {
return rtrim(META_DIR, '/\\') . '/root_metadata.json';
}
$safe = str_replace(['/', '\\', ' '], '-', trim($folderKey));
return rtrim(META_DIR, '/\\') . '/' . $safe . '_metadata.json';
}
/** Turn an absolute path under UPLOAD_DIR into a folder key (“root” or relative with slashes). */
function to_folder_key(string $absPath): string {
$base = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR;
if (realpath($absPath) === realpath(rtrim(UPLOAD_DIR, '/\\'))) {
return 'root';
}
$rel = ltrim(str_replace('\\', '/', substr($absPath, strlen($base))), '/');
return $rel;
}
/** List immediate files in a directory (no subdirs). */
function list_files(string $dir): array {
$out = [];
$entries = @scandir($dir);
if ($entries === false) return $out;
foreach ($entries as $name) {
if ($name === '.' || $name === '..') continue;
$p = $dir . DIRECTORY_SEPARATOR . $name;
if (is_file($p)) $out[] = $name;
}
sort($out, SORT_NATURAL | SORT_FLAG_CASE);
return $out;
}
/** Recursively list subfolders (relative folder keys), skipping trash/. */
function list_all_folders(string $root): array {
$root = rtrim($root, '/\\');
$folders = ['root'];
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::SELF_FIRST
);
foreach ($it as $path => $info) {
if ($info->isDir()) {
// relative key like "foo/bar"
$rel = ltrim(str_replace(['\\'], '/', substr($path, strlen($root) + 1)), '/');
if ($rel === '') continue;
// skip trash and profile_pics subtrees
if ($rel === 'trash' || strpos($rel, 'trash/') === 0) continue;
if ($rel === 'profile_pics' || strpos($rel, 'profile_pics/') === 0) continue;
// obey the apps folder-name regex to stay consistent
if (preg_match(REGEX_FOLDER_NAME, basename($rel))) {
$folders[] = $rel;
}
}
}
// de-dup and sort
$folders = array_values(array_unique($folders));
sort($folders, SORT_NATURAL | SORT_FLAG_CASE);
return $folders;
}
// ---------- main ----------
$uploads = rtrim(UPLOAD_DIR, '/\\');
$metaDir = rtrim(META_DIR, '/\\');
// Ensure metadata dir exists
if (!is_dir($metaDir)) {
@mkdir($metaDir, 0775, true);
}
$now = date(DATE_TIME_FORMAT);
$folders = list_all_folders($uploads);
$totalCreated = 0;
$totalPruned = 0;
foreach ($folders as $folderKey) {
$absFolder = ($folderKey === 'root')
? $uploads
: $uploads . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $folderKey);
if (!is_dir($absFolder)) continue;
$files = list_files($absFolder);
$metaPath = folder_metadata_path($folderKey);
$metadata = [];
if (is_file($metaPath)) {
$decoded = json_decode(@file_get_contents($metaPath), true);
if (is_array($decoded)) $metadata = $decoded;
}
// Build a quick lookup of existing entries
$existing = array_keys($metadata);
// ADD missing files
foreach ($files as $name) {
// Keep same filename validation used in FileModel
if (!preg_match(REGEX_FILE_NAME, $name)) continue;
if (!isset($metadata[$name])) {
$metadata[$name] = [
'uploaded' => $now,
'modified' => $now,
'uploader' => 'Imported'
];
$totalCreated++;
echo "Indexed: " . ($folderKey === 'root' ? '' : $folderKey . '/') . $name . PHP_EOL;
}
}
// PRUNE stale metadata entries for files that no longer exist
foreach ($existing as $name) {
if (!in_array($name, $files, true)) {
unset($metadata[$name]);
$totalPruned++;
}
}
// Ensure parent dir exists and write metadata
@mkdir(dirname($metaPath), 0775, true);
if (@file_put_contents($metaPath, json_encode($metadata, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)) === false) {
fwrite(STDERR, "Failed to write metadata for folder: {$folderKey}\n");
}
}
echo "Done. Created {$totalCreated} entr" . ($totalCreated === 1 ? "y" : "ies") .
", pruned {$totalPruned}.\n";

Some files were not shown because too many files have changed in this diff Show More