Compare commits

...

43 Commits

Author SHA1 Message Date
github-actions[bot]
a56641e81c chore(release): set APP_VERSION to v1.9.7 [skip ci] 2025-11-15 01:11:29 +00:00
Ryan
3b636f69d8 release(v1.9.7): harden client path guard and refine header/folder strip CSS 2025-11-14 20:11:19 -05:00
github-actions[bot]
930ed954ec chore(release): set APP_VERSION to v1.9.6 [skip ci] 2025-11-14 10:00:07 +00:00
Ryan
402f590163 release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67) 2025-11-14 04:59:58 -05:00
github-actions[bot]
ef47ad2b52 chore(release): set APP_VERSION to v1.9.5 [skip ci] 2025-11-13 10:32:48 +00:00
Ryan
8cdff954d5 release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths 2025-11-13 05:32:33 -05:00
github-actions[bot]
01cfa597b9 chore(release): set APP_VERSION to v1.9.4 [skip ci] 2025-11-13 10:06:34 +00:00
Ryan
f5e42a2e81 release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more”
- Lazy folder tree via /api/folder/listChildren.php with cursor pagination
- ACL-safe chevrons using hasSubfolders from server; no file-count leaks
- BFS smart initial folder selection + respect lastOpenedFolder
- Locked nodes are expandable but not selectable
- “Load more” UX (light & dark) for huge directories

Closes #66
2025-11-13 05:06:24 -05:00
Ryan
f1dcc0df24 ci(release): run Release on workflow_run; fix(css): remove folder SVG lip-highlight stroke 2025-11-11 01:04:31 -05:00
github-actions[bot]
ba9ead666d chore(release): set APP_VERSION to v1.9.3 [skip ci] 2025-11-11 05:09:24 +00:00
Ryan
dbdf760d4d release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release 2025-11-11 00:09:15 -05:00
Ryan
a031fc99c2 release(ci): harden release-on-version workflow; remove sleep/race, safer checkout, deterministic ref 2025-11-10 03:01:46 -05:00
github-actions[bot]
db73cf2876 chore(release): set APP_VERSION to v1.9.2 [skip ci] 2025-11-10 07:50:29 +00:00
Ryan
062f34dd3d release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback) 2025-11-10 02:50:19 -05:00
Ryan
63b24ba698 chore(doc): readme adjustments for webdav & security 2025-11-09 21:59:20 -05:00
Ryan
567d2f62e8 chore(doc) readme updated to remove duplicated onlyoffice info 2025-11-09 20:19:30 -05:00
Ryan
9be53ba033 chore(scripts): fix shellcheck SC2148 and harden manual-sync.sh 2025-11-09 20:01:21 -05:00
github-actions[bot]
de925e6fc2 chore(release): set APP_VERSION to v1.9.1 [skip ci] 2025-11-10 00:55:18 +00:00
Ryan
bd7ff4d9cd release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script 2025-11-09 19:55:07 -05:00
Ryan
6727cc66ac docs(assets): refresh screenshots to showcase new Folder Manager 2025-11-09 02:48:26 -05:00
Ryan
f3269877c7 Update image link in README.md 2025-11-09 02:41:38 -05:00
github-actions[bot]
5ffe9b3ffc chore(release): set APP_VERSION to v1.9.0 [skip ci] 2025-11-09 06:45:49 +00:00
Ryan
abd3dad5a5 release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening 2025-11-09 01:45:39 -05:00
github-actions[bot]
4c849b1dc3 chore(release): set APP_VERSION to v1.8.13 [skip ci] 2025-11-09 00:30:43 +00:00
Ryan
7cc314179f release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync 2025-11-08 19:30:33 -05:00
github-actions[bot]
9ddb633cca chore(release): set APP_VERSION to v1.8.12 [skip ci] 2025-11-08 21:05:31 +00:00
Ryan
448e246689 release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons 2025-11-08 16:05:20 -05:00
Ryan
dc7797e50d chore(doc): readme spacing issue fixed 2025-11-08 14:57:54 -05:00
Ryan
913d370ef2 Update README with new gif and remove dark mode image 2025-11-08 14:36:51 -05:00
github-actions[bot]
488b5cb532 chore(release): set APP_VERSION to v1.8.11 [skip ci] 2025-11-08 19:12:57 +00:00
Ryan
15b5aa6d8d release(v1.8.11): doc updated 2025-11-08 14:12:48 -05:00
Ryan
8f03cc7456 release (v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client 2025-11-08 13:53:11 -05:00
github-actions[bot]
c9a99506d7 chore(release): set APP_VERSION to v1.8.10 [skip ci] 2025-11-08 18:33:52 +00:00
Ryan
04ec0a0830 release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul 2025-11-08 13:33:38 -05:00
github-actions[bot]
429cd0314a chore(release): set APP_VERSION to v1.8.9 [skip ci] 2025-11-08 03:10:24 +00:00
Ryan
ba29cc4822 release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64) 2025-11-07 22:10:14 -05:00
github-actions[bot]
e2cd304158 chore(release): set APP_VERSION to v1.8.8 [skip ci] 2025-11-07 07:57:42 +00:00
Ryan
ca8788a694 release(v1.8.8): background ZIP jobs w/ tokenized download + in‑modal progress bar; robust finalize; janitor cleanup — closes #60 2025-11-07 02:57:30 -05:00
Ryan
dc45fed886 chore(ci): increase release delay to 10m to avoid ref replication race 2025-11-05 00:19:56 -05:00
github-actions[bot]
a9fe342175 chore(release): set APP_VERSION to v1.8.7 [skip ci] 2025-11-05 05:02:42 +00:00
Ryan
7669f5a10b release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives 2025-11-05 00:02:32 -05:00
Ryan
34a4e06a23 chore(ci): add manual trigger + bot-derived version detection for releases 2025-11-04 23:09:31 -05:00
github-actions[bot]
d00faf5fe7 chore(release): set APP_VERSION to v1.8.6 [skip ci] 2025-11-05 03:57:04 +00:00
71 changed files with 10128 additions and 6600 deletions

View File

@@ -2,90 +2,83 @@
name: Release on version.js update
on:
push:
branches: ["master"] # keep as-is; change to ["master","main"] if you use main too
paths:
- public/js/version.js
workflow_run:
workflows: ["Bump version and sync Changelog to Docker Repo"]
types: [completed]
branches: [master]
workflow_dispatch:
inputs:
ref:
description: "Ref (branch/sha) to build from (default: master)"
required: false
version:
description: "Explicit version tag to release (e.g., v1.8.12). If empty, parse from public/js/version.js."
required: false
permissions:
contents: write
jobs:
delay:
runs-on: ubuntu-latest
steps:
- name: Delay 2 minutes
run: sleep 120
release:
needs: delay
runs-on: ubuntu-latest
# Guard: Only run on trusted workflow_run events (pushes from this repo)
if: >
if: |
github.event_name == 'push' ||
(github.event_name == 'workflow_run' &&
github.event.workflow_run.event == 'push' &&
github.event.workflow_run.head_repository.full_name == github.repository)
github.event_name == 'workflow_dispatch'
concurrency:
# Ensure concurrency key follows the actual source ref
group: release-${{ github.event_name }}-${{ github.event.workflow_run.head_sha || github.sha }}
group: release-${{ github.event_name }}-${{ github.run_id }}
cancel-in-progress: false
steps:
- name: Resolve correct ref
- name: Resolve source ref
id: pickref
shell: bash
run: |
if [ "${{ github.event_name }}" = "workflow_run" ]; then
echo "ref=${{ github.event.workflow_run.head_sha }}" >> "$GITHUB_OUTPUT"
set -euo pipefail
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
if [[ -n "${{ github.event.inputs.ref }}" ]]; then
REF_IN="${{ github.event.inputs.ref }}"
else
REF_IN="master"
fi
if git ls-remote --exit-code --heads https://github.com/${{ github.repository }}.git "$REF_IN" >/dev/null 2>&1; then
REF="$REF_IN"
else
REF="$REF_IN"
fi
else
echo "ref=${{ github.sha }}" >> "$GITHUB_OUTPUT"
REF="${{ github.sha }}"
fi
echo "Using ref: $(cat $GITHUB_OUTPUT)"
echo "ref=$REF" >> "$GITHUB_OUTPUT"
echo "Using ref=$REF"
- name: Checkout
- name: Checkout chosen ref (full history + tags, no persisted token)
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: ${{ steps.pickref.outputs.ref }}
fetch-depth: 0
persist-credentials: false
- name: Ensure tags available
run: git fetch --tags --force --prune --quiet
# Guard: refuse if the ref isnt contained in master
- name: Assert ref is on master
shell: bash
run: |
set -euo pipefail
REF="${{ steps.pickref.outputs.ref }}"
git fetch origin master --quiet
if ! git merge-base --is-ancestor "$REF" origin/master; then
echo "Ref $REF is not on master; refusing to release."
exit 78 # neutral exit
fi
- name: Debug version.js origin
run: |
echo "version.js at commit: $(git log -n1 --pretty=%h -- public/js/version.js)"
sed -n '1,20p' public/js/version.js || true
- name: Read version from version.js
- name: Determine version
id: ver
shell: bash
run: |
set -euo pipefail
VER=$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")
if [[ -z "$VER" ]]; then
echo "Could not parse APP_VERSION from version.js" >&2
exit 1
if [[ -n "${{ github.event.inputs.version || '' }}" ]]; then
VER="${{ github.event.inputs.version }}"
else
if [[ ! -f public/js/version.js ]]; then
echo "public/js/version.js not found; cannot auto-detect version." >&2
exit 1
fi
VER="$(grep -Eo "APP_VERSION\s*=\s*['\"]v[^'\"]+['\"]" public/js/version.js | sed -E "s/.*['\"](v[^'\"]+)['\"].*/\1/")"
if [[ -z "$VER" ]]; then
echo "Could not parse APP_VERSION from public/js/version.js" >&2
exit 1
fi
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "Parsed version: $VER"
echo "Detected version: $VER"
- name: Skip if tag already exists
id: tagcheck
@@ -99,7 +92,7 @@ jobs:
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Prep stamper script
- name: Prepare stamp script
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
@@ -107,7 +100,7 @@ jobs:
sed -i 's/\r$//' scripts/stamp-assets.sh || true
chmod +x scripts/stamp-assets.sh
- name: Build zip artifact (stamped)
- name: Build stamped staging tree
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
@@ -121,27 +114,67 @@ jobs:
./ staging/
bash ./scripts/stamp-assets.sh "${VER}" "$(pwd)/staging"
- name: Verify placeholders are gone (staging)
# --- PHP + Composer for vendor/ (production) ---
- name: Setup PHP
if: steps.tagcheck.outputs.exists == 'false'
id: php
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
extensions: mbstring, json, curl, dom, fileinfo, openssl, zip
coverage: none
ini-values: memory_limit=-1
- name: Cache Composer downloads
if: steps.tagcheck.outputs.exists == 'false'
uses: actions/cache@v4
with:
path: |
~/.composer/cache
~/.cache/composer
key: composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-${{ hashFiles('**/composer.lock') }}
restore-keys: |
composer-${{ runner.os }}-php-${{ steps.php.outputs.php-version }}-
- name: Install PHP dependencies into staging
if: steps.tagcheck.outputs.exists == 'false'
env:
COMPOSER_MEMORY_LIMIT: -1
shell: bash
run: |
set -euo pipefail
pushd staging >/dev/null
if [[ -f composer.json ]]; then
composer install \
--no-dev \
--prefer-dist \
--no-interaction \
--no-progress \
--optimize-autoloader \
--classmap-authoritative
test -f vendor/autoload.php || (echo "Composer install did not produce vendor/autoload.php" >&2; exit 1)
else
echo "No composer.json in staging; skipping vendor install."
fi
popd >/dev/null
# --- end Composer ---
- name: Verify placeholders removed (skip vendor/)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
set -euo pipefail
ROOT="$(pwd)/staging"
if grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
--exclude-dir=vendor --exclude-dir=vendor-bin \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' 2>/dev/null; then
echo "---- DEBUG (show 10 hits with context) ----"
grep -R -n -E "{{APP_QVER}}|{{APP_VER}}" "$ROOT" \
--include='*.html' --include='*.php' --include='*.css' --include='*.js' \
| head -n 10 | while IFS=: read -r file line _; do
echo ">>> $file:$line"
nl -ba "$file" | sed -n "$((line-3)),$((line+3))p" || true
echo "----------------------------------------"
done
echo "Unreplaced placeholders found in staging." >&2
exit 1
fi
echo "OK: No unreplaced placeholders in staging."
echo "OK: No unreplaced placeholders."
- name: Zip stamped staging
- name: Zip artifact (includes vendor/)
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |
@@ -149,7 +182,7 @@ jobs:
VER="${{ steps.ver.outputs.version }}"
(cd staging && zip -r "../FileRise-${VER}.zip" . >/dev/null)
- name: Compute SHA-256 checksum
- name: Compute SHA-256
if: steps.tagcheck.outputs.exists == 'false'
id: sum
shell: bash
@@ -194,9 +227,9 @@ jobs:
PREV=$(git rev-list --max-parents=0 HEAD | tail -n1)
fi
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
echo "Previous tag or baseline: $PREV"
echo "Previous tag/baseline: $PREV"
- name: Build release body (snippet + full changelog + checksum)
- name: Build release body
if: steps.tagcheck.outputs.exists == 'false'
shell: bash
run: |

View File

@@ -1,5 +1,459 @@
# Changelog
## Changes 11/14/2025 (v1.9.7)
release(v1.9.7): harden client path guard and refine header/folder strip CSS
- Tighten isSafeFolderPath() to reject dot-prefixed/invalid segments (client-side defense-in-depth on folder paths).
- Rework header layout: consistent logo sizing, centered title, cleaner button alignment, and better small-screen stacking.
- Polish user dropdown and icon buttons: improved hover/focus states, dark-mode colors, and rounded menu corners.
- Update folder strip tiles: cap tile width, allow long folder names to wrap neatly, and fine-tune text/icon alignment.
- Tweak folder tree rows: better label wrapping, vertical alignment, and consistent SVG folder icon rendering.
- Small CSS cleanup and normalization (body, main wrapper, media modal/progress styles) without changing behavior.
---
## Changes 11/14/2025 (v1.9.6)
release(v1.9.6): hardened resumable uploads, menu/tag UI polish and hidden temp folders (closes #67)
- Resumable uploads
- Normalize resumable GET “test chunk” handling in `UploadModel` using `resumableChunkNumber` + `resumableIdentifier`, returning explicit `status: "found"|"not found"`.
- Skip CSRF checks for resumable GET tests in `UploadController`, but keep strict CSRF validation for real POST uploads with soft-fail `csrf_expired` responses.
- Refactor `UploadModel::handleUpload()` for chunked uploads: strict filename validation, safe folder normalization, reliable temp chunk directory creation, and robust merge with clear errors if any chunk is missing.
- Add `UploadModel::removeChunks()` + internal `rrmdir()` to safely clean up `resumable_…` temp folders via a dedicated controller endpoint.
- Frontend resumable UX & persistence
- Enable `testChunks: true` for Resumable.js and wire GET checks to the new backend status logic.
- Track in-progress resumable files per user in `localStorage` (identifier, filename, folder, size, lastPercent, updatedAt) and show a resumable hint banner inside the Upload card with a dismiss button that clears the hints for that folder.
- Clamp client-side progress to max `99%` until the server confirms success, so aborted tabs still show resumable state instead of “100% done”.
- Improve progress UI: show upload speed, spinner while finalizing, and ensure progress elements exist even for non-standard flows (e.g., submit without prior list build).
- On complete success, clear the progress UI, reset the file input, cancel Resumables internal queue, clear draft records for the folder, and re-show the resumable banner only when appropriate.
- Hiding resumable temp folders
- Hide `resumable_…` folders alongside `trash` and `profile_pics` in:
- Folder tree BFS traversal (child discovery / recursion).
- `listChildren.php` results and child-cache hydration.
- The inline folder strip above the file list (also filtered in `fileListView.js`).
- Folder manager context menu upgrade
- Replace the old ad-hoc folder context menu with a unified `filr-menu` implementation that mirrors the file context menu styling.
- Add Material icon mapping per action (`create_folder`, `move_folder`, `rename_folder`, `color_folder`, `folder_share`, `delete_folder`) and clamp the menu to viewport with escape/outside-click close behavior.
- Wire the new menu from both tree nodes and breadcrumb links, respecting locked folders and current folder capabilities.
- File context menu & selection logic
- Define a semantic file context menu in `index.html` (`#fileContextMenu` with `.filr-menu` buttons, icons, `data-action`, and `data-when` visibility flags).
- Rebuild `fileMenu.js` to:
- Derive the current selection from file checkboxes and map back to real `fileData` entries, handling the encoded row IDs.
- Toggle menu items based on selection state (`any`, `one`, `many`, `zip`, `can-edit`) and hide redundant separators.
- Position the menu within the viewport, add ESC/outside-click dismissal, and delegate click handling to call the existing file actions (preview, edit, rename, copy/move/delete/download/extract, tag single/multiple).
- Tagging system robustness
- Refactor `fileTags.js` to enforce single-instance modals for both single-file and multi-file tagging, preventing duplicate DOM nodes and double bindings.
- Centralize global tag storage (`window.globalTags` + `localStorage`) with shared dropdowns for both modals, including “×” removal for global tags that syncs back to the server.
- Make the tag modals safer and more idempotent (re-usable DOM, Esc and backdrop-to-close, defensive checks on elements) while keeping the existing file row badge rendering and tag-based filtering behavior.
- Localize various tag-related strings where possible and ensure gallery + table views stay in sync after tag changes.
- Visual polish & theming
- Introduce a shared `--menu-radius` token and apply it across login form, file list container, restore modal, preview modals, OnlyOffice modal, user dropdown menus, and the Upload / Folder Management cards for consistent rounded corners.
- Update header button hover to use the same soft blue hover as other interactive elements and tune card shadows for light vs dark mode.
- Adjust media preview modal background to a darker neutral and tweak `filePreview` panel background fallback (`--panel-bg` / `--bg-color`) for better dark mode contrast.
- Style `.filr-menu` for both file + folder menus with max-height, scrolling, proper separators, and Material icons inheriting text color in light and dark themes.
- Align the user dropdown menu hover/active styles with the new menu hover tokens (`--filr-row-hover-bg`, `--filr-row-outline-hover`) for a consistent interaction feel.
---
## Changes 11/13/2025 (v1.9.5)
release(v1.9.5): harden folder tree DOM, add a11y to “Load more”, and guard folder paths
- Replace innerHTML-based row construction in folderManager.js with safe DOM APIs
(createElement, textContent, dataset). All user-derived strings now use
textContent; only locally-generated SVG remains via innerHTML.
- Add isSafeFolderPath() client-side guard; fail closed on suspicious paths
before rendering clickable nodes.
- “Load more” button rebuilt with proper a11y:
- aria-label, optional aria-controls to the UL
- aria-busy + disabled during fetch; restore state only if the node is still
present (Node.isConnected).
- Keep lazy tree + cursor pagination behavior intact; chevrons/icons continue to
hydrate from server hints (hasSubfolders/nonEmpty) once available.
- Addresses CodeQL XSS findings by removing unsafe HTML interpolation and
avoiding HTML interpretation of extracted text.
No breaking changes; security + UX polish on top of v1.9.4.
---
## Changes 11/13/2025 (v1.9.4)
release(v1.9.4): lazy folder tree, cursor pagination, ACL-safe chevrons, and “Load more” (closes #66)
**Big focus on folder management performance & UX for large libraries.**
feat(folder-tree):
- Lazy-load children on demand with cursor-based pagination (`nextCursor` + `limit`), including inline “Load more” row.
- BFS-based initial selection: if user cant view requested/default folder, auto-pick the first accessible folder (but stick to (Root) when user can view it).
- Persisted expansion state across reloads; restore saved path and last opened folder; prevent navigation into locked folders (shows i18n toast instead).
- Breadcrumb now respects ACL: clicking a locked crumb toggles expansion only (no navigation).
- Live chevrons from server truth: `hasSubfolders` is computed server-side to avoid file count probes and show correct expanders (even when a direct child is unreadable).
- Capabilities-driven toolbar enable/disable for create/move/rename/color/delete/share.
- Color-carry on move/rename + expansion state migration so moved/renamed nodes keep colors and stay visible.
- Root DnD honored only when viewable; structural locks disable dragging.
perf(core):
- New `FS.php` helpers: safe path resolution (`safeReal`), segment sanitization, symlink defense, ignore/skip lists, bounded child counting, `hasSubfolders`, and `hasReadableDescendant` (depth-limited).
- Thin caching for child lists and counts, with targeted cache invalidation on move/rename/create/delete.
- Bounded concurrency for folder count requests; short timeouts to keep UI snappy.
api/model:
- `FolderModel::listChildren(...)` now returns items shaped like:
`{ name, locked, hasSubfolders, nonEmpty? }`
- `nonEmpty` included only for unlocked nodes (prevents side-channel leakage).
- Locked nodes are only returned when `hasReadableDescendant(...)` is true (preserves legacy “structural visibility without listing the entire tree” behavior).
- `public/api/folder/listChildren.php` delegates to controller/model; `isEmpty.php` hardened; `capabilities.php` exposes `canView` (or derived) for fast checks.
- Folder color endpoints gate results by ACL so users only see colors for folders they can at least “own-view”.
ui/ux:
- New “Load more” row (`<li class="load-more">`) with dark-mode friendly ghost button styling; consistent padding, focus ring, hover state.
- Locked folders render with padlock overlay and no DnD; improved contrast/spacing; icons/chevrons update live as children load.
- i18n additions: `no_access`, `load_more`, `color_folder(_saved|_cleared)`, `please_select_valid_folder`, etc.
- When a user has zero access anywhere, tree selects (Root) but shows `no_access` instead of “No files found”.
security:
- Stronger path traversal + symlink protections across folder APIs (all joins normalized, base-anchored).
- Reduced metadata leakage by omitting `nonEmpty` for locked nodes and depth-limiting descendant checks.
fixes:
- Chevron visibility for unreadable intermediate nodes (e.g., “Files” shows a chevron when it contains a readable “Resources” descendant).
- Refresh now honors the actively viewed folder (session/localStorage), not the first globally readable folder.
chore:
- CSS additions for locked state, tree rows, and dark-mode ghost buttons.
- Minor code cleanups and comments across controller/model and JS tree logic.
---
## Changes 11/11/2025 (v1.9.3)
release(v1.9.3): unify folder icons across tree & strip, add “paper” lines, live color sync, and vendor-aware release
- UI / Icons
- Replace Material icon in folder strip with shared `folderSVG()` and export it for reuse. Adds clipPaths, subtle gradients, and `shape-rendering: geometricPrecision` to eliminate the tiny seam.
- Add ruled “paper” lines and blue handwriting dashes; CSS for `.paper-line` and `.paper-ink` included.
- Match strokes between tree (24px) and strip (48px) so both look identical; round joins/caps to avoid nicks.
- Polish folder strip layout & hover: tighter spacing, centered icon+label, improved wrapping.
- Folder color & non-empty detection
- Live color sync: after saving a color we dispatch `folderColorChanged`; strip repaints and tree refreshes.
- Async strip icon: paint immediately, then flip to “paper” if the folder has contents. HSL helpers compute front/back/stroke shades.
- FileList strip
- Render subfolders with `<span class="folder-svg">` + name, wire context menu actions (move, color, share, etc.), and attach icons for each tile.
- Exports & helpers
- Export `openColorFolderModal(...)` and `openMoveFolderUI(...)` for the strip and toolbar; use `refreshFolderIcon(...)` after ops to keep icons current.
- AppCore
- Update file upload DnD relay hook to `#fileList` (id rename).
- CSS tweaks
- Bring tree icon stroke/paint rules in line with the strip, add scribble styles, and adjust margins/spacing.
- CI/CD (release)
- Build PHP dependencies during release: setup PHP 8.3 + Composer, cache downloads, install into `staging/vendor/`, exclude `vendor/` from placeholder checks, and ship artifact including `vendor/`.
- Changelog highlights
- Sharper, seam-free folder SVGs shared across tree & strip, with paper lines + handwriting accents.
- Real-time folder color propagation between views.
- Folder strip switched to SVG tiles with better layout + context actions.
- Release pipeline now produces a ready-to-run zip that includes `vendor/`.
---
## Changes 11/10/2025 (v1.9.2)
release(v1.9.2): Upload modal + DnD relay from file list (with robust synthetic-drop fallback)
- New “Upload file(s)” action in Create menu:
- Adds `<li id="uploadOption">` to the dropdown.
- Opens a reusable Upload modal that *moves* the existing #uploadCard into the modal (no cloning = no lost listeners).
- ESC / backdrop / “×” close support; focus jumps to “Choose Files” for fast keyboard flow.
- Drag & Drop from file list → Upload:
- Drag-over on #fileListContainer shows drop-hover and auto-opens the Upload modal after a short hover.
- On drop, waits until the modals #uploadDropArea exists, then relays the drop to it.
- Uses a resilient relay: attempts to attach DataTransfer to a synthetic event; falls back to a stash.
- Synthetic drop fallback:
- Introduces window.__pendingDropData (cleared after use).
- upload.js now reads e.dataTransfer || window.__pendingDropData to accept relayed drops across browsers.
- Implementation details:
- fileActions.js: adds openUploadModal()/closeUploadModal() with a hidden sentinel to return #uploadCard to its original place on close.
- appCore.js: imports openUploadModal, adds waitFor() helper, and wires dragover/leave/drop logic for the relay.
- index.html: adds Upload option to the Create menu and the #uploadModal scaffold.
- UX/Safety:
- Defensive checks if modal/card isnt present.
- No backend/API changes; CSRF/auth unchanged.
Files touched: public/js/upload.js, public/js/fileActions.js, public/js/appCore.js, public/index.html
---
## Changes 11/9/2025 (v1.9.1)
release(v1.9.1): customizable folder colors + live preview; improved tree persistence; accent button; manual sync script
### Highlights v1.9.1
- 🎨 Per-folder colors with live SVG preview and consistent styling in light/dark modes.
- 📄 Folder icons auto-refresh when contents change (no full page reload).
- 🧭 Drag-and-drop breadcrumb fallback for folder→folder moves.
- 🛠️ Safer upgrade helper script to rsync app files without touching data.
- feat(colors): add per-folder color customization
- New endpoints: GET /api/folder/getFolderColors.php and POST /api/folder/saveFolderColor.php
- AuthZ: reuse canRename for “customize folder”, validate hex, and write atomically to metadata/folder_colors.json.
- Read endpoint filters map by ACL::canRead before returning to the user.
- Frontend: load/apply colors to tree rows; persist on move/rename; API helpers saveFolderColor/getFolderColors.
- feat(ui): color-picker modal with live SVG folder preview
- Shows preview that updates as you pick; supports Save/Reset; protects against accidental toggle clicks.
- feat(controls): “Color folder” button in Folder Management card
- New `.btn-color-folder` with accent palette (#008CB4), hover/active/focus states, dark-mode tuning; event wiring gated by caps.
- i18n: add strings for color UI (color_folder, choose_color, reset_default, save_color, folder_color_saved, folder_color_cleared).
- ux(tree): make expansion state more predictable across refreshes
- `expandTreePath(path, {force,persist,includeLeaf})` with persistence; keep ancestors expanded; add click-suppression guard.
- ux(layout): center the folder-actions toolbar; remove left padding hacks; normalize icon sizing.
- chore(ops): add scripts/manual-sync.sh (safe rsync update path, preserves data dirs and public/.htaccess).
---
## Changes 11/9/2025 (v1.9.0)
release(v1.9.0): folder tree UX overhaul, fast ACL-aware counts, and .htaccess hardening
feat(ui): modern folder tree
- New crisp folder SVG with clear paper insert; unified yellow/orange palette for light & dark
- Proper ARIA tree semantics (role=treeitem, aria-expanded), cleaner chevrons, better alignment
- Breadcrumb tweaks ( separators), hover/selected polish
- Prime icons locally, then confirm via counts for accurate “empty vs non-empty”
feat(api): add /api/folder/isEmpty.php via controller/model
- public/api/folder/isEmpty.php delegates to FolderController::stats()
- FolderModel::countVisible() enforces ACL, path safety, and short-circuits after first entry
- Releases PHP session lock early to avoid parallel-request pileups
perf: cap concurrent “isEmpty” requests + timeouts
- Small concurrency limiter + fetch timeouts
- In-memory result & inflight caches for fewer network hits
fix(state): preserve user expand/collapse choices
- Respect saved folderTreeState; dont auto-expand unopened nodes
- Only show ancestors for visibility when navigating (no unwanted persists)
security: tighten .htaccess while enabling WebDAV
- Deny direct PHP except /api/*.php, /api.php, and /webdav.php
- AcceptPathInfo On; keep path-aware dotfile denial
refactor: move count logic to model; thin controller action
chore(css): add unified “folder tree” block with variables (sizes, gaps, colors)
Files touched: FolderModel.php, FolderController.php, public/js/folderManager.js, public/css/styles.css, public/api/folder/isEmpty.php (new), public/.htaccess
---
## Changes 11/8/2025 (v1.8.13)
release(v1.8.13): ui(dnd): stabilize zones, lock sidebar width, and keep header dock in sync
- dnd: fix disappearing/overlapping cards when moving between sidebar/top; return to origin on failed drop
- layout: placeCardInZone now live-updates top layout, sidebar visibility, and toggle icon
- toggle/collapse: move ALL cards to header on collapse, restore saved layout on expand; keep icon state synced; add body.sidebar-hidden for proper file list expansion; emit `zones:collapsed-changed`
- header dock: show dock whenever icons exist (and on collapse); hide when empty
- responsive: enforceResponsiveZones also updates toggle icon; stash/restore behavior unchanged
- sidebar: hard-lock width to 350px (CSS) and remove runtime 280px minWidth; add placeholder when empty to make dropping back easy
- CSS: right-align header dock buttons, centered “Drop Zone” label, sensible min-height; dark-mode safe
- refactor: small renames/ordering; remove redundant z-index on toggle; minor formatting
---
## Changes 11/8/2025 (v1.8.12)
release(v1.8.12): auth UI & DnD polish — show OIDC, auto-SSO, right-aligned header icons
- auth (public/js/main.js)
- Robust login options: tolerate key variants (disableFormLogin/disable_form_login, etc.).
- Correctly show/hide wrapper + individual methods (form/OIDC/basic).
- Auto-SSO when OIDC is the only enabled method; add opt-out with `?noauto=1`.
- Minor cleanup (SW register catch spacing).
- drag & drop (public/js/dragAndDrop.js)
- Reworked zones model: Sidebar / Top (left/right) / Header (icon+modal).
- Persist user layout with `userZonesSnapshot.v2` and responsive stash for small screens.
- Live UI sync: toggle icon (`material-icons`) updates immediately after moves.
- Smarter small-screen behavior: lift sidebar cards ephemerally; restore only what belonged to sidebar.
- Cleaner header icon modal plumbing; remove legacy/dead code.
- styles (public/css/styles.css)
- Header drop zone fills remaining space and right-aligns its icons.
UX:
- OIDC button reliably appears when form/basic are disabled.
- If OIDC is the sole method, users are taken straight to the provider (unless `?noauto=1`).
- Header icons sit with the other header actions (right-aligned), and the toggle icon reflects layout changes instantly.
---
## Changes 11/8/2025 (v1.8.11)
release(v1.8.11): fix(oidc): always send PKCE (S256) and treat empty secret as public client
- Force PKCE via setCodeChallengeMethod('S256') so Authelias public-client policy is satisfied.
- Convert empty OIDC client secret to null to correctly signal a public client.
- Optional commented hook to switch token endpoint auth to client_secret_post if desired.
- OIDC_TOKEN_ENDPOINT_AUTH_METHOD added to config.php
---
## Changes 11/8/2025 (v1.8.10)
release(v1.8.10): theme-aware media modal, stronger file drag-and-drop, unified progress color, and favicon overhaul
UI/UX — Media modal
- Add fixed top bar to avoid filename/controls overlapping native media chrome; keep hover-on-stage look.
- Show a Material icon by file type next to the filename (image/video/pdf/code/arch/txt, with fallback).
- Restore “X” behavior and make hover theme-aware (red pill + white X in light, red pill + black X in dark).
Video/Image controls
- Top-right action icons use theme-aware styles and align with the filename row.
- Prev/Next paddles remain high-contrast and vertically centered within the stage.
Progress badges (list & modal)
- Standardize “in-progress” to darker orange (#ea580c) for better contrast in light/dark; update CSS and list badge rendering.
Drag & drop
- Support multi-select drags with a clean JSON payload + text fallback; nicer drag ghost.
- More resilient drops: accept data-dest-folder, safer JSON parse, early guards, and better toasts.
- POST move now sends Accept header, uses global CSRF, and refreshes the active view on success.
Editor & ONLYOFFICE
- Full-screen OO modal with preconnect, optional hidden warm-up to reduce first-open latency, and live theme sync.
- CodeMirror path: fix theme/mode setters (use `cm`) and tighten dynamic mode loading.
Assets & polish
- Swap in full favicon stack (SVG + PNG 512/32/16 + ICO) and set theme-color; cache-busted via `{{APP_QVER}}`.
- Refresh `logo.svg` (accessibility, cleaner handles/gradients).
Also added: refreshed resource images and new logo sizes (logo-16, logo-32, logo-64, etc.) for crisper favicons and embeds.
---
## Changes 11/7/2025 (v1.8.9)
release(v1.8.9): fix(oidc, admin): first-save Client ID/Secret (closes #64)
- adminPanel.js:
- Masked inputs without a saved value now start with data-replace="1".
- handleSave() now sends oidc.clientId / oidc.clientSecret on first save (no longer requires clicking “Replace” first).
---
## Changes 11/7/2025 (v1.8.8)
release(v1.8.8): background ZIP jobs w/ tokenized download + inmodal progress bar; robust finalize; janitor cleanup — closes #60
**Summary**
This release moves ZIP creation off the request thread into a **background worker** and switches the client to a **queue > poll > tokenized GET** download flow. It fixes large multiGB ZIP failures caused by request timeouts or crossdevice renames, and provides a resilient inmodal progress experience. It also adds a 6hour janitor for temporary tokens/logs.
**Backend** changes:
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for oneshot downloads.
- Update `FileController::downloadZip()` to enqueue a job and return `{ token, statusUrl, downloadUrl }` instead of streaming a blob in the POST response.
- Implement `spawnZipWorker()` to find a working PHP CLI, set `TMPDIR` on the same filesystem as the final ZIP, spawn with `nohup`, and persist PID/log metadata for diagnostics.
- Serve finished ZIPs via `downloadZipFile()` with strict token/user checks and streaming headers; unlink the ZIP after successful read.
New **Worker**:
- New `src/cli/zip_worker.php` builds the archive in the background.
- Writes progress fields (`pct`, `filesDone`, `filesTotal`, `bytesDone`, `bytesTotal`, `current`, `phase`, `startedAt`, `finalizeAt`) to the pertoken JSON.
- During **finalizing**, publishes `selectedFiles`/`selectedBytes` and clears incremental counters to avoid the confusing “N/N files” display before `close()` returns.
- Adds a **janitor**: purge `.tokens/*.json` and `.logs/WORKER-*.log` older than **6 hours** on each run.
New **API/Status Payload**:
- `zipStatus()` exposes `ready` (derived from `status=done` + existing `zipPath`), and includes `startedAt`/`finalizeAt` for UI timers.
- Returns a prebuilt `downloadUrl` for a direct handoff once the ZIP is ready.
**Frontend (UX)** changes:
- Replace blob POST download with **enqueue → poll → tokenized GET** flow.
- Native `<progress>` bar now renders **inside the modal** (no overflow/jitter).
- Shows determinate **098%** during enumeration, then **locks at 100%** with **“Finalizing… mm:ss — N files, ~Size”** until the download starts.
- Modal closes just before download; UI resets for the next operation.
Added **CSS**:
- Ensure the progress modal has a minimum height and hidden overflow; ellipsize the status line to prevent scrollbars.
**Why this closes #60**?
- ZIP creation no longer depends on the request lifetime (avoids proxy/Apache timeouts).
- Temporary files and final ZIP are created on the **same filesystem** (prevents “rename temp file failed” during `ZipArchive::close()`).
- Users get continuous, truthful feedback for large multiGB archives.
Additional **Notes**
- Download tokens are **oneshot** and are deleted after the GET completes.
- Temporary artifacts (`META_DIR/ziptmp/.tokens`, `.logs`, and old ZIPs) are cleaned up automatically (≥6h).
---
## Changes 11/5/2025 (v1.8.7)
release(v1.8.7): fix(zip-download): stream clean ZIP response and purge stale temp archives
- FileController::downloadZip
- Remove _jsonStart/_jsonEnd and JSON wrappers; send a pure binary ZIP
- Close session locks, disable gzip/output buffering, set Content-Length when known
- Stream in 1MiB chunks; proper HTTP codes/messages on errors
- Unlink the temp ZIP after successful send
- Preserves all auth/ACL/ownership checks
- FileModel::createZipArchive
- Purge META_DIR/ziptmp/download-*.zip older than 6h before creating a new ZIP
Result: fixes “failed to fetch / load failed” with fetch>blob flow and reduces leftover tmp ZIPs.
---
## Changes 11/4/2025 (v1.8.6)
release(v1.8.6): fix large ZIP downloads + safer extract; close #60

View File

@@ -21,16 +21,13 @@ Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage
With drag-and-drop uploads, in-browser editing, secure user logins (SSO & TOTP 2FA), and one-click public sharing, **FileRise** brings professional-grade file management to your own server — simple to deploy, easy to scale, and fully self-hosted.
New: Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Where supported by your Document Server, users can add **comments/annotations** to documents (and PDFs). Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
Open and edit Office documents — **Word (DOCX)**, **Excel (XLSX)**, **PowerPoint (PPTX)** — directly in **FileRise** using your self-hosted **ONLYOFFICE Document Server** (optional). Open **ODT/ODS/ODP**, and view **PDFs** inline. Everything is enforced by the same per-folder ACLs across the UI and WebDAV.
**10/25/2025 Video demo:**
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
**Dark mode:**
![Dark Header](https://raw.githubusercontent.com/error311/FileRise/refs/heads/master/resources/dark-header.png)
![filerise-v1 9 0](https://github.com/user-attachments/assets/a346dd8a-eef1-4180-8140-4c1c08e6026e)
---
@@ -327,21 +324,6 @@ https://your-host/webdav.php/
- Check **Connect using different credentials**, then enter your FileRise username/password.
- Click **Finish**.
> **Important:**
> Windows requires HTTPS (SSL) for WebDAV connections by default.
> If your server uses plain HTTP, you must adjust a registry setting:
>
> 1. Open **Registry Editor** (`regedit.exe`).
> 2. Navigate to:
>
> ```text
> HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WebClient\Parameters
> ```
>
> 3. Find or create a `DWORD` value named **BasicAuthLevel**.
> 4. Set its value to `2`.
> 5. Restart the **WebClient** service or reboot.
📖 See the full [WebDAV Usage Wiki](https://github.com/error311/FileRise/wiki/WebDAV) for SSL setup, HTTP workaround, and troubleshooting.
---
@@ -405,6 +387,8 @@ For more Q&A or to ask for help, open a Discussion or Issue.
## Security posture
> ⚠️ **Security fix in v1.5.0** — ACL hardening. If youre on ≤1.4.x, please upgrade.
We practice responsible disclosure. All known security issues are fixed in **v1.5.0** (ACL hardening).
Advisories: [GHSA-6p87-q9rh-95wh](https://github.com/error311/FileRise/security/advisories/GHSA-6p87-q9rh-95wh) (≤ 1.3.15), [GHSA-jm96-2w52-5qjj](https://github.com/error311/FileRise/security/advisories/GHSA-jm96-2w52-5qjj) (v1.4.0). Fixed in **v1.5.0**. Thanks to [@kiwi865](https://github.com/kiwi865) for reporting.
If youre running ≤1.4.x, please upgrade.
@@ -446,18 +430,11 @@ Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
### ONLYOFFICE integration
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
- **We do not bundle ONLYOFFICE.** Admins point FileRise to an existing ONLYOFFICE Docs server and (optionally) set a JWT secret in **Admin > ONLYOFFICE**.
- **Licensing:** ONLYOFFICE Document Server (Community Edition) is released under the GNU AGPL v3. Enterprise editions are commercially licensed. When you deploy ONLYOFFICE, you are responsible for complying with the license of the edition you use.
Project page & license: <https://github.com/ONLYOFFICE/DocumentServer> (AGPL-3.0)
- **FileRise license unaffected:** FileRise communicates with ONLYOFFICE over standard HTTP and loads `api.js` from the configured Document Server at runtime; FileRise does not redistribute ONLYOFFICE code.
- **Trademarks:** ONLYOFFICE is a trademark of Ascensio System SIA. FileRise is not affiliated with or endorsed by ONLYOFFICE.
#### Security / CSP
If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src`, `connect-src`). The Admin panel shows a ready-to-copy line for Apache/Nginx.
### PHP Libraries
- **[jumbojett/openid-connect-php](https://github.com/jumbojett/OpenID-Connect-PHP)** (v^1.0.0)
@@ -479,7 +456,7 @@ If you enable ONLYOFFICE, allow its origin in your CSP (`script-src`, `frame-src
## Acknowledgments
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
- [uploader](https://github.com/sensboston/uploader) by @sensboston.
---

View File

@@ -33,6 +33,10 @@ define('ONLYOFFICE_DOCS_ORIGIN', 'http://192.168.1.61'); // your Document Server
define('ONLYOFFICE_DEBUG', true);
*/
if (!defined('OIDC_TOKEN_ENDPOINT_AUTH_METHOD')) {
define('OIDC_TOKEN_ENDPOINT_AUTH_METHOD', 'client_secret_basic'); // default
}
// Encryption helpers
function encryptData($data, $encryptionKey)
{

View File

@@ -1,12 +1,16 @@
# --------------------------------
# FileRise portable .htaccess
# --------------------------------
Options -Indexes
Options -Indexes -Multiviews
DirectoryIndex index.html
# Allow PATH_INFO for routes like /webdav.php/foo/bar
AcceptPathInfo On
# ---------------- Security: dotfiles ----------------
<IfModule mod_authz_core.c>
# Block dotfiles like .env, .git, etc., but allow ACME under .well-known
<FilesMatch "^\.(?!well-known(?:/|$))">
# Block direct access to dotfiles like .env, .gitignore, etc.
<FilesMatch "^\..*">
Require all denied
</FilesMatch>
</IfModule>
@@ -15,15 +19,28 @@ DirectoryIndex index.html
<IfModule mod_rewrite.c>
RewriteEngine On
# Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
RewriteRule ^ - [L]
# Let ACME http-01 pass BEFORE any redirect (needed for auto-renew)
# 0) Let ACME http-01 pass BEFORE any other rule (needed for auto-renew)
RewriteCond %{REQUEST_URI} ^/.well-known/acme-challenge/
RewriteRule - - [L]
# HTTPS redirect (enable ONE of these, comment the other)
# 1) Block hidden files/dirs anywhere EXCEPT .well-known (path-aware)
# Prevents requests like /.env, /.git/config, /.ssh/id_rsa, etc.
RewriteRule "(^|/)\.(?!well-known/)" - [F]
# 2) Deny direct access to PHP except the API endpoints and WebDAV front controller
# - allow /api/*.php (API endpoints)
# - allow /api.php (ReDoc/spec page)
# - allow /webdav.php (SabreDAV front)
RewriteCond %{REQUEST_URI} !^/api/ [NC]
RewriteCond %{REQUEST_URI} !^/api\.php$ [NC]
RewriteCond %{REQUEST_URI} !^/webdav\.php$ [NC]
RewriteRule \.php$ - [F,L]
# 3) Never redirect local/dev hosts
RewriteCond %{HTTP_HOST} ^(localhost|127\.0\.0\.1|fr\.local|192\.168\.[0-9]+\.[0-9]+)$ [NC]
RewriteRule ^ - [L]
# 4) HTTPS redirect (enable ONE of these, comment the other)
# A) Direct TLS on this server
#RewriteCond %{HTTPS} !=on
@@ -35,7 +52,7 @@ RewriteRule - - [L]
#RewriteCond %{HTTPS} !=on
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
# Mark versioned assets (?v=...) with env flag for caching rules below
# 5) Mark versioned assets (?v=...) with env flag for caching rules below
RewriteCond %{QUERY_STRING} (^|&)v= [NC]
RewriteRule ^ - [E=IS_VER:1]
</IfModule>
@@ -98,7 +115,6 @@ RewriteRule ^ - [E=IS_VER:1]
# ---------------- Compression ----------------
<IfModule mod_brotli.c>
# Do NOT set BrotliCompressionQuality in .htaccess (vhost/server only)
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript application/json image/svg+xml
</IfModule>
<IfModule mod_deflate.c>

View File

@@ -0,0 +1,24 @@
<?php
// public/api/file/downloadZipFile.php
/**
* @OA\Get(
* path="/api/file/downloadZipFile.php",
* summary="Download a finished ZIP by token",
* description="Streams the zip once; token is one-shot.",
* operationId="downloadZipFile",
* tags={"Files"},
* security={{"cookieAuth": {}}},
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
* @OA\Parameter(name="name", in="query", required=false, @OA\Schema(type="string"), description="Suggested filename"),
* @OA\Response(response=200, description="ZIP stream"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=404, description="Not found")
* )
*/
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$controller = new FileController();
$controller->downloadZipFile();

View File

@@ -0,0 +1,23 @@
<?php
// public/api/file/zipStatus.php
/**
* @OA\Get(
* path="/api/file/zipStatus.php",
* summary="Check status of a background ZIP build",
* description="Returns status for the authenticated user's token.",
* operationId="zipStatus",
* tags={"Files"},
* security={{"cookieAuth": {}}},
* @OA\Parameter(name="k", in="query", required=true, @OA\Schema(type="string"), description="Job token"),
* @OA\Response(response=200, description="Status payload"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=404, description="Not found")
* )
*/
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FileController.php';
$controller = new FileController();
$controller->zipStatus();

View File

@@ -1,245 +1,18 @@
<?php
// public/api/folder/capabilities.php
/**
* @OA\Get(
* path="/api/folder/capabilities.php",
* summary="Get effective capabilities for the current user in a folder",
* description="Computes the caller's capabilities for a given folder by combining account flags (readOnly/disableUpload), ACL grants (read/write/share), and the user-folder-only scope. Returns booleans indicating what the user can do.",
* operationId="getFolderCapabilities",
* tags={"Folders"},
* security={{"cookieAuth": {}}},
*
* @OA\Parameter(
* name="folder",
* in="query",
* required=false,
* description="Target folder path. Defaults to 'root'. Supports nested paths like 'team/reports'.",
* @OA\Schema(type="string"),
* example="projects/acme"
* ),
*
* @OA\Response(
* response=200,
* description="Capabilities computed successfully.",
* @OA\JsonContent(
* type="object",
* required={"user","folder","isAdmin","flags","canView","canUpload","canCreate","canRename","canDelete","canMoveIn","canShare"},
* @OA\Property(property="user", type="string", example="alice"),
* @OA\Property(property="folder", type="string", example="projects/acme"),
* @OA\Property(property="isAdmin", type="boolean", example=false),
* @OA\Property(
* property="flags",
* type="object",
* required={"folderOnly","readOnly","disableUpload"},
* @OA\Property(property="folderOnly", type="boolean", example=false),
* @OA\Property(property="readOnly", type="boolean", example=false),
* @OA\Property(property="disableUpload", type="boolean", example=false)
* ),
* @OA\Property(property="owner", type="string", nullable=true, example="alice"),
* @OA\Property(property="canView", type="boolean", example=true, description="User can view items in this folder."),
* @OA\Property(property="canUpload", type="boolean", example=true, description="User can upload/edit/rename/move/delete items (i.e., WRITE)."),
* @OA\Property(property="canCreate", type="boolean", example=true, description="User can create subfolders here."),
* @OA\Property(property="canRename", type="boolean", example=true, description="User can rename items here."),
* @OA\Property(property="canDelete", type="boolean", example=true, description="User can delete items here."),
* @OA\Property(property="canMoveIn", type="boolean", example=true, description="User can move items into this folder."),
* @OA\Property(property="canShare", type="boolean", example=false, description="User can create share links for this folder.")
* )
* ),
* @OA\Response(response=400, description="Invalid folder name."),
* @OA\Response(response=401, ref="#/components/responses/Unauthorized")
* )
*/
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UserModel.php';
require_once PROJECT_ROOT . '/src/models/FolderModel.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
header('Content-Type: application/json');
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
$username = (string)($_SESSION['username'] ?? '');
if ($username === '') { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
@session_write_close();
// --- auth ---
$username = $_SESSION['username'] ?? '';
if ($username === '') {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
exit;
}
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
// --- helpers ---
function loadPermsFor(string $u): array {
try {
if (function_exists('loadUserPermissions')) {
$p = loadUserPermissions($u);
return is_array($p) ? $p : [];
}
if (class_exists('userModel') && method_exists('userModel', 'getUserPermissions')) {
$all = userModel::getUserPermissions();
if (is_array($all)) {
if (isset($all[$u])) return (array)$all[$u];
$lk = strtolower($u);
if (isset($all[$lk])) return (array)$all[$lk];
}
}
} catch (Throwable $e) {}
return [];
}
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
// direct owner
if (ACL::isOwner($user, $perms, $f)) return true;
// ancestor owner
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
/**
* folder-only scope:
* - Admins: always in scope
* - Non folder-only accounts: always in scope
* - Folder-only accounts: in scope iff:
* - folder == username OR subpath of username, OR
* - user is owner of this folder (or any ancestor)
*/
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
if ($isAdmin) return true;
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
//if (!$folderOnly) return true;
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
// folder-only users cannot act on root unless they own a subfolder (handled below)
return isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
// Treat ownership as in-scope
return isOwnerOrAncestorOwner($u, $perms, $f);
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// validate folder path
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
if (empty($parts)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
http_response_code(400);
echo json_encode(['error' => 'Invalid folder name.']);
exit;
}
}
$folder = implode('/', $parts);
}
// --- user + flags ---
$perms = loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$readOnly = !empty($perms['readOnly']);
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
// --- ACL base abilities ---
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
// granular base
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
// --- Apply scope + flags to effective UI actions ---
$canView = $canViewBase && $inScope; // keep scope for folder-only
$canUpload = $gUploadBase && !$readOnly && $inScope;
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
$canDelete = $gDeleteBase && !$readOnly && $inScope;
// Destination can receive items if user can create/write (or manage) here
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
$canMoveIn = $canReceive;
$canMoveAlias = $canMoveIn;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
// Sharing respects scope; optionally also gate on readOnly
$canShare = $canShareBase && $inScope; // legacy umbrella
$canShareFileEff = $gShareFile && $inScope;
$canShareFoldEff = $gShareFolder && $inScope;
// never allow destructive ops on root
$isRoot = ($folder === 'root');
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFoldEff = false;
$canMoveFolder = false;
}
if (!$isRoot) {
$canMoveFolder = (ACL::canManage($username, $perms, $folder) || ACL::isOwner($username, $perms, $folder))
&& !$readOnly;
}
$owner = null;
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
echo json_encode([
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly,
],
'owner' => $owner,
// viewing
'canView' => $canView,
'canViewOwn' => $canViewOwn,
// write-ish
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canMove' => $canMoveAlias,
'canMoveFolder'=> $canMoveFolder,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
// sharing
'canShare' => $canShare, // legacy
'canShareFile' => $canShareFileEff,
'canShareFolder' => $canShareFoldEff,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo json_encode(FolderController::capabilities($folder, $username), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
try {
$ctl = new FolderController();
$ctl->getFolderColors(); // echoes JSON + status codes
} catch (Throwable $e) {
error_log('getFolderColors failed: ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Internal server error']);
}

View File

@@ -0,0 +1,28 @@
<?php
// Fast ACL-aware peek for tree icons/chevrons
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
echo json_encode(FolderController::stats($folder, $username, $perms), JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('X-Content-Type-Options: nosniff');
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
if (empty($_SESSION['authenticated'])) { http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit; }
$username = (string)($_SESSION['username'] ?? '');
$perms = [
'role' => $_SESSION['role'] ?? null,
'admin' => $_SESSION['admin'] ?? null,
'isAdmin' => $_SESSION['isAdmin'] ?? null,
'folderOnly' => $_SESSION['folderOnly'] ?? null,
'readOnly' => $_SESSION['readOnly'] ?? null,
];
@session_write_close();
$folder = isset($_GET['folder']) ? (string)$_GET['folder'] : 'root';
$folder = str_replace('\\', '/', trim($folder));
$folder = ($folder === '' || strcasecmp($folder, 'root') === 0) ? 'root' : trim($folder, '/');
$limit = max(1, min(2000, (int)($_GET['limit'] ?? 500)));
$cursor = isset($_GET['cursor']) && $_GET['cursor'] !== '' ? (string)$_GET['cursor'] : null;
$res = FolderController::listChildren($folder, $username, $perms, $cursor, $limit);
echo json_encode($res, JSON_UNESCAPED_SLASHES);

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../config/config.php';
require_once PROJECT_ROOT . '/src/controllers/FolderController.php';
if (session_status() !== PHP_SESSION_ACTIVE) { @session_start(); }
try {
$ctl = new FolderController();
$ctl->saveFolderColor(); // validates method + CSRF, does ACL, echoes JSON
} catch (Throwable $e) {
error_log('saveFolderColor failed: ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['error' => 'Internal server error']);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/assets/logo-128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/assets/logo-16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

BIN
public/assets/logo-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
public/assets/logo-256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
public/assets/logo-32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 B

BIN
public/assets/logo-48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/assets/logo-64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -3,17 +3,24 @@
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>FileRise</title>
<meta name="theme-color" content="#0b5ed7">
<script>(function(){try{var s=localStorage.getItem('darkMode');var isDark=(s===null)?(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches):(s==='1'||s==='true');var root=document.documentElement;root.setAttribute('data-theme',isDark?'dark':'light');root.classList.toggle('dark-mode',isDark);var bg=isDark?'#121212':'#ffffff';root.style.backgroundColor=bg;root.style.colorScheme=isDark?'dark':'light';root.style.setProperty('--pre-bg',bg);var m=document.querySelector('meta[name="theme-color"]');if(m)m.setAttribute('content',bg);}catch(e){}})();</script>
<style id="pretheme-css">
html,body,#loadingOverlay{background:var(--pre-bg,#ffffff) !important;}
</style>
<link rel="icon" type="image/png" href="/assets/logo.png"><link rel="icon" type="image/svg+xml" href="/assets/logo.svg">
<!-- Favicons (ordered: SVG -> PNGs -> ICO) -->
<link rel="icon" href="/assets/logo.svg?v={{APP_QVER}}" type="image/svg+xml" sizes="any">
<link rel="icon" href="/assets/logo.png?v={{APP_QVER}}" type="image/png" sizes="512x512">
<link rel="icon" href="/assets/logo-32.png?v={{APP_QVER}}" type="image/png" sizes="32x32">
<link rel="icon" href="/assets/logo-16.png?v={{APP_QVER}}" type="image/png" sizes="16x16">
<link rel="shortcut icon" href="/assets/favicon.ico?v={{APP_QVER}}">
<meta name="description" content="FileRise is a fast, self-hosted file manager with granular per-folder ACLs, drag-and-drop folder moves, WebDAV, tagging, and a clean UI.">
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="theme-color" content="#0b5ed7"><meta name="color-scheme" content="light dark">
<meta name="csrf-token" content=""><meta name="share-url" content=""><meta name="color-scheme" content="light dark">
<link rel="manifest" href="/manifest.webmanifest?v={{APP_QVER}}">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png?v={{APP_QVER}}">
<!-- Critical CSS -->
<!-- Critical CSS -->
<link rel="stylesheet" href="/vendor/bootstrap/4.5.2/bootstrap.min.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/styles.css?v={{APP_QVER}}">
<link rel="stylesheet" href="/css/vendor/roboto.css?v={{APP_QVER}}">
@@ -245,6 +252,9 @@
</div>
</div>
</div>
<button id="colorFolderBtn" class="btn btn-color-folder ml-2" data-i18n-title="color_folder" title="Color folder">
<i class="material-icons">palette</i>
</button>
<button id="shareFolderBtn" class="btn btn-secondary ml-2" data-i18n-title="share_folder">
<i class="material-icons">share</i>
@@ -267,14 +277,24 @@
</div>
</div>
<div id="folderHelpTooltip" class="folder-help-tooltip"
style="display: none; position: absolute; top: 50px; right: 15px; background: #fff; border: 1px solid #ccc; padding: 10px; z-index: 1000; box-shadow: 2px 2px 6px rgba(0,0,0,0.2);">
<ul class="folder-help-list" style="margin: 0; padding-left: 20px;">
<li data-i18n-key="folder_help_item_1">Click on a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_item_2">Use [-] to collapse and [+] to expand folders.</li>
<li data-i18n-key="folder_help_item_3">Select a folder and click "Create Folder" to add a
subfolder.</li>
<li data-i18n-key="folder_help_item_4">To rename or delete a folder, select it and then click
the appropriate button.</li>
style="display:none;position:absolute;top:50px;right:15px;background:#fff;border:1px solid #ccc;padding:10px;z-index:1000;box-shadow:2px 2px 6px rgba(0,0,0,0.2);border-radius:8px;max-width:320px;line-height:1.35;">
<style>
/* Dark mode polish */
body.dark-mode #folderHelpTooltip {
background:#2c2c2c; border-color:#555; color:#e8e8e8; box-shadow:2px 2px 10px rgba(0,0,0,.5);
}
#folderHelpTooltip .folder-help-list { margin:0; padding-left:18px; }
#folderHelpTooltip .folder-help-list li { margin:6px 0; }
</style>
<ul class="folder-help-list">
<li data-i18n-key="folder_help_click_view">Click a folder in the tree to view its files.</li>
<li data-i18n-key="folder_help_expand_chevrons">Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.</li>
<li data-i18n-key="folder_help_context_menu">Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.</li>
<li data-i18n-key="folder_help_drag_drop">Drag a folder onto another folder <em>or</em> a breadcrumb to move it.</li>
<li data-i18n-key="folder_help_load_more">For long lists, click “Load more” to fetch the next page of folders.</li>
<li data-i18n-key="folder_help_last_folder">Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.</li>
<li data-i18n-key="folder_help_breadcrumbs">Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.</li>
<li data-i18n-key="folder_help_permissions">Buttons enable/disable based on your permissions for the selected folder.</li>
</ul>
</div>
</div>
@@ -345,6 +365,10 @@
<li id="createFolderOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="create_folder">Create folder</span>
</li>
<li id="uploadOption" class="dropdown-item" style="padding:8px 12px; cursor:pointer;">
<span data-i18n-key="upload">Upload file(s)</span>
</li>
</ul>
</div>
<!-- Create File Modal -->
@@ -453,6 +477,26 @@
</form>
</div>
</div>
<div id="fileContextMenu" class="filr-menu" hidden role="menu" aria-label="File actions">
<button type="button" class="mi" data-action="create_file" data-when="always"><i class="material-icons">note_add</i><span>Create file</span></button>
<div class="sep" data-when="always"></div>
<button type="button" class="mi" data-action="delete_selected" data-when="any"><i class="material-icons">delete</i><span>Delete selected</span></button>
<button type="button" class="mi" data-action="copy_selected" data-when="any"><i class="material-icons">content_copy</i><span>Copy selected</span></button>
<button type="button" class="mi" data-action="move_selected" data-when="any"><i class="material-icons">drive_file_move</i><span>Move selected</span></button>
<button type="button" class="mi" data-action="download_zip" data-when="any"><i class="material-icons">archive</i><span>Download as ZIP</span></button>
<button type="button" class="mi" data-action="extract_zip" data-when="zip"><i class="material-icons">unarchive</i><span>Extract ZIP</span></button>
<div class="sep" data-when="any"></div>
<button type="button" class="mi" data-action="tag_selected" data-when="many"><i class="material-icons">sell</i><span>Tag selected</span></button>
<button type="button" class="mi" data-action="preview" data-when="one"><i class="material-icons">visibility</i><span>Preview</span></button>
<button type="button" class="mi" data-action="edit" data-when="can-edit"><i class="material-icons">edit</i><span>Edit</span></button>
<button type="button" class="mi" data-action="rename" data-when="one"><i class="material-icons">drive_file_rename_outline</i><span>Rename</span></button>
<button type="button" class="mi" data-action="tag_file" data-when="one"><i class="material-icons">sell</i><span>Tag file</span></button>
</div>
<div id="removeUserModal" class="modal" style="display:none;">
<div class="modal-content">
<h3 data-i18n-key="remove_user_title">Remove User</h3>
@@ -484,6 +528,19 @@
</div>
</div>
</div>
<!-- Upload Modal -->
<div id="uploadModal" class="modal" style="display:none;">
<div class="modal-content" style="max-width:900px;width:92vw;">
<div class="modal-header" style="display:flex;justify-content:space-between;align-items:center;">
<h3 style="margin:0;">Upload</h3>
<span id="closeUploadModal" class="editor-close-btn" role="button" aria-label="Close">&times;</span>
</div>
<div class="modal-body">
<!-- we will MOVE #uploadCard into here while open -->
<div id="uploadModalBody"></div>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -58,7 +58,7 @@ function wireHeaderTitleLive() {
function renderMaskedInput({ id, label, hasValue, isSecret = false }) {
const type = isSecret ? 'password' : 'text';
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : '';
const disabled = hasValue ? 'disabled data-replace="0" placeholder="•••••• (saved)"' : 'data-replace="1"';
const replaceBtn = hasValue
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-replace-for="${id}">Replace</button>`
: '';
@@ -465,7 +465,7 @@ export function openAdminPanel() {
background:${dark ? "#2c2c2c" : "#fff"};
color:${dark ? "#e0e0e0" : "#000"};
padding:20px; max-width:1100px; width:50%;
border-radius:8px; position:relative;
position:relative;
max-height:90vh; overflow:auto;
border:1px solid ${dark ? "#555" : "#ccc"};
`;
@@ -1070,11 +1070,15 @@ function handleSave() {
const idEl = document.getElementById("oidcClientId");
const scEl = document.getElementById("oidcClientSecret");
if (idEl?.dataset.replace === '1' && idEl.value.trim() !== '') {
payload.oidc.clientId = idEl.value.trim();
const idVal = idEl?.value.trim() || '';
const secVal = scEl?.value.trim() || '';
const idFirstTime = idEl && !idEl.hasAttribute('data-replace'); // no saved value yet
const secFirstTime = scEl && !scEl.hasAttribute('data-replace'); // no saved value yet
if ((idEl?.dataset.replace === '1' || idFirstTime) && idVal !== '') {
payload.oidc.clientId = idVal;
}
if (scEl?.dataset.replace === '1' && scEl.value.trim() !== '') {
payload.oidc.clientSecret = scEl.value.trim();
if ((scEl?.dataset.replace === '1' || secFirstTime) && secVal !== '') {
payload.oidc.clientSecret = secVal;
}
const ooSecretEl = document.getElementById("ooJwtSecret");
@@ -1521,7 +1525,6 @@ export function openUserPermissionsModal() {
padding: 20px;
width: clamp(980px, 92vw, 1280px);
max-width: none;
border-radius: 8px;
position: relative;
max-height: 90vh;
overflow: auto;
@@ -1650,7 +1653,7 @@ export async function openUserFlagsModal() {
<div class="modal-content"
style="background:${contentBg}; color:${contentFg};
padding:16px; max-width:900px; width:95%;
border-radius:8px; position:relative;
position:relative;
border:1px solid ${borderCol};">
<span id="closeUserFlagsModal"
class="editor-close-btn"

View File

@@ -5,10 +5,24 @@ import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { setupTrashRestoreDelete } from './trashRestoreDelete.js?v={{APP_QVER}}';
import { initDragAndDrop, loadSidebarOrder, loadHeaderOrder } from './dragAndDrop.js?v={{APP_QVER}}';
import { initTagSearch } from './fileTags.js?v={{APP_QVER}}';
import { initFileActions } from './fileActions.js?v={{APP_QVER}}';
import { initFileActions, openUploadModal } from './fileActions.js?v={{APP_QVER}}';
import { initUpload } from './upload.js?v={{APP_QVER}}';
import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
window.__pendingDropData = null;
function waitFor(selector, timeout = 1200) {
return new Promise(resolve => {
const t0 = performance.now();
(function tick() {
const el = document.querySelector(selector);
if (el) return resolve(el);
if (performance.now() - t0 >= timeout) return resolve(null);
requestAnimationFrame(tick);
})();
});
}
// Keep a bound handle to the native fetch so wrappers elsewhere never recurse
const _nativeFetch = window.fetch.bind(window);
@@ -84,25 +98,53 @@ export function initializeApp() {
// Enable tag search UI; initial file list load is controlled elsewhere
initTagSearch();
// Hook DnD relay from fileList area into upload area
const fileListArea = document.getElementById('fileListContainer');
const uploadArea = document.getElementById('uploadDropArea');
if (fileListArea && uploadArea) {
const fileListArea = document.getElementById('fileList');
if (fileListArea) {
let hoverTimer = null;
fileListArea.addEventListener('dragover', e => {
e.preventDefault();
fileListArea.classList.add('drop-hover');
// (optional) auto-open after brief hover so users see the drop target
if (!hoverTimer) {
hoverTimer = setTimeout(() => {
if (typeof window.openUploadModal === 'function') window.openUploadModal();
}, 400);
}
});
fileListArea.addEventListener('dragleave', () => {
fileListArea.classList.remove('drop-hover');
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
});
fileListArea.addEventListener('drop', e => {
fileListArea.addEventListener('drop', async e => {
e.preventDefault();
fileListArea.classList.remove('drop-hover');
uploadArea.dispatchEvent(new DragEvent('drop', {
dataTransfer: e.dataTransfer,
bubbles: true,
cancelable: true
}));
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null; }
// 1) open the same modal that the Create menu uses
openUploadModal();
// 2) wait until the upload area exists *in the modal*, then relay the drop
// Prefer a scoped selector first to avoid duplicate IDs.
const uploadArea =
(await waitFor('#uploadModal #uploadDropArea')) ||
(await waitFor('#uploadDropArea'));
if (!uploadArea) return;
try {
// Many browsers make dataTransfer read-only; we try the direct attach first
const relay = new DragEvent('drop', { bubbles: true, cancelable: true });
Object.defineProperty(relay, 'dataTransfer', { value: e.dataTransfer });
uploadArea.dispatchEvent(relay);
} catch {
// Fallback: stash DataTransfer and fire a plain event; handler will read the stash
window.__pendingDropData = e.dataTransfer || null;
uploadArea.dispatchEvent(new Event('drop', { bubbles: true, cancelable: true }));
}
});
}

View File

@@ -195,7 +195,6 @@ export async function openUserPanel() {
color: ${isDark ? '#e0e0e0' : '#000'};
padding: 20px;
max-width: 600px; width:90%;
border-radius: 8px;
overflow-y: auto; max-height: 500px;
border: ${isDark ? '1px solid #444' : '1px solid #ccc'};
box-sizing: border-box;

View File

@@ -156,7 +156,7 @@ export function buildSearchAndPaginationControls({ currentPage, totalPages, sear
export function buildFileTableHeader(sortOrder) {
return `
<table class="table">
<table class="table filr-table table-hover table-striped">
<thead>
<tr>
<th class="checkbox-col"><input type="checkbox" id="selectAll"></th>
@@ -283,9 +283,9 @@ export function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr');
if (!row) return;
if (checkbox.checked) {
row.classList.add('row-selected');
row.classList.add('row-selected', 'selected');
} else {
row.classList.remove('row-selected');
row.classList.remove('row-selected', 'selected');
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
import { showToast, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { formatFolderName } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
export function handleDeleteSelected(e) {
@@ -12,7 +13,6 @@ export function handleDeleteSelected(e) {
showToast("no_files_selected");
return;
}
window.filesToDelete = Array.from(checkboxes).map(chk => chk.value);
const count = window.filesToDelete.length;
document.getElementById("deleteFilesMessage").textContent = t("confirm_delete_files", { count: count });
@@ -20,6 +20,52 @@ export function handleDeleteSelected(e) {
attachEnterKeyListener("deleteFilesModal", "confirmDeleteFiles");
}
// --- Upload modal "portal" support ---
let _uploadCardSentinel = null;
export function openUploadModal() {
const modal = document.getElementById('uploadModal');
const body = document.getElementById('uploadModalBody');
const card = document.getElementById('uploadCard'); // <-- your existing card
window.openUploadModal = openUploadModal;
window.__pendingDropData = null;
if (!modal || !body || !card) {
console.warn('Upload modal or upload card not found');
return;
}
// Create a hidden sentinel so we can put the card back in place later
if (!_uploadCardSentinel) {
_uploadCardSentinel = document.createElement('div');
_uploadCardSentinel.id = 'uploadCardSentinel';
_uploadCardSentinel.style.display = 'none';
card.parentNode.insertBefore(_uploadCardSentinel, card);
}
// Move the actual card node into the modal (keeps all existing listeners)
body.appendChild(card);
// Show modal
modal.style.display = 'block';
// Focus the chooser for quick keyboard flow
setTimeout(() => {
const chooseBtn = document.getElementById('customChooseBtn');
if (chooseBtn) chooseBtn.focus();
}, 50);
}
export function closeUploadModal() {
const modal = document.getElementById('uploadModal');
const card = document.getElementById('uploadCard');
if (_uploadCardSentinel && _uploadCardSentinel.parentNode && card) {
_uploadCardSentinel.parentNode.insertBefore(card, _uploadCardSentinel);
}
if (modal) modal.style.display = 'none';
}
document.addEventListener("DOMContentLoaded", function () {
const cancelDelete = document.getElementById("cancelDeleteFiles");
if (cancelDelete) {
@@ -47,6 +93,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files deleted successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not delete files"));
}
@@ -119,7 +166,7 @@ export async function handleCreateFile(e) {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type':'application/json',
'Content-Type': 'application/json',
'X-CSRF-Token': window.csrfToken
},
// ⚠️ must send `name`, not `filename`
@@ -129,6 +176,7 @@ export async function handleCreateFile(e) {
if (!js.success) throw new Error(js.error);
showToast(t('file_created'));
loadFileList(folder);
refreshFolderIcon(folder);
} catch (err) {
showToast(err.message || t('error_creating_file'));
} finally {
@@ -139,7 +187,7 @@ export async function handleCreateFile(e) {
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 (cancel) cancel.addEventListener('click', () => document.getElementById('createFileModal').style.display = 'none');
if (confirm) confirm.addEventListener('click', handleCreateFile);
});
@@ -265,7 +313,7 @@ document.addEventListener("DOMContentLoaded", () => {
const cancelZipBtn = document.getElementById("cancelDownloadZip");
const confirmZipBtn = document.getElementById("confirmDownloadZip");
const cancelCreate = document.getElementById('cancelCreateFile');
if (cancelCreate) {
cancelCreate.addEventListener('click', () => {
document.getElementById('createFileModal').style.display = 'none';
@@ -300,12 +348,13 @@ document.addEventListener("DOMContentLoaded", () => {
}
showToast(t('file_created_successfully'));
loadFileList(window.currentFolder);
refreshFolderIcon(folder);
} catch (err) {
console.error(err);
showToast(err.message || t('error_creating_file'));
}
});
attachEnterKeyListener('createFileModal','confirmCreateFile');
attachEnterKeyListener('createFileModal', 'confirmCreateFile');
}
// 1) Cancel button hides the name modal
@@ -321,63 +370,187 @@ document.addEventListener("DOMContentLoaded", () => {
confirmZipBtn.addEventListener("click", async () => {
// a) Validate ZIP filename
let zipName = document.getElementById("zipFileNameInput").value.trim();
if (!zipName) {
showToast("Please enter a name for the zip file.");
return;
}
if (!zipName.toLowerCase().endsWith(".zip")) {
zipName += ".zip";
}
if (!zipName) { showToast("Please enter a name for the zip file."); return; }
if (!zipName.toLowerCase().endsWith(".zip")) zipName += ".zip";
// b) Hide the nameinput modal, show the spinner modal
// b) Hide the nameinput modal, show the progress modal
zipNameModal.style.display = "none";
progressModal.style.display = "block";
// c) (Optional) update the “Preparing…” text if you gave it an ID
// c) Title text (optional)
const titleEl = document.getElementById("downloadProgressTitle");
if (titleEl) titleEl.textContent = `Preparing ${zipName}`;
try {
// d) POST and await the ZIP blob
const res = await fetch("/api/file/downloadZip.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: window.currentFolder || "root",
files: window.filesToDownload
})
});
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";
// d) Queue the job
const res = await fetch("/api/file/downloadZip.php", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder: window.currentFolder || "root", files: window.filesToDownload })
});
const jsr = await res.json().catch(() => ({}));
if (!res.ok || !jsr.ok) {
const msg = (jsr && jsr.error) ? jsr.error : `Status ${res.status}`;
throw new Error(msg);
}
const token = jsr.token;
const statusUrl = jsr.statusUrl;
const downloadUrl = jsr.downloadUrl + "&name=" + encodeURIComponent(zipName);
// Ensure a progress UI exists in the modal
function ensureZipProgressUI() {
const modalEl = document.getElementById("downloadProgressModal");
if (!modalEl) {
// really shouldn't happen, but fall back to body
console.warn("downloadProgressModal not found; falling back to document.body");
}
// Prefer a dedicated content node inside the modal
let host =
(modalEl && modalEl.querySelector("#downloadProgressContent")) ||
(modalEl && modalEl.querySelector(".modal-body")) ||
(modalEl && modalEl.querySelector(".rise-modal-body")) ||
(modalEl && modalEl.querySelector(".modal-content")) ||
(modalEl && modalEl.querySelector(".content")) ||
null;
// If no suitable container, create one inside the modal
if (!host) {
host = document.createElement("div");
host.id = "downloadProgressContent";
(modalEl || document.body).appendChild(host);
}
// Helper: ensure/move an element with given id into host
function ensureInHost(id, tag, init) {
let el = document.getElementById(id);
if (el && el.parentElement !== host) host.appendChild(el); // move if it exists elsewhere
if (!el) {
el = document.createElement(tag);
el.id = id;
if (typeof init === "function") init(el);
host.appendChild(el);
}
return el;
}
// Title
const title = ensureInHost("downloadProgressTitle", "div", (el) => {
el.style.marginBottom = "8px";
el.textContent = "Preparing…";
});
// Progress bar (native <progress>)
const bar = (function () {
let el = document.getElementById("downloadProgressBar");
if (el && el.parentElement !== host) host.appendChild(el); // move into modal
if (!el) {
el = document.createElement("progress");
el.id = "downloadProgressBar";
host.appendChild(el);
}
el.max = 100;
el.value = 0;
el.style.display = ""; // override any inline display:none
el.style.width = "100%";
el.style.height = "1.1em";
return el;
})();
// Text line
const text = ensureInHost("downloadProgressText", "div", (el) => {
el.style.marginTop = "8px";
el.style.fontSize = "0.9rem";
el.style.whiteSpace = "nowrap";
el.style.overflow = "hidden";
el.style.textOverflow = "ellipsis";
});
// Optional spinner hider
const hideSpinner = () => {
const sp = document.getElementById("downloadSpinner");
if (sp) sp.style.display = "none";
};
return { bar, text, title, hideSpinner };
}
function humanBytes(n) {
if (!Number.isFinite(n) || n < 0) return "";
const u = ["B", "KB", "MB", "GB", "TB"]; let i = 0, x = n;
while (x >= 1024 && i < u.length - 1) { x /= 1024; i++; }
return x.toFixed(x >= 10 || i === 0 ? 0 : 1) + " " + u[i];
}
function mmss(sec) {
sec = Math.max(0, sec | 0);
const m = (sec / 60) | 0, s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
const ui = ensureZipProgressUI();
const t0 = Date.now();
// e) Poll until ready
while (true) {
await new Promise(r => setTimeout(r, 1200));
const s = await fetch(`${statusUrl}&_=${Date.now()}`, {
credentials: "include", cache: "no-store",
}).then(r => r.json());
if (s.error) throw new Error(s.error);
if (ui.title) ui.title.textContent = `Preparing ${zipName}`;
// --- RENDER PROGRESS ---
if (typeof s.pct === "number" && ui.bar && ui.text) {
if ((s.phase !== 'finalizing') && (s.pct < 99)) {
ui.hideSpinner && ui.hideSpinner();
const filesDone = s.filesDone ?? 0;
const filesTotal = s.filesTotal ?? 0;
const bytesDone = s.bytesDone ?? 0;
const bytesTotal = s.bytesTotal ?? 0;
// Determinate 098% while enumerating
const pct = Math.max(0, Math.min(98, s.pct | 0));
if (!ui.bar.hasAttribute("value")) ui.bar.value = 0;
ui.bar.value = pct;
ui.text.textContent =
`${pct}% — ${filesDone}/${filesTotal} files, ${humanBytes(bytesDone)} / ${humanBytes(bytesTotal)}`;
} else {
// FINALIZING: keep progress at 100% and show timer + selected totals
if (!ui.bar.hasAttribute("value")) ui.bar.value = 100;
ui.bar.value = 100; // lock at 100 during finalizing
const since = s.finalizeAt ? Math.max(0, (Date.now() / 1000 | 0) - (s.finalizeAt | 0)) : 0;
const selF = s.selectedFiles ?? s.filesTotal ?? 0;
const selB = s.selectedBytes ?? s.bytesTotal ?? 0;
ui.text.textContent = `Finalizing… ${mmss(since)}${selF} file${selF === 1 ? '' : 's'}, ~${humanBytes(selB)}`;
}
} else if (ui.text) {
ui.text.textContent = "Still preparing…";
}
// --- /RENDER ---
if (s.ready) {
// Snap to 100 and close modal just before download
if (ui.bar) { ui.bar.max = 100; ui.bar.value = 100; }
progressModal.style.display = "none";
await new Promise(r => setTimeout(r, 0));
break;
}
if (Date.now() - t0 > 15 * 60 * 1000) throw new Error("Timed out preparing ZIP");
}
// f) Trigger download
const a = document.createElement("a");
a.href = downloadUrl;
a.download = zipName;
a.style.display = "none";
document.body.appendChild(a);
a.click();
a.remove();
// g) Reset for next time
if (ui.bar) ui.bar.value = 0;
if (ui.text) ui.text.textContent = "";
if (Array.isArray(window.filesToDownload)) window.filesToDownload = [];
});
}
});
@@ -509,6 +682,7 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files copied successfully!", 5000);
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
} else {
showToast("Error: " + (data.error || "Could not copy files"), 5000);
}
@@ -561,6 +735,8 @@ document.addEventListener("DOMContentLoaded", function () {
if (data.success) {
showToast("Selected files moved successfully!");
loadFileList(window.currentFolder);
refreshFolderIcon(targetFolder);
refreshFolderIcon(window.currentFolder);
} else {
showToast("Error: " + (data.error || "Could not move files"));
}
@@ -694,10 +870,11 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.addEventListener('DOMContentLoaded', () => {
const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu');
const fileOpt = document.getElementById('createFileOption');
const folderOpt= document.getElementById('createFolderOption');
const btn = document.getElementById('createBtn');
const menu = document.getElementById('createMenu');
const fileOpt = document.getElementById('createFileOption');
const folderOpt = document.getElementById('createFolderOption');
const uploadOpt = document.getElementById('uploadOption'); // NEW
// Toggle dropdown on click
btn.addEventListener('click', (e) => {
@@ -722,6 +899,32 @@ document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', () => {
menu.style.display = 'none';
});
if (uploadOpt) {
uploadOpt.addEventListener('click', () => {
if (menu) menu.style.display = 'none';
openUploadModal();
});
}
// Close buttons / backdrop
const upModal = document.getElementById('uploadModal');
const closeX = document.getElementById('closeUploadModal');
if (closeX) closeX.addEventListener('click', closeUploadModal);
// click outside content to close
if (upModal) {
upModal.addEventListener('click', (e) => {
if (e.target === upModal) closeUploadModal();
});
}
// ESC to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && upModal && upModal.style.display === 'block') {
closeUploadModal();
}
});
});
window.renameFile = renameFile;

View File

@@ -2,124 +2,163 @@
import { showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
export function fileDragStartHandler(event) {
const row = event.currentTarget;
let fileNames = [];
const selectedCheckboxes = document.querySelectorAll("#fileList .file-checkbox:checked");
if (selectedCheckboxes.length > 1) {
selectedCheckboxes.forEach(chk => {
const parentRow = chk.closest("tr");
if (parentRow) {
const cell = parentRow.querySelector("td:nth-child(2)");
if (cell) {
let rawName = cell.textContent.trim();
const tagContainer = cell.querySelector(".tag-badges");
if (tagContainer) {
const tagText = tagContainer.innerText.trim();
if (rawName.endsWith(tagText)) {
rawName = rawName.slice(0, -tagText.length).trim();
}
}
fileNames.push(rawName);
}
}
});
} else {
const fileNameCell = row.querySelector("td:nth-child(2)");
if (fileNameCell) {
let rawName = fileNameCell.textContent.trim();
const tagContainer = fileNameCell.querySelector(".tag-badges");
if (tagContainer) {
const tagText = tagContainer.innerText.trim();
if (rawName.endsWith(tagText)) {
rawName = rawName.slice(0, -tagText.length).trim();
}
}
fileNames.push(rawName);
}
}
if (fileNames.length === 0) return;
const dragData = fileNames.length === 1
? { fileName: fileNames[0], sourceFolder: window.currentFolder || "root" }
: { files: fileNames, sourceFolder: window.currentFolder || "root" };
event.dataTransfer.setData("application/json", JSON.stringify(dragData));
let dragImage = document.createElement("div");
dragImage.style.display = "inline-flex";
dragImage.style.width = "auto";
dragImage.style.maxWidth = "fit-content";
dragImage.style.padding = "6px 10px";
dragImage.style.backgroundColor = "#333";
dragImage.style.color = "#fff";
dragImage.style.border = "1px solid #555";
dragImage.style.borderRadius = "4px";
dragImage.style.alignItems = "center";
dragImage.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.3)";
const icon = document.createElement("span");
icon.className = "material-icons";
icon.textContent = "insert_drive_file";
icon.style.marginRight = "4px";
const label = document.createElement("span");
label.textContent = fileNames.length === 1 ? fileNames[0] : fileNames.length + " files";
dragImage.appendChild(icon);
dragImage.appendChild(label);
document.body.appendChild(dragImage);
event.dataTransfer.setDragImage(dragImage, 5, 5);
setTimeout(() => {
document.body.removeChild(dragImage);
}, 0);
/* ---------------- helpers ---------------- */
function getRowEl(el) {
return el?.closest('tr[data-file-name], .gallery-card[data-file-name]') || null;
}
function getNameFromAny(el) {
const row = getRowEl(el);
if (!row) return null;
// 1) canonical
const n = row.getAttribute('data-file-name');
if (n) return n;
// 2) filename-only span
const span = row.querySelector('.filename-text');
if (span) return span.textContent.trim();
return null;
}
function getSelectedFileNames() {
const boxes = Array.from(document.querySelectorAll('#fileList .file-checkbox:checked'));
const names = boxes.map(cb => getNameFromAny(cb)).filter(Boolean);
// de-dup just in case
return Array.from(new Set(names));
}
function makeDragImage(labelText, iconName = 'insert_drive_file') {
const wrap = document.createElement('div');
Object.assign(wrap.style, {
display: 'inline-flex',
maxWidth: '420px',
padding: '6px 10px',
backgroundColor: '#333',
color: '#fff',
border: '1px solid #555',
borderRadius: '6px',
alignItems: 'center',
gap: '6px',
boxShadow: '2px 2px 6px rgba(0,0,0,0.3)',
fontSize: '12px',
pointerEvents: 'none'
});
const icon = document.createElement('span');
icon.className = 'material-icons';
icon.textContent = iconName;
const label = document.createElement('span');
// trim long single-name labels
const txt = String(labelText || '');
label.textContent = txt.length > 60 ? (txt.slice(0, 57) + '…') : txt;
wrap.appendChild(icon);
wrap.appendChild(label);
document.body.appendChild(wrap);
return wrap;
}
/* ---------------- drag start (rows/cards) ---------------- */
export function fileDragStartHandler(event) {
const row = getRowEl(event.currentTarget);
if (!row) return;
// Use current selection if present; otherwise drag just this rows file
let names = getSelectedFileNames();
if (names.length === 0) {
const single = getNameFromAny(row);
if (single) names = [single];
}
if (names.length === 0) return;
const sourceFolder = window.currentFolder || 'root';
const payload = { files: names, sourceFolder };
// primary payload
event.dataTransfer.setData('application/json', JSON.stringify(payload));
// fallback (lets some environments read something human)
event.dataTransfer.setData('text/plain', names.join('\n'));
// nicer drag image
const dragLabel = (names.length === 1) ? names[0] : `${names.length} files`;
const ghost = makeDragImage(dragLabel, names.length === 1 ? 'insert_drive_file' : 'folder');
event.dataTransfer.setDragImage(ghost, 6, 6);
// clean up the ghost as soon as the browser has captured it
setTimeout(() => { try { document.body.removeChild(ghost); } catch { } }, 0);
}
/* ---------------- folder targets ---------------- */
export function folderDragOverHandler(event) {
event.preventDefault();
event.currentTarget.classList.add("drop-hover");
event.currentTarget.classList.add('drop-hover');
}
export function folderDragLeaveHandler(event) {
event.currentTarget.classList.remove("drop-hover");
event.currentTarget.classList.remove('drop-hover');
}
export function folderDropHandler(event) {
export async function folderDropHandler(event) {
event.preventDefault();
event.currentTarget.classList.remove("drop-hover");
const dropFolder = event.currentTarget.getAttribute("data-folder");
let dragData;
event.currentTarget.classList.remove('drop-hover');
const dropFolder = event.currentTarget.getAttribute('data-folder')
|| event.currentTarget.getAttribute('data-dest-folder')
|| 'root';
// parse drag payload
let dragData = null;
try {
dragData = JSON.parse(event.dataTransfer.getData("application/json"));
} catch (e) {
console.error("Invalid drag data");
const raw = event.dataTransfer.getData('application/json') || '{}';
dragData = JSON.parse(raw);
} catch {
// ignore
}
if (!dragData) {
showToast('Invalid drag data.');
return;
}
if (!dragData || !dragData.fileName) return;
fetch("/api/file/moveFiles.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').getAttribute("content")
},
body: JSON.stringify({
source: dragData.sourceFolder,
files: [dragData.fileName],
destination: dropFolder
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(`File "${dragData.fileName}" moved successfully to ${dropFolder}!`);
loadFileList(dragData.sourceFolder);
} else {
showToast("Error moving file: " + (data.error || "Unknown error"));
}
})
.catch(error => {
console.error("Error moving file via drop:", error);
showToast("Error moving file.");
// normalize names
let names = Array.isArray(dragData.files) ? dragData.files.slice()
: dragData.fileName ? [dragData.fileName]
: [];
names = names.filter(v => typeof v === 'string' && v.length > 0);
if (names.length === 0) {
showToast('No files to move.');
return;
}
const sourceFolder = dragData.sourceFolder || (window.currentFolder || 'root');
if (dropFolder === sourceFolder) {
showToast('Source and destination are the same.');
return;
}
// POST move
try {
const res = await fetch('/api/file/moveFiles.php', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-CSRF-Token': window.csrfToken
},
body: JSON.stringify({
source: sourceFolder,
files: names,
destination: dropFolder
})
});
const data = await res.json().catch(() => ({}));
if (res.ok && data && data.success) {
const msg = (names.length === 1)
? `Moved "${names[0]}" to ${dropFolder}.`
: `Moved ${names.length} files to ${dropFolder}.`;
showToast(msg);
// Refresh whatever view the user is currently looking at
loadFileList(window.currentFolder || sourceFolder);
} else {
const err = (data && (data.error || data.message)) || `HTTP ${res.status}`;
showToast('Error moving file(s): ' + err);
}
} catch (e) {
console.error('Error moving file(s):', e);
showToast('Error moving file(s).');
}
}

View File

@@ -70,7 +70,7 @@ function normalizeModeName(modeOption) {
function getExt(name) { const i = name.lastIndexOf('.'); return i >= 0 ? name.slice(i + 1).toLowerCase() : ''; }
// Cache OO capabilities (enabled flag + ext list) from /api/onlyoffice/status.php
let __ooCaps = { enabled: false, exts: new Set(), fetched: false };
let __ooCaps = { enabled: false, exts: new Set(), fetched: false, docsOrigin: null };
async function fetchOnlyOfficeCapsOnce() {
if (__ooCaps.fetched) return __ooCaps;
@@ -80,6 +80,7 @@ async function fetchOnlyOfficeCapsOnce() {
const j = await r.json();
__ooCaps.enabled = !!j.enabled;
__ooCaps.exts = new Set(Array.isArray(j.exts) ? j.exts : []);
__ooCaps.docsOrigin = j.docsOrigin || null; // harmless if server doesn't send it
}
} catch { /* ignore; keep defaults */ }
__ooCaps.fetched = true;
@@ -93,121 +94,23 @@ async function shouldUseOnlyOffice(fileName) {
function isAbsoluteHttpUrl(u) { return /^https?:\/\//i.test(u || ''); }
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
let src =
srcFromConfig ||
(originFromConfig ? originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js'
: (window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js'));
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
await loadScriptOnce(src);
}
async function openOnlyOffice(fileName, folder) {
let editor; // make visible to the whole function
try {
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
const resp = await fetch(url, { credentials: 'include' });
const text = await resp.text();
let cfg;
try { cfg = JSON.parse(text); } catch {
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
}
if (!resp.ok) throw new Error(cfg.error || `ONLYOFFICE config HTTP ${resp.status}`);
// Must be absolute
const docUrl = cfg?.document?.url;
const cbUrl = cfg?.editorConfig?.callbackUrl;
if (!/^https?:\/\//i.test(docUrl || '') || !/^https?:\/\//i.test(cbUrl || '')) {
throw new Error(`Config URLs must be absolute. document.url='${docUrl}', callbackUrl='${cbUrl}'`);
}
// Load DocsAPI if needed
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
// Modal
const modal = document.createElement('div');
modal.id = 'ooEditorModal';
modal.classList.add('modal', 'editor-modal');
modal.setAttribute('tabindex', '-1');
modal.innerHTML = `
<div class="editor-header">
<h3 class="editor-title">
${t("editing")}: ${escapeHTML(fileName)}
</h3>
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">&times;</button>
</div>
<div class="editor-body" style="flex:1;min-height:200px">
<div id="oo-editor" style="width:100%;height:100%"></div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
modal.focus();
// Well fill this after wiring the toggle, so destroy() can unhook it
let removeThemeListener = () => {};
const destroy = () => {
try { editor?.destroyEditor?.(); } catch {}
try { removeThemeListener(); } catch {}
try { modal.remove(); } catch {}
};
modal.addEventListener('keydown', e => { if (e.key === 'Escape') destroy(); });
document.getElementById('closeEditorX')?.addEventListener('click', destroy);
// Let DS request closing
cfg.events = Object.assign({}, cfg.events, { onRequestClose: destroy });
// Initial theme
const isDark =
document.documentElement.classList.contains('dark-mode') ||
/^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
cfg.editorConfig = cfg.editorConfig || {};
cfg.editorConfig.customization = Object.assign(
{},
cfg.editorConfig.customization,
{ uiTheme: isDark ? 'theme-dark' : 'theme-light' } // <- correct key/value
);
// Launch editor
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
// Live theme switching (ONLYOFFICE v7.2+ supports setTheme)
const darkToggle = document.getElementById('darkModeToggle');
const onDarkToggle = () => {
const nowDark = document.documentElement.classList.contains('dark-mode');
if (editor && typeof editor.setTheme === 'function') {
editor.setTheme(nowDark ? 'dark' : 'light');
}
};
if (darkToggle) {
darkToggle.addEventListener('click', onDarkToggle);
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
}
} catch (e) {
console.error('[ONLYOFFICE] failed to open:', e);
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
}
}
// ---- /ONLYOFFICE integration ----------------------------------------------
// ---- script/css single-load with timeout guards ----
const _loadedScripts = new Set();
const _loadedCss = new Set();
let _corePromise = null;
function loadScriptOnce(url) {
function loadScriptOnce(url, timeoutMs = 12000) {
return new Promise((resolve, reject) => {
if (_loadedScripts.has(url)) return resolve();
const s = document.createElement("script");
const timer = setTimeout(() => {
try { s.remove(); } catch { }
reject(new Error(`Timeout loading: ${url}`));
}, timeoutMs);
s.src = url;
s.async = true;
s.onload = () => { _loadedScripts.add(url); resolve(); };
s.onerror = () => reject(new Error(`Load failed: ${url}`));
s.onload = () => { clearTimeout(timer); _loadedScripts.add(url); resolve(); };
s.onerror = () => { clearTimeout(timer); reject(new Error(`Load failed: ${url}`)); };
document.head.appendChild(s);
});
}
@@ -240,7 +143,6 @@ async function ensureCore() {
async function loadSingleMode(name) {
const rel = MODE_URL[name];
if (!rel) return;
// prepend base if needed
const url = rel.startsWith("http") ? rel : (rel.startsWith("/") ? rel : (CM_BASE + rel));
await loadScriptOnce(url);
}
@@ -265,9 +167,299 @@ async function ensureModeLoaded(modeOption) {
}
// Public helper for callers (we keep your existing function name in use):
const MODE_LOAD_TIMEOUT_MS = 2500; // allow closing immediately; don't wait forever
const MODE_LOAD_TIMEOUT_MS = 300; // allow closing immediately; don't wait forever
// ==== /CodeMirror lazy loader ===============================================
// ---- OO preconnect / prewarm ----
function injectOOPreconnect(origin) {
try {
if (!origin || !isAbsoluteHttpUrl(origin)) return;
const make = (rel) => { const l = document.createElement('link'); l.rel = rel; l.href = origin; return l; };
document.head.appendChild(make('dns-prefetch'));
document.head.appendChild(make('preconnect'));
} catch { }
}
async function ensureOnlyOfficeApi(srcFromConfig, originFromConfig) {
// Prefer explicit src; else derive from origin; else fall back to window/global or default prefix path
let src = srcFromConfig;
if (!src) {
if (originFromConfig && isAbsoluteHttpUrl(originFromConfig)) {
src = originFromConfig.replace(/\/$/, '') + '/web-apps/apps/api/documents/api.js';
} else {
src = window.ONLYOFFICE_API_SRC || '/onlyoffice/web-apps/apps/api/documents/api.js';
}
}
if (window.DocsAPI && typeof window.DocsAPI.DocEditor === 'function') return;
// Try once; if it times out and we derived from origin, fall back to the default prefix path
try {
console.time('oo:api.js');
await loadScriptOnce(src);
} catch (e) {
if (src !== '/onlyoffice/web-apps/apps/api/documents/api.js') {
await loadScriptOnce('/onlyoffice/web-apps/apps/api/documents/api.js');
} else {
throw e;
}
} finally {
console.timeEnd('oo:api.js');
}
}
// ===== ONLYOFFICE: full-screen modal + warm on every click =====
const ALWAYS_WARM_OO = true; // warm EVERY time
const OO_WARM_MS = 300;
function ensureOoModalCss() {
const prev = document.getElementById('ooEditorModalCss');
if (prev) return;
const style = document.createElement('style');
style.id = 'ooEditorModalCss';
style.textContent = `
#ooEditorModal{
--oo-header-h: 40px;
--oo-header-pad-v: 12px;
--oo-header-pad-h: 18px;
--oo-logo-h: 26px; /* tweak logo size */
}
#ooEditorModal{
position:fixed!important; inset:0!important; margin:0!important; padding:0!important;
display:flex!important; flex-direction:column!important; z-index:2147483646!important;
background:var(--oo-modal-bg,#111)!important;
}
/* Header: logo (left) + title (fill) + absolute close (right) */
#ooEditorModal .editor-header{
position:relative; display:flex; align-items:center; gap:12px;
min-height:var(--oo-header-h);
padding:var(--oo-header-pad-v) var(--oo-header-pad-h);
padding-right: calc(var(--oo-header-pad-h) + 64px); /* room for 32px round close */
border-bottom:1px solid rgba(0,0,0,.15);
box-sizing:border-box;
}
#ooEditorModal .editor-logo{
height:var(--oo-logo-h); width:auto; flex:0 0 auto;
display:block; user-select:none; -webkit-user-drag:none;
}
#ooEditorModal .editor-title{
margin:0; font-size:18px; font-weight:700; line-height:1.2;
overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
flex:1 1 auto;
}
/* Your scoped close button style */
#ooEditorModal .editor-close-btn{
position:absolute; top:5px; right:10px;
display:flex; justify-content:center; align-items: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-color:rgba(255,255,255,.9); border:2px solid transparent;
transition:all .3s ease-in-out;
}
#ooEditorModal .editor-close-btn:hover{
color:#fff; background-color:#ff4d4d;
box-shadow:0 0 6px rgba(255,77,77,.8); transform:scale(1.05);
}
.dark-mode #ooEditorModal .editor-close-btn{ background-color:rgba(0,0,0,.7); color:#ff6666; }
.dark-mode #ooEditorModal .editor-close-btn:hover{ background-color:#ff6666; color:#000; }
#ooEditorModal .editor-body{
position:relative!important; flex:1 1 auto!important; min-height:0!important; overflow:hidden!important;
}
#ooEditorModal #oo-editor{ width:100%!important; height:100%!important; }
#ooEditorModal .oo-warm-overlay{
position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
background:rgba(0,0,0,.14); z-index:5; font-weight:600; font-size:14px;
}
html.oo-lock, body.oo-lock{ height:100%!important; overflow:hidden!important; }
`;
document.head.appendChild(style);
}
// Theme-aware background so theres no white/gray edge
function applyModalBg(modal){
const isDark = document.documentElement.classList.contains('dark-mode')
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
const cs = getComputedStyle(document.documentElement);
const bg = (cs.getPropertyValue('--bg-color') || cs.getPropertyValue('--pre-bg') || '').trim()
|| (isDark ? '#121212' : '#ffffff');
modal.style.setProperty('--oo-modal-bg', bg);
}
function lockPageScroll(on){
[document.documentElement, document.body].forEach(el => el.classList.toggle('oo-lock', !!on));
}
function ensureOoFullscreenModal(){
ensureOoModalCss();
let modal = document.getElementById('ooEditorModal');
if (!modal){
modal = document.createElement('div');
modal.id = 'ooEditorModal';
modal.innerHTML = `
<div class="editor-header">
<img class="editor-logo" src="/assets/logo.svg" alt="FileRise logo" />
<h3 class="editor-title"></h3>
<button id="closeEditorX" class="editor-close-btn" aria-label="${t("close") || "Close"}">&times;</button>
</div>
<div class="editor-body">
<div id="oo-editor"></div>
</div>
`;
document.body.appendChild(modal);
} else {
modal.querySelector('.editor-body').innerHTML = `<div id="oo-editor"></div>`;
// ensure logo exists and is placed before title when reusing
const header = modal.querySelector('.editor-header');
if (!header.querySelector('.editor-logo')){
const img = document.createElement('img');
img.className = 'editor-logo';
img.src = '/assets/logo.svg';
img.alt = 'FileRise logo';
header.insertBefore(img, header.querySelector('.editor-title'));
} else {
// make sure order is logo -> title
const logo = header.querySelector('.editor-logo');
const title = header.querySelector('.editor-title');
if (logo.nextElementSibling !== title){
header.insertBefore(logo, title);
}
}
}
applyModalBg(modal);
modal.style.display = 'flex';
modal.focus();
lockPageScroll(true);
return modal;
}
// Overlay lives INSIDE the modal body
function setOoBusy(modal, on, label='Preparing editor…'){
if (!modal) return;
const body = modal.querySelector('.editor-body');
let ov = body.querySelector('.oo-warm-overlay');
if (on){
if (!ov){
ov = document.createElement('div');
ov.className = 'oo-warm-overlay';
ov.textContent = label;
body.appendChild(ov);
}
} else if (ov){
ov.remove();
}
}
// Hidden warm-up DocEditor (creates DS session/cache) then destroys
async function warmDocServerOnce(cfg){
let host = null, warmEditor = null;
try{
host = document.createElement('div');
host.id = 'oo-warm-' + Math.random().toString(36).slice(2);
Object.assign(host.style, {
position:'absolute', left:'-99999px', top:'0', width:'2px', height:'2px', overflow:'hidden'
});
document.body.appendChild(host);
const warmCfg = JSON.parse(JSON.stringify(cfg));
warmCfg.events = Object.assign({}, warmCfg.events, { onAppReady(){}, onDocumentReady(){} });
warmEditor = new window.DocsAPI.DocEditor(host.id, warmCfg);
await new Promise(res => setTimeout(res, OO_WARM_MS));
}catch{} finally{
try{ warmEditor?.destroyEditor?.(); }catch{}
try{ host?.remove(); }catch{}
}
}
// Full-screen OO open with hidden warm-up EVERY click, then real editor
async function openOnlyOffice(fileName, folder){
let editor = null;
let removeThemeListener = () => {};
let cfg = null;
let userClosed = false;
// Build our full-screen modal
const modal = ensureOoFullscreenModal();
const titleEl = modal.querySelector('.editor-title');
if (titleEl) titleEl.innerHTML = `${t("editing")}: ${escapeHTML(fileName)}`;
const destroy = (removeModal = true) => {
try { editor?.destroyEditor?.(); } catch {}
try { removeThemeListener(); } catch {}
if (removeModal) { try { modal.remove(); } catch {} }
lockPageScroll(false);
};
const onClose = () => { userClosed = true; destroy(true); };
modal.querySelector('#closeEditorX')?.addEventListener('click', onClose);
modal.addEventListener('keydown', (e) => { if (e.key === 'Escape') onClose(); });
try{
// 1) Fetch config
const url = `/api/onlyoffice/config.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(fileName)}`;
const resp = await fetch(url, { credentials: 'include' });
const text = await resp.text();
try { cfg = JSON.parse(text); } catch {
throw new Error(`ONLYOFFICE config parse failed (HTTP ${resp.status}). First 120 chars: ${text.slice(0,120)}`);
}
if (!resp.ok) throw new Error(cfg?.error || `ONLYOFFICE config HTTP ${resp.status}`);
// 2) Preconnect + load DocsAPI
injectOOPreconnect(cfg.documentServerOrigin || null);
await ensureOnlyOfficeApi(cfg.docs_api_js, cfg.documentServerOrigin);
// 3) Theme + base events
const isDark = document.documentElement.classList.contains('dark-mode')
|| /^(1|true)$/i.test(localStorage.getItem('darkMode') || '');
cfg.events = (cfg.events && typeof cfg.events === 'object') ? cfg.events : {};
cfg.editorConfig = cfg.editorConfig || {};
cfg.editorConfig.customization = Object.assign(
{}, cfg.editorConfig.customization, { uiTheme: isDark ? 'theme-dark' : 'theme-light' }
);
cfg.events.onRequestClose = () => onClose();
// 4) Warm EVERY click
if (ALWAYS_WARM_OO && !userClosed){
setOoBusy(modal, true); // overlay INSIDE modal body
await warmDocServerOnce(cfg);
if (userClosed) return;
}
// 5) Launch visible editor in full-screen modal
cfg.events.onDocumentReady = () => { setOoBusy(modal, false); };
editor = new window.DocsAPI.DocEditor('oo-editor', cfg);
// Live theme switching + keep modal bg in sync
const darkToggle = document.getElementById('darkModeToggle');
const onDarkToggle = () => {
const nowDark = document.documentElement.classList.contains('dark-mode');
if (editor && typeof editor.setTheme === 'function') {
editor.setTheme(nowDark ? 'dark' : 'light');
}
applyModalBg(modal);
};
if (darkToggle) {
darkToggle.addEventListener('click', onDarkToggle);
removeThemeListener = () => darkToggle.removeEventListener('click', onDarkToggle);
}
}catch(e){
console.error('[ONLYOFFICE] failed to open:', e);
showToast((e && e.message) ? e.message : 'Unable to open ONLYOFFICE editor.');
destroy(true);
}
}
// ---- /ONLYOFFICE integration ----------------------------------------------
// ==== Editor (CodeMirror) path =============================================
function getModeForFile(fileName) {
const dot = fileName.lastIndexOf(".");
const ext = dot >= 0 ? fileName.slice(dot + 1).toLowerCase() : "";
@@ -452,38 +644,36 @@ export async function editFile(fileName, folder) {
const normName = normalizeModeName(desiredMode) || "text/plain";
const initialMode = (forcePlainText || !isModeRegistered(normName)) ? "text/plain" : desiredMode;
const cmOptions = {
lineNumbers: !forcePlainText,
mode: initialMode,
theme,
viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false
};
const editor = window.CodeMirror.fromTextArea(
const cm = window.CodeMirror.fromTextArea(
document.getElementById("fileEditor"),
cmOptions
{
lineNumbers: !forcePlainText,
mode: initialMode,
theme,
viewportMargin: forcePlainText ? 20 : Infinity,
lineWrapping: false
}
);
window.currentEditor = editor;
window.currentEditor = cm;
setTimeout(adjustEditorSize, 50);
observeModalResize(modal);
// Font controls (now that editor exists)
let currentFontSize = 14;
const wrapper = editor.getWrapperElement();
const wrapper = cm.getWrapperElement();
wrapper.style.fontSize = currentFontSize + "px";
editor.refresh();
cm.refresh();
decBtn.addEventListener("click", function () {
currentFontSize = Math.max(8, currentFontSize - 2);
wrapper.style.fontSize = currentFontSize + "px";
editor.refresh();
cm.refresh();
});
incBtn.addEventListener("click", function () {
currentFontSize = Math.min(32, currentFontSize + 2);
wrapper.style.fontSize = currentFontSize + "px";
editor.refresh();
cm.refresh();
});
// Save
@@ -496,7 +686,7 @@ export async function editFile(fileName, folder) {
// Theme switch
function updateEditorTheme() {
const isDark = document.body.classList.contains("dark-mode");
editor.setOption("theme", isDark ? "material-darker" : "default");
cm.setOption("theme", isDark ? "material-darker" : "default");
}
const toggle = document.getElementById("darkModeToggle");
if (toggle) toggle.addEventListener("click", updateEditorTheme);
@@ -506,12 +696,10 @@ export async function editFile(fileName, folder) {
if (!canceled && !forcePlainText) {
const nn = normalizeModeName(desiredMode);
if (nn && isModeRegistered(nn)) {
editor.setOption("mode", desiredMode);
cm.setOption("mode", desiredMode);
}
}
}).catch(() => {
// If the mode truly fails to load, we just stay in plain text
});
}).catch(() => { /* stay in plain text */ });
});
})
.catch(error => {

File diff suppressed because it is too large Load Diff

View File

@@ -1,154 +1,246 @@
// fileMenu.js
import { updateRowHighlight, showToast } from './domUtils.js?v={{APP_QVER}}';
import { handleDeleteSelected, handleCopySelected, handleMoveSelected, handleDownloadZipSelected, handleExtractZipSelected, renameFile, openCreateFileModal } from './fileActions.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { updateRowHighlight } from './domUtils.js?v={{APP_QVER}}';
import {
handleDeleteSelected, handleCopySelected, handleMoveSelected,
handleDownloadZipSelected, handleExtractZipSelected,
renameFile, openCreateFileModal
} from './fileActions.js?v={{APP_QVER}}';
import { previewFile, buildPreviewUrl } from './filePreview.js?v={{APP_QVER}}';
import { editFile } from './fileEditor.js?v={{APP_QVER}}';
import { canEditFile, fileData } from './fileListView.js?v={{APP_QVER}}';
import { openTagModal, openMultiTagModal } from './fileTags.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
export function showFileContextMenu(x, y, menuItems) {
let menu = document.getElementById("fileContextMenu");
if (!menu) {
menu = document.createElement("div");
menu.id = "fileContextMenu";
menu.style.position = "fixed";
menu.style.backgroundColor = "#fff";
menu.style.border = "1px solid #ccc";
menu.style.boxShadow = "2px 2px 6px rgba(0,0,0,0.2)";
menu.style.zIndex = "9999";
menu.style.padding = "5px 0";
menu.style.minWidth = "150px";
document.body.appendChild(menu);
}
menu.innerHTML = "";
menuItems.forEach(item => {
let menuItem = document.createElement("div");
menuItem.textContent = item.label;
menuItem.style.padding = "5px 15px";
menuItem.style.cursor = "pointer";
menuItem.addEventListener("mouseover", () => {
menuItem.style.backgroundColor = document.body.classList.contains("dark-mode") ? "#444" : "#f0f0f0";
});
menuItem.addEventListener("mouseout", () => {
menuItem.style.backgroundColor = "";
});
menuItem.addEventListener("click", () => {
item.action();
hideFileContextMenu();
});
menu.appendChild(menuItem);
const MENU_ID = 'fileContextMenu';
function qMenu() { return document.getElementById(MENU_ID); }
function setText(btn, key) { btn.querySelector('span').textContent = t(key); }
// One-time: localize labels
function localizeMenu() {
const m = qMenu(); if (!m) return;
const map = {
'create_file': 'create_file',
'delete_selected': 'delete_selected',
'copy_selected': 'copy_selected',
'move_selected': 'move_selected',
'download_zip': 'download_zip',
'extract_zip': 'extract_zip',
'tag_selected': 'tag_selected',
'preview': 'preview',
'edit': 'edit',
'rename': 'rename',
'tag_file': 'tag_file'
};
Object.entries(map).forEach(([action, key]) => {
const el = m.querySelector(`.mi[data-action="${action}"]`);
if (el) setText(el, key);
});
}
menu.style.left = x + "px";
menu.style.top = y + "px";
menu.style.display = "block";
// Show/hide items based on selection state
function configureVisibility({ any, one, many, anyZip, canEdit }) {
const m = qMenu(); if (!m) return;
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
if (menuRect.bottom > viewportHeight) {
let newTop = viewportHeight - menuRect.height;
if (newTop < 0) newTop = 0;
menu.style.top = newTop + "px";
const show = (sel, on) => sel.forEach(el => el.hidden = !on);
show(m.querySelectorAll('[data-when="always"]'), true);
show(m.querySelectorAll('[data-when="any"]'), any);
show(m.querySelectorAll('[data-when="one"]'), one);
show(m.querySelectorAll('[data-when="many"]'), many);
show(m.querySelectorAll('[data-when="zip"]'), anyZip);
show(m.querySelectorAll('[data-when="can-edit"]'), canEdit);
// Hide separators at edges or duplicates
cleanupSeparators(m);
}
function cleanupSeparators(menu) {
const kids = Array.from(menu.children);
let lastWasSep = true; // leading seps hidden
kids.forEach((el, i) => {
if (el.classList.contains('sep')) {
const hide = lastWasSep || (i === kids.length - 1);
el.hidden = hide || el.hidden; // keep hidden if already hidden by state
lastWasSep = !el.hidden;
} else if (!el.hidden) {
lastWasSep = false;
}
});
}
// Position menu within viewport
function placeMenu(x, y) {
const m = qMenu(); if (!m) return;
// make visible to measure
m.hidden = false;
m.style.left = '0px';
m.style.top = '0px';
// force a max-height via CSS fallback if styles didn't load yet
const pad = 8;
const vh = window.innerHeight, vw = window.innerWidth;
const mh = Math.min(vh - pad*2, 600); // JS fallback limit
m.style.maxHeight = mh + 'px';
// measure now that it's flow-visible
const r0 = m.getBoundingClientRect();
let nx = x, ny = y;
// If it would overflow right, shift left
if (nx + r0.width > vw - pad) nx = Math.max(pad, vw - r0.width - pad);
// If it would overflow bottom, try placing it above the cursor
if (ny + r0.height > vh - pad) {
const above = y - r0.height - 4;
ny = (above >= pad) ? above : Math.max(pad, vh - r0.height - pad);
}
// Guard top/left minimums
nx = Math.max(pad, nx);
ny = Math.max(pad, ny);
m.style.left = `${nx}px`;
m.style.top = `${ny}px`;
}
export function hideFileContextMenu() {
const menu = document.getElementById("fileContextMenu");
if (menu) {
menu.style.display = "none";
}
const m = qMenu();
if (m) m.hidden = true;
}
function currentSelection() {
const checks = Array.from(document.querySelectorAll('#fileList .file-checkbox'));
// checkbox values are ESCAPED names (because buildFileTableRow used safeFileName)
const selectedEsc = checks.filter(cb => cb.checked).map(cb => cb.value);
const escSet = new Set(selectedEsc);
// map back to real file objects by comparing escaped(f.name)
const files = fileData.filter(f => escSet.has(escapeHTML(f.name)));
const any = files.length > 0;
const one = files.length === 1;
const many = files.length > 1;
const anyZip = files.some(f => f.name.toLowerCase().endsWith('.zip'));
const file = one ? files[0] : null;
const canEditFlag = !!(file && canEditFile(file.name));
// also return the raw names if any caller needs them
return {
files, // <— real file objects for modals
all: files.map(f => f.name),
any, one, many, anyZip,
file,
canEdit: canEditFlag
};
}
export function fileListContextMenuHandler(e) {
e.preventDefault();
let row = e.target.closest("tr");
// Check row if needed
const row = e.target.closest('tr');
if (row) {
const checkbox = row.querySelector(".file-checkbox");
if (checkbox && !checkbox.checked) {
checkbox.checked = true;
updateRowHighlight(checkbox);
const cb = row.querySelector('.file-checkbox');
if (cb && !cb.checked) {
cb.checked = true;
updateRowHighlight(cb);
}
}
const selected = Array.from(document.querySelectorAll("#fileList .file-checkbox:checked")).map(chk => chk.value);
const state = currentSelection();
configureVisibility(state);
placeMenu(e.clientX, e.clientY);
let menuItems = [
{ label: t("create_file"), action: () => openCreateFileModal() },
{ label: t("delete_selected"), action: () => { handleDeleteSelected(new Event("click")); } },
{ label: t("copy_selected"), action: () => { handleCopySelected(new Event("click")); } },
{ label: t("move_selected"), action: () => { handleMoveSelected(new Event("click")); } },
{ label: t("download_zip"), action: () => { handleDownloadZipSelected(new Event("click")); } }
];
if (selected.some(name => name.toLowerCase().endsWith(".zip"))) {
menuItems.push({
label: t("extract_zip"),
action: () => { handleExtractZipSelected(new Event("click")); }
});
}
if (selected.length > 1) {
menuItems.push({
label: t("tag_selected"),
action: () => {
const files = fileData.filter(f => selected.includes(f.name));
openMultiTagModal(files);
}
});
}
else if (selected.length === 1) {
const file = fileData.find(f => f.name === selected[0]);
menuItems.push({
label: t("preview"),
action: () => {
const folder = window.currentFolder || "root";
previewFile(buildPreviewUrl(folder, file.name), file.name);
}
});
if (canEditFile(file.name)) {
menuItems.push({
label: t("edit"),
action: () => { editFile(selected[0], window.currentFolder); }
});
}
menuItems.push({
label: t("rename"),
action: () => { renameFile(selected[0], window.currentFolder); }
});
menuItems.push({
label: t("tag_file"),
action: () => { openTagModal(file); }
});
}
showFileContextMenu(e.clientX, e.clientY, menuItems);
// Stash for click handlers
window.__filr_ctx_state = state;
}
// --- add near top ---
let __ctxBoundOnce = false;
function docClickClose(ev) {
const m = qMenu(); if (!m || m.hidden) return;
if (!m.contains(ev.target)) hideFileContextMenu();
}
function docKeyClose(ev) {
if (ev.key === 'Escape') hideFileContextMenu();
}
function menuClickDelegate(ev) {
const btn = ev.target.closest('.mi[data-action]');
if (!btn) return;
ev.stopPropagation();
// CLOSE MENU FIRST so it cant overlay the modal
hideFileContextMenu();
const action = btn.dataset.action;
const s = window.__filr_ctx_state || currentSelection();
const folder = window.currentFolder || 'root';
switch (action) {
case 'create_file': openCreateFileModal(); break;
case 'delete_selected': handleDeleteSelected(new Event('click')); break;
case 'copy_selected': handleCopySelected(new Event('click')); break;
case 'move_selected': handleMoveSelected(new Event('click')); break;
case 'download_zip': handleDownloadZipSelected(new Event('click')); break;
case 'extract_zip': handleExtractZipSelected(new Event('click')); break;
case 'tag_selected':
openMultiTagModal(s.files); // s.files are the real file objects
break;
case 'preview':
if (s.file) previewFile(buildPreviewUrl(folder, s.file.name), s.file.name);
break;
case 'edit':
if (s.file && s.canEdit) editFile(s.file.name, folder);
break;
case 'rename':
if (s.file) renameFile(s.file.name, folder);
break;
case 'tag_file':
if (s.file) openTagModal(s.file);
break;
}
}
// keep your renderFileTable wrapper as-is
export function bindFileListContextMenu() {
const fileListContainer = document.getElementById("fileList");
if (fileListContainer) {
fileListContainer.oncontextmenu = fileListContextMenuHandler;
const container = document.getElementById('fileList');
const menu = qMenu();
if (!container || !menu) return;
localizeMenu();
// Open on right click in the table
container.oncontextmenu = fileListContextMenuHandler;
// Bind once
if (!__ctxBoundOnce) {
document.addEventListener('click', docClickClose);
document.addEventListener('keydown', docKeyClose);
menu.addEventListener('click', menuClickDelegate); // handles actions
__ctxBoundOnce = true;
}
}
document.addEventListener("click", function (e) {
const menu = document.getElementById("fileContextMenu");
if (menu && menu.style.display === "block") {
hideFileContextMenu();
}
});
// Rebind context menu after file table render.
// Rebind after table render (keeps your original behavior)
(function () {
const originalRenderFileTable = window.renderFileTable;
window.renderFileTable = function (folder) {
originalRenderFileTable(folder);
bindFileListContextMenu();
};
const orig = window.renderFileTable;
if (typeof orig === 'function') {
window.renderFileTable = function (folder) {
orig(folder);
bindFileListContextMenu();
};
} else {
// If not present yet, bind once DOM is ready
document.addEventListener('DOMContentLoaded', bindFileListContextMenu, { once: true });
}
})();

View File

@@ -123,6 +123,21 @@ export function openShareModal(file, folder) {
const IMG_RE = /\.(jpg|jpeg|png|gif|bmp|webp|svg|ico)$/i;
const VID_RE = /\.(mp4|mkv|webm|mov|ogv)$/i;
const AUD_RE = /\.(mp3|wav|m4a|ogg|flac|aac|wma|opus)$/i;
const ARCH_RE = /\.(zip|rar|7z|gz|bz2|xz|tar)$/i;
const CODE_RE = /\.(js|mjs|ts|tsx|json|yml|yaml|xml|html?|css|scss|less|php|py|rb|go|rs|c|cpp|h|hpp|java|cs|sh|bat|ps1)$/i;
const TXT_RE = /\.(txt|rtf|md|log)$/i;
function getIconForFile(name) {
const lower = (name || '').toLowerCase();
if (IMG_RE.test(lower)) return 'image';
if (VID_RE.test(lower)) return 'ondemand_video';
if (AUD_RE.test(lower)) return 'audiotrack';
if (lower.endsWith('.pdf')) return 'picture_as_pdf';
if (ARCH_RE.test(lower)) return 'archive';
if (CODE_RE.test(lower)) return 'code';
if (TXT_RE.test(lower)) return 'description';
return 'insert_drive_file';
}
function ensureMediaModal() {
let overlay = document.getElementById("filePreviewModal");
@@ -145,116 +160,173 @@ function ensureMediaModal() {
const root = document.documentElement;
const styles = getComputedStyle(root);
const isDark = root.classList.contains('dark-mode');
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#121212' : '#ffffff');
const panelBg = styles.getPropertyValue('--panel-bg').trim() || styles.getPropertyValue('--bg-color').trim() || (isDark ? '#2c2c2c' : '#ffffff');
const textCol = styles.getPropertyValue('--text-color').trim() || (isDark ? '#eaeaea' : '#111111');
const navBg = isDark ? 'rgba(255,255,255,.28)' : 'rgba(0,0,0,.45)';
const navFg = '#fff';
const navBorder = isDark ? 'rgba(255,255,255,.35)' : 'rgba(0,0,0,.25)';
// fixed top bar; pad-right to avoid overlap with absolute close “×”
overlay.innerHTML = `
<div class="modal-content media-modal" style="
position: relative;
max-width: 92vw;
max-height: 92vh;
width: 92vw;
max-height: 92vh;
height: 92vh;
box-sizing: border-box;
padding: 12px;
background: ${panelBg};
color: ${textCol};
overflow: hidden;
border-radius: 10px;
display:flex; flex-direction:column;
">
<div class="media-stage" style="position:relative; display:flex; align-items:center; justify-content:center; height: calc(92vh - 8px);">
<!-- filename badge (top-left) -->
<div class="media-title-badge" style="
position:absolute; top:8px; left:12px; max-width:60vw;
padding:4px 10px; border-radius:10px;
background: ${isDark ? 'rgba(0,0,0,.55)' : 'rgba(255,255,255,.65)'};
color: ${isDark ? '#fff' : '#111'};
font-weight:600; font-size:13px; line-height:1.3; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; z-index:1002;">
<!-- Top bar -->
<div class="media-topbar" style="
flex:0 0 auto; display:flex; align-items:center; justify-content:space-between;
height:44px; padding:6px 12px; padding-right:56px; gap:10px;
border-bottom:1px solid ${isDark ? 'rgba(255,255,255,.12)' : 'rgba(0,0,0,.08)'};
background:${panelBg};
">
<div class="media-title" style="display:flex; align-items:center; gap:8px; min-width:0;">
<span class="material-icons title-icon" style="
width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center;
font-size:22px; line-height:1; opacity:${isDark ? '0.96' : '0.9'};">
insert_drive_file
</span>
<div class="title-text" style="font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;"></div>
</div>
<!-- top-right actions row (aligned with your X at top:10px) -->
<div class="media-actions-bar" style="
position:absolute; top:10px; right:56px; display:flex; gap:6px; align-items:center; z-index:1002;">
<div class="media-right" style="display:flex; align-items:center; gap:8px;">
<span class="status-chip" style="
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
border:1px solid rgba(250,204,21,.45); background:rgba(250,204,21,.15); color:#facc15;"></span>
<div class="action-group" style="display:flex; gap:6px;"></div>
display:none; padding:4px 8px; border-radius:999px; font-size:12px; line-height:1;
border:1px solid transparent; background:transparent; color:inherit;"></span>
<div class="action-group" style="display:flex; gap:8px; align-items:center;"></div>
</div>
</div>
<!-- your absolute close X -->
<span id="closeFileModal" class="close-image-modal" title="${t('close')}">&times;</span>
<!-- centered media -->
<!-- Stage -->
<div class="media-stage" style="position:relative; flex:1 1 auto; display:flex; align-items:center; justify-content:center; overflow:hidden;">
<div class="file-preview-container" style="position:relative; text-align:center; flex:1; min-width:0;"></div>
<!-- high-contrast prev/next -->
<!-- prev/next = rounded rectangles with centered glyphs -->
<button class="nav-left" aria-label="${t('previous')||'Previous'}" style="
position:absolute; left:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
height:56px; min-width:48px; padding:0 14px;
display:flex; align-items:center; justify-content:center;
font-size:38px; line-height:0;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
<button class="nav-right" aria-label="${t('next')||'Next'}" style="
position:absolute; right:8px; top:50%; transform:translateY(-50%);
height:56px; min-width:44px; padding:0 12px; font-size:42px; line-height:1;
height:56px; min-width:48px; padding:0 14px;
display:flex; align-items:center; justify-content:center;
font-size:38px; line-height:0;
background:${navBg}; color:${navFg}; border:1px solid ${navBorder};
text-shadow: 0 1px 2px rgba(0,0,0,.6);
border-radius:12px; cursor:pointer; display:none; z-index:1001; backdrop-filter: blur(2px);
box-shadow: 0 2px 8px rgba(0,0,0,.35);"></button>
</div>
<!-- Absolute close “×” (like original), themed + hover behavior -->
<span id="closeFileModal" class="close-image-modal" title="${t('close')}" style="
position:absolute; top:8px; right:10px; z-index:1002;
width:32px; height:32px; display:inline-flex; align-items:center; justify-content:center;
font-size:22px; cursor:pointer; user-select:none; border-radius:50%; transition:all .15s ease;
">&times;</span>
</div>`;
document.body.appendChild(overlay);
// theme the close “×” for visibility + hover rules that match your site:
const closeBtn = overlay.querySelector("#closeFileModal");
function paintCloseBase() {
closeBtn.style.backgroundColor = 'transparent';
closeBtn.style.color = '#e11d48'; // base red X
closeBtn.style.boxShadow = 'none';
}
function onCloseHoverEnter() {
const dark = document.documentElement.classList.contains('dark-mode');
closeBtn.style.backgroundColor = '#ef4444'; // red fill
closeBtn.style.color = dark ? '#000' : '#fff'; // X: black in dark / white in light
closeBtn.style.boxShadow = '0 0 6px rgba(239,68,68,.6)';
}
function onCloseHoverLeave() { paintCloseBase(); }
paintCloseBase();
closeBtn.addEventListener('mouseenter', onCloseHoverEnter);
closeBtn.addEventListener('mouseleave', onCloseHoverLeave);
function closeModal() {
try { overlay.querySelectorAll("video,audio").forEach(m => { try{m.pause()}catch(_){}}); } catch {}
if (overlay._onKey) window.removeEventListener('keydown', overlay._onKey);
overlay.remove();
}
overlay.querySelector("#closeFileModal").addEventListener("click", closeModal);
closeBtn.addEventListener("click", closeModal);
overlay.addEventListener("click", (e) => { if (e.target === overlay) closeModal(); });
return overlay;
}
function setTitle(overlay, name) {
const el = overlay.querySelector('.media-title-badge');
if (el) el.textContent = name || '';
const textEl = overlay.querySelector('.title-text');
const iconEl = overlay.querySelector('.title-icon');
if (textEl) {
textEl.textContent = name || '';
textEl.setAttribute('title', name || '');
}
if (iconEl) {
iconEl.textContent = getIconForFile(name);
// keep the icon legible in both themes
const dark = document.documentElement.classList.contains('dark-mode');
iconEl.style.color = dark ? '#f5f5f5' : '#111111';
iconEl.style.opacity = dark ? '0.96' : '0.9';
}
}
function makeMI(name, title) {
// Topbar icon (theme-aware) used for image tools + video actions
function makeTopIcon(name, title) {
const b = document.createElement('button');
b.className = `material-icons ${name}`;
b.textContent = name; // Material Icons font
b.className = 'material-icons';
b.textContent = name;
b.title = title;
const dark = document.documentElement.classList.contains('dark-mode');
Object.assign(b.style, {
width: "32px",
height: "32px",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,.25)",
border: "1px solid rgba(255,255,255,.25)",
cursor: "pointer",
userSelect: "none",
fontSize: "20px",
padding: "0",
borderRadius: "8px",
color: "#fff",
lineHeight: "1"
width: '32px',
height: '32px',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: dark ? '1px solid rgba(255,255,255,.25)' : '1px solid rgba(0,0,0,.15)',
background: dark ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)',
cursor: 'pointer',
fontSize: '20px',
lineHeight: '1',
color: dark ? '#f5f5f5' : '#111',
boxShadow: dark ? '0 1px 2px rgba(0,0,0,.6)' : '0 1px 1px rgba(0,0,0,.08)'
});
b.addEventListener('mouseenter', () => {
const darkNow = document.documentElement.classList.contains('dark-mode');
b.style.background = darkNow ? 'rgba(255,255,255,.22)' : 'rgba(0,0,0,.14)';
});
b.addEventListener('mouseleave', () => {
const darkNow = document.documentElement.classList.contains('dark-mode');
b.style.background = darkNow ? 'rgba(255,255,255,.14)' : 'rgba(0,0,0,.08)';
});
return b;
}
function setNavVisibility(overlay, showPrev, showNext) {
const prev = overlay.querySelector('.nav-left');
const next = overlay.querySelector('.nav-right');
prev.style.display = showPrev ? 'inline-flex' : 'none';
next.style.display = showNext ? 'inline-flex' : 'none';
prev.style.display = showPrev ? 'flex' : 'none';
next.style.display = showNext ? 'flex' : 'none';
}
function setRowWatchedBadge(name, watched) {
@@ -280,8 +352,8 @@ function setRowWatchedBadge(name, watched) {
export function previewFile(fileUrl, fileName) {
const overlay = ensureMediaModal();
const container = overlay.querySelector(".file-preview-container");
const actionWrap = overlay.querySelector(".media-actions-bar .action-group");
const statusChip = overlay.querySelector(".media-actions-bar .status-chip");
const actionWrap = overlay.querySelector(".media-right .action-group");
const statusChip = overlay.querySelector(".media-right .status-chip");
// replace nav buttons to clear old listeners
let prevBtn = overlay.querySelector('.nav-left');
@@ -320,10 +392,11 @@ export function previewFile(fileUrl, fileName) {
img.dataset.rotate = 0;
container.appendChild(img);
const zoomInBtn = makeMI('zoom_in', t('zoom_in') || 'Zoom In');
const zoomOutBtn = makeMI('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateLeft = makeMI('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeMI('rotate_right', t('rotate_right') || 'Rotate Right');
// topbar-aligned, theme-aware icons
const zoomInBtn = makeTopIcon('zoom_in', t('zoom_in') || 'Zoom In');
const zoomOutBtn = makeTopIcon('zoom_out', t('zoom_out') || 'Zoom Out');
const rotateLeft = makeTopIcon('rotate_left', t('rotate_left') || 'Rotate Left');
const rotateRight = makeTopIcon('rotate_right', t('rotate_right') || 'Rotate Right');
actionWrap.appendChild(zoomInBtn);
actionWrap.appendChild(zoomOutBtn);
actionWrap.appendChild(rotateLeft);
@@ -405,14 +478,11 @@ export function previewFile(fileUrl, fileName) {
video.style.objectFit = "contain";
container.appendChild(video);
const markBtn = document.createElement('button');
const clearBtn = document.createElement('button');
markBtn.className = 'btn btn-sm btn-success';
clearBtn.className = 'btn btn-sm btn-secondary';
markBtn.textContent = t("mark_as_viewed") || "Mark as viewed";
clearBtn.textContent = t("clear_progress") || "Clear progress";
actionWrap.appendChild(markBtn);
actionWrap.appendChild(clearBtn);
// Top-right action icons (Material icons, theme-aware)
const markBtnIcon = makeTopIcon('check_circle', t("mark_as_viewed") || "Mark as viewed");
const clearBtnIcon = makeTopIcon('restart_alt', t("clear_progress") || "Clear progress");
actionWrap.appendChild(markBtnIcon);
actionWrap.appendChild(clearBtnIcon);
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
overlay.mediaType = 'video';
@@ -453,15 +523,14 @@ export function previewFile(fileUrl, fileName) {
if (!statusChip) return;
// Completed
if (state && state.completed) {
statusChip.textContent = (t('viewed') || 'Viewed') + ' ✓';
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(34,197,94,.45)';
statusChip.style.background = 'rgba(34,197,94,.15)';
statusChip.style.color = '#22c55e';
markBtn.style.display = 'none';
clearBtn.style.display = '';
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
markBtnIcon.style.display = 'none';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// In progress
@@ -469,18 +538,20 @@ export function previewFile(fileUrl, fileName) {
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
statusChip.textContent = `${pct}%`;
statusChip.style.display = 'inline-block';
statusChip.style.borderColor = 'rgba(250,204,21,.45)';
statusChip.style.background = 'rgba(250,204,21,.15)';
statusChip.style.color = '#facc15';
markBtn.style.display = '';
clearBtn.style.display = '';
clearBtn.textContent = t('reset_progress') || t('clear_progress') || 'Reset';
const dark = document.documentElement.classList.contains('dark-mode');
const ORANGE_HEX = '#ea580c'; // darker orange (works in light/dark)
statusChip.style.color = ORANGE_HEX;
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)'; // #ea580c @ different alphas
statusChip.style.background = dark ? 'rgba(234,88,12,.18)' : 'rgba(234,88,12,.12)';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = '';
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
return;
}
// No progress
statusChip.style.display = 'none';
markBtn.style.display = '';
clearBtn.style.display = 'none';
markBtnIcon.style.display = '';
clearBtnIcon.style.display = 'none';
}
function bindVideoEvents(nm) {
@@ -494,8 +565,8 @@ export function previewFile(fileUrl, fileName) {
if (state && Number.isFinite(state.seconds) && state.seconds > 0 && state.seconds < (video.duration || Infinity)) {
video.currentTime = state.seconds;
const seconds = Math.floor(video.currentTime || 0);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
const duration = Math.floor(video.duration || 0);
setFileProgressBadge(nm, seconds, duration);
showToast((t("resumed_from") || "Resumed from") + " " + Math.floor(state.seconds) + "s");
} else {
const ls = localStorage.getItem(lsKey(nm));
@@ -528,14 +599,14 @@ setFileProgressBadge(nm, seconds, duration);
renderStatus({ seconds: duration, duration, completed: true });
});
markBtn.onclick = async () => {
markBtnIcon.onclick = async () => {
const duration = Math.floor(video.duration || 0);
await sendProgress({ nm, seconds: duration, duration, completed: true });
showToast(t("marked_viewed") || "Marked as viewed");
setFileWatchedBadge(nm, true);
renderStatus({ seconds: duration, duration, completed: true });
};
clearBtn.onclick = async () => {
clearBtnIcon.onclick = async () => {
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
try { localStorage.removeItem(lsKey(nm)); } catch {}
showToast(t("progress_cleared") || "Progress cleared");

View File

@@ -1,172 +1,214 @@
// fileTags.js
// This module provides functions for opening the tag modal,
// adding tags to files (with a global tag store for reuse),
// updating the file row display with tag badges,
// filtering the file list by tag, and persisting tag data.
// fileTags.js (drop-in fix: single-instance modals, idempotent bindings)
import { escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
import { renderFileTable, renderGalleryView } from './fileListView.js?v={{APP_QVER}}';
export function openTagModal(file) {
// Create the modal element.
let modal = document.createElement('div');
modal.id = 'tagModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="
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 class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t("tag_name")}</label>
<input type="text" id="tagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
<br><br>
<label for="tagColorInput">${t("tag_name")}</label>
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
<!-- Custom tag options will be populated here -->
</div>
<br>
<div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary">${t("save_tag")}</button>
</div>
<div id="currentTags" style="margin-top:10px; font-size:0.9em;">
<!-- Existing tags will be listed here -->
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
// -------------------- state --------------------
let __singleInit = false;
let __multiInit = false;
let currentFile = null;
updateCustomTagDropdown();
document.getElementById('closeTagModal').addEventListener('click', () => {
modal.remove();
});
updateTagModalDisplay(file);
document.getElementById('tagNameInput').addEventListener('input', (e) => {
updateCustomTagDropdown(e.target.value);
});
document.getElementById('saveTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('tagNameInput').value.trim();
const tagColor = document.getElementById('tagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
}
addTagToFile(file, { name: tagName, color: tagColor });
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
document.getElementById('tagNameInput').value = '';
updateCustomTagDropdown();
});
// Global store (preserve existing behavior)
window.globalTags = window.globalTags || [];
if (localStorage.getItem('globalTags')) {
try { window.globalTags = JSON.parse(localStorage.getItem('globalTags')); } catch (e) {}
}
/**
* Open a modal to tag multiple files.
* @param {Array} files - Array of file objects to tag.
*/
export function openMultiTagModal(files) {
let modal = document.createElement('div');
modal.id = 'multiTagModal';
modal.className = 'modal';
modal.innerHTML = `
<div class="modal-content" style="width: 450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 style="margin:0;">Tag Selected Files (${files.length})</h3>
<span id="closeMultiTagModal" class="editor-close-btn">&times;</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">Tag Name:</label>
<input type="text" id="multiTagNameInput" placeholder="Enter tag name" style="width:100%; padding:5px;"/>
<br><br>
<label for="multiTagColorInput">Tag Color:</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;">
<!-- Custom tag options will be populated here -->
</div>
<br>
<div style="text-align:right;">
<button id="saveMultiTagBtn" class="btn btn-primary">Save Tag to Selected</button>
// -------------------- ensure DOM (create-once-if-missing) --------------------
function ensureSingleTagModal() {
// de-dupe if something already injected multiples
const all = document.querySelectorAll('#tagModal');
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
let modal = document.getElementById('tagModal');
if (!modal) {
document.body.insertAdjacentHTML('beforeend', `
<div id="tagModal" class="modal" style="display:none">
<div class="modal-content" style="width:450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 id="tagModalTitle" style="margin:0; max-width:calc(100% - 40px); overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
${t('tag_file')}
</h3>
<span id="closeTagModal" class="editor-close-btn">×</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="tagNameInput">${t('tag_name')}</label>
<input type="text" id="tagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br>
<label for="tagColorInput">${t('tag_color') || 'Tag Color'}</label>
<input type="color" id="tagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="customTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<br>
<div style="text-align:right;">
<button id="saveTagBtn" class="btn btn-primary" type="button">${t('save_tag')}</button>
</div>
<div id="currentTags" style="margin-top:10px; font-size:.9em;"></div>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.style.display = 'block';
`);
modal = document.getElementById('tagModal');
}
return modal;
}
updateMultiCustomTagDropdown();
function ensureMultiTagModal() {
const all = document.querySelectorAll('#multiTagModal');
if (all.length > 1) [...all].slice(0, -1).forEach(n => n.remove());
document.getElementById('closeMultiTagModal').addEventListener('click', () => {
modal.remove();
let modal = document.getElementById('multiTagModal');
if (!modal) {
document.body.insertAdjacentHTML('beforeend', `
<div id="multiTagModal" class="modal" style="display:none">
<div class="modal-content" style="width:450px; max-width:90vw;">
<div class="modal-header" style="display:flex; justify-content:space-between; align-items:center;">
<h3 id="multiTagTitle" style="margin:0;"></h3>
<span id="closeMultiTagModal" class="editor-close-btn">×</span>
</div>
<div class="modal-body" style="margin-top:10px;">
<label for="multiTagNameInput">${t('tag_name')}</label>
<input type="text" id="multiTagNameInput" placeholder="${t('tag_name')}" style="width:100%; padding:5px;"/>
<br><br>
<label for="multiTagColorInput">${t('tag_color') || 'Tag Color'}</label>
<input type="color" id="multiTagColorInput" value="#ff0000" style="width:100%; padding:5px;"/>
<br><br>
<div id="multiCustomTagDropdown" style="max-height:150px; overflow-y:auto; border:1px solid #ccc; margin-top:5px; padding:5px;"></div>
<br>
<div style="text-align:right;">
<button id="saveMultiTagBtn" class="btn btn-primary" type="button">${t('save_tag') || 'Save Tag'}</button>
</div>
</div>
</div>
</div>
`);
modal = document.getElementById('multiTagModal');
}
return modal;
}
// -------------------- init (bind once) --------------------
function initSingleModalOnce() {
if (__singleInit) return;
const modal = ensureSingleTagModal();
const closeBtn = document.getElementById('closeTagModal');
const saveBtn = document.getElementById('saveTagBtn');
const nameInp = document.getElementById('tagNameInput');
// Close handlers
closeBtn?.addEventListener('click', hideTagModal);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideTagModal(); });
modal.addEventListener('click', (e) => {
if (e.target === modal) hideTagModal(); // click backdrop
});
document.getElementById('multiTagNameInput').addEventListener('input', (e) => {
updateMultiCustomTagDropdown(e.target.value);
// Input filter for dropdown
nameInp?.addEventListener('input', (e) => updateCustomTagDropdown(e.target.value));
// Save handler
saveBtn?.addEventListener('click', () => {
const tagName = (document.getElementById('tagNameInput')?.value || '').trim();
const tagColor = document.getElementById('tagColorInput')?.value || '#ff0000';
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
if (!currentFile) return;
addTagToFile(currentFile, { name: tagName, color: tagColor });
updateTagModalDisplay(currentFile);
updateFileRowTagDisplay(currentFile);
saveFileTags(currentFile);
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
else renderFileTable(window.currentFolder);
const inp = document.getElementById('tagNameInput');
if (inp) inp.value = '';
updateCustomTagDropdown('');
});
document.getElementById('saveMultiTagBtn').addEventListener('click', () => {
const tagName = document.getElementById('multiTagNameInput').value.trim();
const tagColor = document.getElementById('multiTagColorInput').value;
if (!tagName) {
alert('Please enter a tag name.');
return;
}
__singleInit = true;
}
function initMultiModalOnce() {
if (__multiInit) return;
const modal = ensureMultiTagModal();
const closeBtn = document.getElementById('closeMultiTagModal');
const saveBtn = document.getElementById('saveMultiTagBtn');
const nameInp = document.getElementById('multiTagNameInput');
closeBtn?.addEventListener('click', hideMultiTagModal);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideMultiTagModal(); });
modal.addEventListener('click', (e) => {
if (e.target === modal) hideMultiTagModal();
});
nameInp?.addEventListener('input', (e) => updateMultiCustomTagDropdown(e.target.value));
saveBtn?.addEventListener('click', () => {
const tagName = (document.getElementById('multiTagNameInput')?.value || '').trim();
const tagColor = document.getElementById('multiTagColorInput')?.value || '#ff0000';
if (!tagName) { alert(t('enter_tag_name') || 'Please enter a tag name.'); return; }
const files = (window.__multiTagFiles || []);
files.forEach(file => {
addTagToFile(file, { name: tagName, color: tagColor });
updateFileRowTagDisplay(file);
saveFileTags(file);
});
modal.remove();
if (window.viewMode === 'gallery') {
renderGalleryView(window.currentFolder);
} else {
renderFileTable(window.currentFolder);
}
hideMultiTagModal();
if (window.viewMode === 'gallery') renderGalleryView(window.currentFolder);
else renderFileTable(window.currentFolder);
});
__multiInit = true;
}
/**
* Update the custom dropdown for multi-tag modal.
* Similar to updateCustomTagDropdown but includes a remove icon.
*/
// -------------------- open/close APIs --------------------
export function openTagModal(file) {
initSingleModalOnce();
const modal = document.getElementById('tagModal');
const title = document.getElementById('tagModalTitle');
currentFile = file || null;
if (title) title.textContent = `${t('tag_file')}: ${file ? escapeHTML(file.name) : ''}`;
updateCustomTagDropdown('');
updateTagModalDisplay(file);
modal.style.display = 'block';
}
export function hideTagModal() {
const modal = document.getElementById('tagModal');
if (modal) modal.style.display = 'none';
}
export function openMultiTagModal(files) {
initMultiModalOnce();
const modal = document.getElementById('multiTagModal');
const title = document.getElementById('multiTagTitle');
window.__multiTagFiles = Array.isArray(files) ? files : [];
if (title) title.textContent = `${t('tag_selected') || 'Tag Selected'} (${window.__multiTagFiles.length})`;
updateMultiCustomTagDropdown('');
modal.style.display = 'block';
}
export function hideMultiTagModal() {
const modal = document.getElementById('multiTagModal');
if (modal) modal.style.display = 'none';
}
// -------------------- dropdown + UI helpers --------------------
function updateMultiCustomTagDropdown(filterText = "") {
const dropdown = document.getElementById("multiCustomTagDropdown");
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
if (filterText) {
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
item.style.cursor = "pointer";
item.style.padding = "5px";
item.style.borderBottom = "1px solid #eee";
// Display colored square and tag name with remove icon.
item.innerHTML = `
<span style="display:inline-block; width:16px; height:16px; background-color:${tag.color}; border:1px solid #ccc; margin-right:5px; vertical-align:middle;"></span>
${escapeHTML(tag.name)}
@@ -174,8 +216,10 @@ function updateMultiCustomTagDropdown(filterText = "") {
`;
item.addEventListener("click", function(e) {
if (e.target.classList.contains("global-remove")) return;
document.getElementById("multiTagNameInput").value = tag.name;
document.getElementById("multiTagColorInput").value = tag.color;
const n = document.getElementById("multiTagNameInput");
const c = document.getElementById("multiTagColorInput");
if (n) n.value = tag.name;
if (c) c.value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
@@ -184,7 +228,7 @@ function updateMultiCustomTagDropdown(filterText = "") {
dropdown.appendChild(item);
});
} else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
}
}
@@ -193,9 +237,7 @@ function updateCustomTagDropdown(filterText = "") {
if (!dropdown) return;
dropdown.innerHTML = "";
let tags = window.globalTags || [];
if (filterText) {
tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
}
if (filterText) tags = tags.filter(tag => tag.name.toLowerCase().includes(filterText.toLowerCase()));
if (tags.length > 0) {
tags.forEach(tag => {
const item = document.createElement("div");
@@ -209,8 +251,10 @@ function updateCustomTagDropdown(filterText = "") {
`;
item.addEventListener("click", function(e){
if (e.target.classList.contains('global-remove')) return;
document.getElementById("tagNameInput").value = tag.name;
document.getElementById("tagColorInput").value = tag.color;
const n = document.getElementById("tagNameInput");
const c = document.getElementById("tagColorInput");
if (n) n.value = tag.name;
if (c) c.value = tag.color;
});
item.querySelector('.global-remove').addEventListener("click", function(e){
e.stopPropagation();
@@ -219,16 +263,16 @@ function updateCustomTagDropdown(filterText = "") {
dropdown.appendChild(item);
});
} else {
dropdown.innerHTML = "<div style='padding:5px;'>No tags available</div>";
dropdown.innerHTML = `<div style="padding:5px;">${t('no_tags_available') || 'No tags available'}</div>`;
}
}
// Update the modal display to show current tags on the file.
function updateTagModalDisplay(file) {
const container = document.getElementById('currentTags');
if (!container) return;
container.innerHTML = '<strong>Current Tags:</strong> ';
if (file.tags && file.tags.length > 0) {
container.innerHTML = `<strong>${t('current_tags') || 'Current Tags'}:</strong> `;
if (file?.tags?.length) {
file.tags.forEach(tag => {
const tagElem = document.createElement('span');
tagElem.textContent = tag.name;
@@ -239,102 +283,65 @@ function updateTagModalDisplay(file) {
tagElem.style.borderRadius = '3px';
tagElem.style.display = 'inline-block';
tagElem.style.position = 'relative';
const removeIcon = document.createElement('span');
removeIcon.textContent = ' ✕';
removeIcon.style.fontWeight = 'bold';
removeIcon.style.marginLeft = '3px';
removeIcon.style.cursor = 'pointer';
removeIcon.addEventListener('click', (e) => {
e.stopPropagation();
removeTagFromFile(file, tag.name);
});
tagElem.appendChild(removeIcon);
container.appendChild(tagElem);
});
} else {
container.innerHTML += 'None';
container.innerHTML += (t('none') || 'None');
}
}
function removeTagFromFile(file, tagName) {
file.tags = file.tags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
file.tags = (file.tags || []).filter(tg => tg.name.toLowerCase() !== tagName.toLowerCase());
updateTagModalDisplay(file);
updateFileRowTagDisplay(file);
saveFileTags(file);
}
/**
* Remove a tag from the global tag store.
* This function updates window.globalTags and calls the backend endpoint
* to remove the tag from the persistent store.
*/
function removeGlobalTag(tagName) {
window.globalTags = window.globalTags.filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
window.globalTags = (window.globalTags || []).filter(t => t.name.toLowerCase() !== tagName.toLowerCase());
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
saveGlobalTagRemoval(tagName);
}
// NEW: Save global tag removal to the server.
function saveGlobalTagRemoval(tagName) {
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
body: JSON.stringify({
folder: "root",
file: "global",
deleteGlobal: true,
tagToDelete: tagName,
tags: []
})
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify({ folder: "root", file: "global", deleteGlobal: true, tagToDelete: tagName, tags: [] })
})
.then(response => response.json())
.then(data => {
if (data.success) {
console.log("Global tag removed:", tagName);
if (data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
}
} else {
console.error("Error removing global tag:", data.error);
}
})
.catch(err => {
console.error("Error removing global tag:", err);
});
.then(r => r.json())
.then(data => {
if (data.success && data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
} else if (!data.success) {
console.error("Error removing global tag:", data.error);
}
})
.catch(err => console.error("Error removing global tag:", err));
}
// Global store for reusable tags.
window.globalTags = window.globalTags || [];
if (localStorage.getItem('globalTags')) {
try {
window.globalTags = JSON.parse(localStorage.getItem('globalTags'));
} catch (e) { }
}
// New function to load global tags from the server's persistent JSON.
// -------------------- exports kept from your original --------------------
export function loadGlobalTags() {
fetch("/api/file/getFileTag.php", { credentials: "include" })
.then(response => {
if (!response.ok) {
// If the file doesn't exist, assume there are no global tags.
return [];
}
return response.json();
})
.then(r => r.ok ? r.json() : [])
.then(data => {
window.globalTags = data;
window.globalTags = data || [];
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
@@ -346,142 +353,113 @@ export function loadGlobalTags() {
updateMultiCustomTagDropdown();
});
}
loadGlobalTags();
// Add (or update) a tag in the file object.
export function addTagToFile(file, tag) {
if (!file.tags) {
file.tags = [];
}
const exists = file.tags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (exists) {
exists.color = tag.color;
} else {
file.tags.push(tag);
}
const globalExists = window.globalTags.find(t => t.name.toLowerCase() === tag.name.toLowerCase());
if (!file.tags) file.tags = [];
const exists = file.tags.find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
if (exists) exists.color = tag.color; else file.tags.push(tag);
const globalExists = (window.globalTags || []).find(tg => tg.name.toLowerCase() === tag.name.toLowerCase());
if (!globalExists) {
window.globalTags.push(tag);
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
}
}
// Update the file row (in table view) to show tag badges.
export function updateFileRowTagDisplay(file) {
const rows = document.querySelectorAll(`[id^="file-row-${encodeURIComponent(file.name)}"]`);
console.log('Updating tags for rows:', rows);
rows.forEach(row => {
let cell = row.querySelector('.file-name-cell');
if (cell) {
let badgeContainer = cell.querySelector('.tag-badges');
if (!badgeContainer) {
badgeContainer = document.createElement('div');
badgeContainer.className = 'tag-badges';
badgeContainer.style.display = 'inline-block';
badgeContainer.style.marginLeft = '5px';
cell.appendChild(badgeContainer);
}
badgeContainer.innerHTML = '';
if (file.tags && file.tags.length > 0) {
file.tags.forEach(tag => {
const badge = document.createElement('span');
badge.textContent = tag.name;
badge.style.backgroundColor = tag.color;
badge.style.color = '#fff';
badge.style.padding = '2px 4px';
badge.style.marginRight = '2px';
badge.style.borderRadius = '3px';
badge.style.fontSize = '0.8em';
badge.style.verticalAlign = 'middle';
badgeContainer.appendChild(badge);
});
}
if (!cell) return;
let badgeContainer = cell.querySelector('.tag-badges');
if (!badgeContainer) {
badgeContainer = document.createElement('div');
badgeContainer.className = 'tag-badges';
badgeContainer.style.display = 'inline-block';
badgeContainer.style.marginLeft = '5px';
cell.appendChild(badgeContainer);
}
badgeContainer.innerHTML = '';
(file.tags || []).forEach(tag => {
const badge = document.createElement('span');
badge.textContent = tag.name;
badge.style.backgroundColor = tag.color;
badge.style.color = '#fff';
badge.style.padding = '2px 4px';
badge.style.marginRight = '2px';
badge.style.borderRadius = '3px';
badge.style.fontSize = '0.8em';
badge.style.verticalAlign = 'middle';
badgeContainer.appendChild(badge);
});
});
}
export function initTagSearch() {
const searchInput = document.getElementById('searchInput');
if (searchInput) {
let tagSearchInput = document.getElementById('tagSearchInput');
if (!tagSearchInput) {
tagSearchInput = document.createElement('input');
tagSearchInput.id = 'tagSearchInput';
tagSearchInput.placeholder = 'Filter by tag';
tagSearchInput.style.marginLeft = '10px';
tagSearchInput.style.padding = '5px';
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
tagSearchInput.addEventListener('input', () => {
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
if (window.currentFolder) {
renderFileTable(window.currentFolder);
}
});
}
}
}
export function filterFilesByTag(files) {
if (window.currentTagFilter && window.currentTagFilter !== '') {
return files.filter(file => {
if (file.tags && file.tags.length > 0) {
return file.tags.some(tag => tag.name.toLowerCase().includes(window.currentTagFilter));
}
return false;
if (!searchInput) return;
let tagSearchInput = document.getElementById('tagSearchInput');
if (!tagSearchInput) {
tagSearchInput = document.createElement('input');
tagSearchInput.id = 'tagSearchInput';
tagSearchInput.placeholder = t('filter_by_tag') || 'Filter by tag';
tagSearchInput.style.marginLeft = '10px';
tagSearchInput.style.padding = '5px';
searchInput.parentNode.insertBefore(tagSearchInput, searchInput.nextSibling);
tagSearchInput.addEventListener('input', () => {
window.currentTagFilter = tagSearchInput.value.trim().toLowerCase();
if (window.currentFolder) renderFileTable(window.currentFolder);
});
}
return files;
}
export function filterFilesByTag(files) {
const q = (window.currentTagFilter || '').trim().toLowerCase();
if (!q) return files;
return files.filter(file => (file.tags || []).some(tag => tag.name.toLowerCase().includes(q)));
}
function updateGlobalTagList() {
const dataList = document.getElementById("globalTagList");
if (dataList) {
dataList.innerHTML = "";
window.globalTags.forEach(tag => {
const option = document.createElement("option");
option.value = tag.name;
dataList.appendChild(option);
});
}
if (!dataList) return;
dataList.innerHTML = "";
(window.globalTags || []).forEach(tag => {
const option = document.createElement("option");
option.value = tag.name;
dataList.appendChild(option);
});
}
export function saveFileTags(file, deleteGlobal = false, tagToDelete = null) {
const folder = file.folder || "root";
const payload = {
folder: folder,
file: file.name,
tags: file.tags
};
if (deleteGlobal && tagToDelete) {
payload.file = "global";
payload.deleteGlobal = true;
payload.tagToDelete = tagToDelete;
}
const payload = deleteGlobal && tagToDelete ? {
folder: "root",
file: "global",
deleteGlobal: true,
tagToDelete,
tags: []
} : { folder, file: file.name, tags: file.tags };
fetch("/api/file/saveFileTag.php", {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": window.csrfToken
},
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
body: JSON.stringify(payload)
})
.then(response => response.json())
.then(r => r.json())
.then(data => {
if (data.success) {
console.log("Tags saved:", data);
if (data.globalTags) {
window.globalTags = data.globalTags;
localStorage.setItem('globalTags', JSON.stringify(window.globalTags));
updateCustomTagDropdown();
updateMultiCustomTagDropdown();
}
updateGlobalTagList();
} else {
console.error("Error saving tags:", data.error);
}
})
.catch(err => {
console.error("Error saving tags:", err);
});
.catch(err => console.error("Error saving tags:", err));
}

File diff suppressed because it is too large Load Diff

View File

@@ -312,7 +312,25 @@ const translations = {
"previous": "Previous",
"next": "Next",
"watched": "Watched",
"reset_progress": "Reset Progress"
"reset_progress": "Reset Progress",
"color_folder": "Color folder",
"choose_color": "Choose a color",
"reset_default": "Reset",
"save_color": "Save",
"folder_color_saved": "Folder color saved.",
"folder_color_cleared": "Folder color reset.",
"load_more": "Load more",
"loading": "Loading...",
"no_access": "You do not have access to this resource.",
"please_select_valid_folder": "Please select a valid folder.",
"folder_help_click_view": "Click a folder in the tree to view its files.",
"folder_help_expand_chevrons": "Use chevrons to expand/collapse. Locked folders (padlock) can expand but cant be opened.",
"folder_help_context_menu": "Right-click a folder for quick actions: Create, Move, Rename, Share, Color, Delete.",
"folder_help_drag_drop": "Drag a folder onto another folder or a breadcrumb to move it.",
"folder_help_load_more": "For long lists, click “Load more” to fetch the next page of folders.",
"folder_help_last_folder": "Your last opened folder is remembered. If you lose access, we pick the first allowed folder automatically.",
"folder_help_breadcrumbs": "Use the breadcrumb to jump up the path. You can also drop onto a breadcrumb.",
"folder_help_permissions": "Buttons enable/disable based on your permissions for the selected folder."
},
es: {
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",

View File

@@ -403,39 +403,57 @@ function bindDarkMode() {
function applySiteConfig(cfg, { phase = 'final' } = {}) {
try {
const title = (cfg && cfg.header_title) ? String(cfg.header_title) : 'FileRise';
// Always keep <title> correct early (no visual flicker)
document.title = title;
// --- Login options (apply in BOTH phases so login page is correct) ---
const lo = (cfg && cfg.loginOptions) ? cfg.loginOptions : {};
const disableForm = !!lo.disableFormLogin;
const disableOIDC = !!lo.disableOIDCLogin;
const disableBasic = !!lo.disableBasicAuth;
const row = $('#loginForm');
if (row) {
if (disableForm) {
row.setAttribute('hidden', '');
row.style.display = ''; // don't leave display:none lying around
// be tolerant to key variants just in case
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const showForm = !disableForm;
const showOIDC = !disableOIDC;
const showBasic = !disableBasic;
const loginWrap = $('#loginForm'); // outer wrapper that contains buttons + form
const authForm = $('#authForm'); // inner username/password form
const oidcBtn = $('#oidcLoginBtn'); // OIDC button
const basicLink = document.querySelector('a[href="/api/auth/login_basic.php"]');
// 1) Show the wrapper if ANY method is enabled (form OR OIDC OR basic)
if (loginWrap) {
const anyMethod = showForm || showOIDC || showBasic;
if (anyMethod) {
loginWrap.removeAttribute('hidden'); // remove [hidden], which beats display:
loginWrap.style.display = ''; // let CSS decide
} else {
row.removeAttribute('hidden');
row.style.display = '';
loginWrap.setAttribute('hidden', '');
loginWrap.style.display = '';
}
}
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
// 2) Toggle the pieces inside the wrapper
if (authForm) authForm.style.display = showForm ? '' : 'none';
if (oidcBtn) oidcBtn.style.display = showOIDC ? '' : 'none';
if (basicLink) basicLink.style.display = showBasic ? '' : 'none';
const oidc = $('#oidcLoginBtn'); if (oidc) oidc.style.display = disableOIDC ? 'none' : '';
const basic = document.querySelector('a[href="/api/auth/login_basic.php"]');
if (basic) basic.style.display = disableBasic ? 'none' : '';
// --- Header <h1> only in the FINAL phase (prevents visible flips) ---
if (phase === 'final') {
const h1 = document.querySelector('.header-title h1');
if (h1) {
// prevent i18n or legacy from overwriting it
if (h1.hasAttribute('data-i18n-key')) h1.removeAttribute('data-i18n-key');
if (h1.textContent !== title) h1.textContent = title;
// lock it so late code can't stomp it
if (!h1.__titleLock) {
const mo = new MutationObserver(() => {
@@ -1037,6 +1055,21 @@ function bindDarkMode() {
if (login) login.style.display = '';
// …wire stuff…
applySiteConfig(window.__FR_SITE_CFG__ || {}, { phase: 'final' });
// Auto-SSO if OIDC is the only enabled method (add ?noauto=1 to skip)
(() => {
const lo = (window.__FR_SITE_CFG__ && window.__FR_SITE_CFG__.loginOptions) || {};
const disableForm = !!(lo.disableFormLogin ?? lo.disable_form_login ?? lo.disableForm);
const disableBasic = !!(lo.disableBasicAuth ?? lo.disable_basic_auth ?? lo.disableBasic);
const disableOIDC = !!(lo.disableOIDCLogin ?? lo.disable_oidc_login ?? lo.disableOIDC);
const onlyOIDC = disableForm && disableBasic && !disableOIDC;
const qp = new URLSearchParams(location.search);
if (onlyOIDC && qp.get('noauto') !== '1') {
const btn = document.getElementById('oidcLoginBtn');
if (btn) setTimeout(() => btn.click(), 250);
}
})();
await revealAppAndHideOverlay();
const hb = document.querySelector('.header-buttons');
if (hb) hb.style.visibility = 'hidden';
@@ -1102,7 +1135,7 @@ function bindDarkMode() {
const onHttps = location.protocol === 'https:' || location.hostname === 'localhost';
if ('serviceWorker' in navigator && onHttps && !hasCapBridge && !isCapUA) {
window.addEventListener('load', () => {
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => {});
navigator.serviceWorker.register(`/js/pwa/sw.js?v=${encodeURIComponent(QVER)}`).catch(() => { });
});
}
})();

View File

@@ -2,7 +2,7 @@
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
import { toggleVisibility, showToast } from './domUtils.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFolderTree, refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
function showConfirm(message, onConfirm) {
@@ -89,6 +89,7 @@ export function setupTrashRestoreDelete() {
toggleVisibility("restoreFilesModal", false);
loadFileList(window.currentFolder);
loadFolderTree(window.currentFolder);
refreshFolderIcon(window.currentFolder);
})
.catch(err => {
console.error("Error restoring files:", err);

View File

@@ -3,8 +3,179 @@ import { displayFilePreview } from './filePreview.js?v={{APP_QVER}}';
import { showToast, escapeHTML } from './domUtils.js?v={{APP_QVER}}';
import { loadFolderTree } from './folderManager.js?v={{APP_QVER}}';
import { loadFileList } from './fileListView.js?v={{APP_QVER}}';
import { refreshFolderIcon } from './folderManager.js?v={{APP_QVER}}';
import { t } from './i18n.js?v={{APP_QVER}}';
// --- Lightweight tracking of in-progress resumable uploads (per user) ---
const RESUMABLE_DRAFTS_KEY = 'filr_resumable_drafts_v1';
function getCurrentUserKey() {
// Try a few globals; fall back to browser profile
const u =
(window.currentUser && String(window.currentUser)) ||
(window.appUser && String(window.appUser)) ||
(window.username && String(window.username)) ||
'';
return u || 'anon';
}
function loadResumableDraftsAll() {
try {
const raw = localStorage.getItem(RESUMABLE_DRAFTS_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return (parsed && typeof parsed === 'object') ? parsed : {};
} catch (e) {
console.warn('Failed to read resumable drafts from localStorage', e);
return {};
}
}
function saveResumableDraftsAll(all) {
try {
localStorage.setItem(RESUMABLE_DRAFTS_KEY, JSON.stringify(all));
} catch (e) {
console.warn('Failed to persist resumable drafts to localStorage', e);
}
}
function getUserDraftContext() {
const all = loadResumableDraftsAll();
const userKey = getCurrentUserKey();
if (!all[userKey] || typeof all[userKey] !== 'object') {
all[userKey] = {};
}
const drafts = all[userKey];
return { all, userKey, drafts };
}
// Upsert / update a record for this resumable file
function upsertResumableDraft(file, percent) {
if (!file || !file.uniqueIdentifier) return;
const { all, userKey, drafts } = getUserDraftContext();
const id = file.uniqueIdentifier;
const folder = window.currentFolder || 'root';
const name = file.fileName || file.name || 'Unnamed file';
const size = file.size || 0;
const prev = drafts[id] || {};
const p = Math.max(0, Math.min(100, Math.floor(percent || 0)));
// Avoid hammering localStorage if nothing substantially changed
if (prev.lastPercent !== undefined && Math.abs(p - prev.lastPercent) < 1) {
return;
}
drafts[id] = {
identifier: id,
fileName: name,
size,
folder,
lastPercent: p,
updatedAt: Date.now()
};
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
// Remove a single draft by identifier
function clearResumableDraft(identifier) {
if (!identifier) return;
const { all, userKey, drafts } = getUserDraftContext();
if (drafts[identifier]) {
delete drafts[identifier];
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
}
// Optionally clear all drafts for the current folder (used on full success)
function clearResumableDraftsForFolder(folder) {
const { all, userKey, drafts } = getUserDraftContext();
const f = folder || 'root';
let changed = false;
for (const [id, rec] of Object.entries(drafts)) {
if (!rec || typeof rec !== 'object') continue;
if (rec.folder === f) {
delete drafts[id];
changed = true;
}
}
if (changed) {
all[userKey] = drafts;
saveResumableDraftsAll(all);
}
}
// Show a small banner if there is any in-progress resumable upload for this folder
function showResumableDraftBanner() {
const uploadCard = document.getElementById('uploadCard');
if (!uploadCard) return;
// Remove any existing banner first
const existing = document.getElementById('resumableDraftBanner');
if (existing && existing.parentNode) {
existing.parentNode.removeChild(existing);
}
const { drafts } = getUserDraftContext();
const folder = window.currentFolder || 'root';
const candidates = Object.values(drafts)
.filter(d =>
d &&
d.folder === folder &&
typeof d.lastPercent === 'number' &&
d.lastPercent > 0 &&
d.lastPercent < 100
)
.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
if (!candidates.length) {
return; // nothing to show
}
const latest = candidates[0];
const count = candidates.length;
const countText =
count === 1
? 'You have a partially uploaded file'
: `You have ${count} partially uploaded files. Latest:`;
const banner = document.createElement('div');
banner.id = 'resumableDraftBanner';
banner.className = 'upload-resume-banner';
banner.innerHTML = `
<div class="upload-resume-banner-inner">
<span class="material-icons" style="vertical-align:middle;margin-right:6px;">cloud_upload</span>
<span class="upload-resume-text">
${countText}
<strong>${escapeHTML(latest.fileName)}</strong>
(~${latest.lastPercent}%).
Choose it again from your device to resume.
</span>
<button type="button" class="upload-resume-dismiss-btn">Dismiss</button>
</div>
`;
const dismissBtn = banner.querySelector('.upload-resume-dismiss-btn');
if (dismissBtn) {
dismissBtn.addEventListener('click', () => {
// Clear all resumable hints for this folder when the user dismisses.
clearResumableDraftsForFolder(folder);
if (banner.parentNode) {
banner.parentNode.removeChild(banner);
}
});
}
// Insert at top of uploadCard
uploadCard.insertBefore(banner, uploadCard.firstChild);
}
/* -----------------------------------------------------
Helpers for DragandDrop Folder Uploads (Original Code)
----------------------------------------------------- */
@@ -455,7 +626,7 @@ async function initResumableUpload() {
chunkSize: 1.5 * 1024 * 1024,
simultaneousUploads: 3,
forceChunkSize: true,
testChunks: false,
testChunks: true,
withCredentials: true,
headers: { 'X-CSRF-Token': window.csrfToken },
query: () => ({
@@ -492,6 +663,11 @@ async function initResumableUpload() {
window.selectedFiles = [];
}
window.selectedFiles.push(file);
// Track as in-progress draft at 0%
upsertResumableDraft(file, 0);
showResumableDraftBanner();
const progressContainer = document.getElementById("uploadProgressContainer");
// Check if a wrapper already exists; if not, create one with a UL inside.
@@ -519,8 +695,40 @@ async function initResumableUpload() {
resumableInstance.on("fileProgress", function (file) {
const progress = file.progress(); // value between 0 and 1
const percent = Math.floor(progress * 100);
const li = document.querySelector(`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`);
let percent = Math.floor(progress * 100);
// Never persist a full 100% from progress alone.
// If the tab dies here, we still want it to look resumable.
if (percent >= 100) percent = 99;
const li = document.querySelector(
`li.upload-progress-item[data-upload-index="${file.uniqueIdentifier}"]`
);
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
const bytesUploaded = progress * file.size;
const spd = bytesUploaded / elapsed;
if (spd < 1024) speed = spd.toFixed(0) + " B/s";
else if (spd < 1048576) speed = (spd / 1024).toFixed(1) + " KB/s";
else speed = (spd / 1048576).toFixed(1) + " MB/s";
}
li.progressBar.innerText = percent + "% (" + speed + ")";
} else {
li.progressBar.style.width = "100%";
li.progressBar.innerHTML =
'<i class="material-icons spinning" style="vertical-align: middle;">autorenew</i>';
}
const pauseResumeBtn = li.querySelector(".pause-resume-btn");
if (pauseResumeBtn) {
pauseResumeBtn.disabled = false;
}
}
if (li && li.progressBar) {
if (percent < 99) {
li.progressBar.style.width = percent + "%";
@@ -552,6 +760,7 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false;
}
}
upsertResumableDraft(file, percent);
});
resumableInstance.on("fileSuccess", function (file, message) {
@@ -588,8 +797,11 @@ async function initResumableUpload() {
if (removeBtn) removeBtn.style.display = "none";
setTimeout(() => li.remove(), 5000);
}
refreshFolderIcon(window.currentFolder);
loadFileList(window.currentFolder);
// This file finished successfully, remove its draft record
clearResumableDraft(file.uniqueIdentifier);
showResumableDraftBanner();
});
@@ -607,18 +819,22 @@ async function initResumableUpload() {
pauseResumeBtn.disabled = false;
}
showToast("Error uploading file: " + file.fileName);
// Treat errored file as no longer resumable (for now) and clear its hint
showResumableDraftBanner();
});
resumableInstance.on("complete", function () {
resumableInstance.on("complete", function () {
// If any file is marked with an error, leave the list intact.
const hasError = window.selectedFiles.some(f => f.isError);
const hasError = Array.isArray(window.selectedFiles) && window.selectedFiles.some(f => f.isError);
if (!hasError) {
// All files succeeded—clear the file input and progress container after 5 seconds.
setTimeout(() => {
const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = "";
const progressContainer = document.getElementById("uploadProgressContainer");
progressContainer.innerHTML = "";
if (progressContainer) {
progressContainer.innerHTML = "";
}
window.selectedFiles = [];
adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer");
@@ -627,6 +843,15 @@ async function initResumableUpload() {
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
// IMPORTANT: clear Resumable's internal file list so the next upload
// doesn't think there are still resumable files queued.
if (resumableInstance) {
// cancel() after completion just resets internal state; no chunks are deleted server-side.
resumableInstance.cancel();
}
clearResumableDraftsForFolder(window.currentFolder || 'root');
showResumableDraftBanner();
}, 5000);
} else {
showToast("Some files failed to upload. Please check the list.");
@@ -650,11 +875,34 @@ function submitFiles(allFiles) {
const f = window.currentFolder || "root";
try { return decodeURIComponent(f); } catch { return f; }
})();
const progressContainer = document.getElementById("uploadProgressContainer");
const fileInput = document.getElementById("file");
if (!progressContainer) {
console.warn("submitFiles called but #uploadProgressContainer not found");
return;
}
// --- Ensure there are progress list items for these files ---
let listItems = progressContainer.querySelectorAll("li.upload-progress-item");
if (!listItems.length) {
// Guarantee each file has a stable uploadIndex
allFiles.forEach((file, index) => {
if (file.uploadIndex === undefined || file.uploadIndex === null) {
file.uploadIndex = index;
}
});
// Build the UI rows for these files
// This will also set window.selectedFiles and fileInfoContainer, etc.
processFiles(allFiles);
// Re-query now that processFiles has populated the DOM
listItems = progressContainer.querySelectorAll("li.upload-progress-item");
}
const progressElements = {};
const listItems = progressContainer.querySelectorAll("li.upload-progress-item");
listItems.forEach(item => {
progressElements[item.dataset.uploadIndex] = item;
});
@@ -680,7 +928,7 @@ function submitFiles(allFiles) {
if (e.lengthComputable) {
currentPercent = Math.round((e.loaded / e.total) * 100);
const li = progressElements[file.uploadIndex];
if (li) {
if (li && li.progressBar) {
const elapsed = (Date.now() - li.startTime) / 1000;
let speed = "";
if (elapsed > 0) {
@@ -716,12 +964,12 @@ function submitFiles(allFiles) {
return; // skip the "finishedCount++" and error/success logic for now
}
// ─── Normal success/error handling ────────────────────────────
// ─── Normal success/error handling ────────────────────────────
const li = progressElements[file.uploadIndex];
if (xhr.status >= 200 && xhr.status < 300 && (!jsonResponse || !jsonResponse.error)) {
// real success
if (li) {
if (li && li.progressBar) {
li.progressBar.style.width = "100%";
li.progressBar.innerText = "Done";
if (li.removeBtn) li.removeBtn.style.display = "none";
@@ -730,39 +978,40 @@ function submitFiles(allFiles) {
} else {
// real failure
if (li) {
if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
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>`;
const pc = document.getElementById("uploadProgressContainer");
if (pc) pc.innerHTML = "";
const fic = document.getElementById("fileInfoContainer");
if (fic) {
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
}, 5000);
}
// ─── Only now count this chunk as finished ───────────────────
// ─── Only now count this upload as finished ───────────────────
finishedCount++;
if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
if (finishedCount === allFiles.length) {
const succeededCount = uploadResults.filter(Boolean).length;
const failedCount = allFiles.length - succeededCount;
setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements);
}, 250);
}
setTimeout(() => {
refreshFileList(allFiles, uploadResults, progressElements);
}, 250);
}
});
xhr.addEventListener("error", function () {
const li = progressElements[file.uploadIndex];
if (li) {
if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
uploadResults[file.uploadIndex] = false;
@@ -778,7 +1027,7 @@ if (finishedCount === allFiles.length) {
xhr.addEventListener("abort", function () {
const li = progressElements[file.uploadIndex];
if (li) {
if (li && li.progressBar) {
li.progressBar.innerText = "Aborted";
}
uploadResults[file.uploadIndex] = false;
@@ -808,38 +1057,42 @@ if (finishedCount === allFiles.length) {
})
.map(s => s.trim().toLowerCase())
.filter(Boolean);
let overallSuccess = true;
let succeeded = 0;
allFiles.forEach(file => {
const clientFileName = file.name.trim().toLowerCase();
const li = progressElements[file.uploadIndex];
const hadRelative = !!(file.webkitRelativePath || file.customRelativePath);
if (!uploadResults[file.uploadIndex] || (!hadRelative && !serverFiles.includes(clientFileName))) {
if (li) {
if (!uploadResults[file.uploadIndex] ||
(!hadRelative && !serverFiles.includes(clientFileName))) {
if (li && li.progressBar) {
li.progressBar.innerText = "Error";
}
overallSuccess = false;
} else if (li) {
succeeded++;
// Schedule removal of successful file entry after 5 seconds.
setTimeout(() => {
li.remove();
delete progressElements[file.uploadIndex];
updateFileInfoCount();
const progressContainer = document.getElementById("uploadProgressContainer");
if (progressContainer && progressContainer.querySelectorAll("li.upload-progress-item").length === 0) {
const fileInput = document.getElementById("file");
if (fileInput) fileInput.value = "";
progressContainer.innerHTML = "";
const pc = document.getElementById("uploadProgressContainer");
if (pc && pc.querySelectorAll("li.upload-progress-item").length === 0) {
const fi = document.getElementById("file");
if (fi) fi.value = "";
pc.innerHTML = "";
adjustFolderHelpExpansionClosed();
const fileInfoContainer = document.getElementById("fileInfoContainer");
if (fileInfoContainer) {
fileInfoContainer.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
const fic = document.getElementById("fileInfoContainer");
if (fic) {
fic.innerHTML = `<span id="fileInfoDefault">No files selected</span>`;
}
const dropArea = document.getElementById("uploadDropArea");
if (dropArea) setDropAreaDefault();
window.selectedFiles = [];
}
}, 5000);
}
@@ -849,7 +1102,7 @@ if (finishedCount === allFiles.length) {
const failed = allFiles.length - succeeded;
showToast(`${failed} file(s) failed, ${succeeded} succeeded. Please check the list.`);
} else {
showToast(`${succeeded} file succeeded. Please check the list.`);
showToast(`${succeeded} file(s) succeeded. Please check the list.`);
}
})
.catch(error => {
@@ -858,7 +1111,6 @@ if (finishedCount === allFiles.length) {
})
.finally(() => {
loadFolderTree(window.currentFolder);
});
}
}
@@ -895,7 +1147,8 @@ function initUpload() {
dropArea.addEventListener("drop", function (e) {
e.preventDefault();
dropArea.style.backgroundColor = "";
const dt = e.dataTransfer;
const dt = e.dataTransfer || window.__pendingDropData || null;
window.__pendingDropData = null;
if (dt.items && dt.items.length > 0) {
getFilesFromDataTransferItems(dt.items).then(files => {
if (files.length > 0) {
@@ -916,17 +1169,23 @@ function initUpload() {
fileInput.addEventListener("change", async function () {
const files = Array.from(fileInput.files || []);
if (!files.length) return;
if (useResumable) {
// New resumable batch: reset selectedFiles so the count is correct
window.selectedFiles = [];
// Ensure the lib/instance exists
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
for (const f of files) resumableInstance.addFile(f);
for (const f of files) {
resumableInstance.addFile(f);
}
} else {
// If still not ready (load error), fall back to your XHR path
// If Resumable failed to load, fall back to XHR
processFiles(files);
}
} else {
// Non-resumable: normal XHR path, drag-and-drop etc.
processFiles(files);
}
});
@@ -935,27 +1194,40 @@ function initUpload() {
if (uploadForm) {
uploadForm.addEventListener("submit", async function (e) {
e.preventDefault();
const files = window.selectedFiles || (fileInput ? fileInput.files : []);
const files =
(Array.isArray(window.selectedFiles) && window.selectedFiles.length)
? window.selectedFiles
: (fileInput ? Array.from(fileInput.files || []) : []);
if (!files || !files.length) {
showToast("No files selected.");
return;
}
// Resumable path (only for picked files, not folder uploads)
const first = files[0];
const isFolderish = !!(first.customRelativePath || first.webkitRelativePath);
if (useResumable && !isFolderish) {
// If we have any files queued in Resumable, treat this as a resumable upload.
const hasResumableFiles =
useResumable &&
resumableInstance &&
Array.isArray(resumableInstance.files) &&
resumableInstance.files.length > 0;
if (hasResumableFiles) {
if (!_resumableReady) await initResumableUpload();
if (resumableInstance) {
// ensure folder/token fresh
// Keep folder/token fresh
resumableInstance.opts.query.folder = window.currentFolder || "root";
resumableInstance.opts.query.upload_token = window.csrfToken;
resumableInstance.opts.headers['X-CSRF-Token'] = window.csrfToken;
resumableInstance.upload();
showToast("Resumable upload started...");
} else {
// fallback
// Hard fallback should basically never happen
submitFiles(files);
}
} else {
// No resumable queue → drag-and-drop / paste / simple input → XHR path
submitFiles(files);
}
});
@@ -964,6 +1236,7 @@ function initUpload() {
if (useResumable) {
initResumableUpload();
}
showResumableDraftBanner();
}
export { initUpload };

View File

@@ -1,2 +1,2 @@
// generated by CI
window.APP_VERSION = 'v1.8.5';
window.APP_VERSION = 'v1.9.7';

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 623 KiB

After

Width:  |  Height:  |  Size: 645 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 KiB

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

After

Width:  |  Height:  |  Size: 694 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 552 KiB

After

Width:  |  Height:  |  Size: 546 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 428 KiB

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 MiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 608 KiB

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

After

Width:  |  Height:  |  Size: 541 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 610 KiB

After

Width:  |  Height:  |  Size: 632 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 554 KiB

After

Width:  |  Height:  |  Size: 666 KiB

54
scripts/manual-sync.sh Normal file
View File

@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# === Update FileRise to v1.9.1 (safe rsync) ===
# shellcheck disable=SC2155 # we intentionally assign 'stamp' with command substitution
set -Eeuo pipefail
VER="v1.9.1"
ASSET="FileRise-${VER}.zip" # If the asset name is different, set it exactly (e.g. FileRise-v1.9.0.zip)
WEBROOT="/var/www"
TMP="/tmp/filerise-update"
# 0) (optional) quick backup of critical bits
stamp="$(date +%F-%H%M)"
mkdir -p /root/backups
tar -C "$WEBROOT" -czf "/root/backups/filerise-$stamp.tgz" \
public/.htaccess config users uploads metadata || true
echo "Backup saved to /root/backups/filerise-$stamp.tgz"
# 1) Fetch the release zip
rm -rf "$TMP"
mkdir -p "$TMP"
curl -fsSL "https://github.com/error311/FileRise/releases/download/${VER}/${ASSET}" -o "$TMP/$ASSET"
# 2) Unzip to a staging dir
unzip -q "$TMP/$ASSET" -d "$TMP"
STAGE_DIR="$(find "$TMP" -maxdepth 1 -type d -name 'FileRise*' ! -path "$TMP" | head -n1 || true)"
[ -n "${STAGE_DIR:-}" ] || STAGE_DIR="$TMP"
# 3) Sync code into /var/www
# - keep public/.htaccess
# - keep data dirs and current config.php
rsync -a --delete \
--exclude='public/.htaccess' \
--exclude='uploads/***' \
--exclude='users/***' \
--exclude='metadata/***' \
--exclude='config/config.php' \
--exclude='.github/***' \
--exclude='docker-compose.yml' \
"$STAGE_DIR"/ "$WEBROOT"/
# 4) Ownership (Ubuntu/Debian w/ Apache)
chown -R www-data:www-data "$WEBROOT"
# 5) (optional) Composer autoload optimization if composer is available
if command -v composer >/dev/null 2>&1; then
cd "$WEBROOT" || { echo "cd to $WEBROOT failed" >&2; exit 1; }
composer install --no-dev --optimize-autoloader
fi
# 6) Reload Apache (dont fail the whole script if reload isnt available)
systemctl reload apache2 2>/dev/null || true
echo "✅ FileRise updated to ${VER} (code). Data and public/.htaccess preserved."

179
src/cli/zip_worker.php Normal file
View File

@@ -0,0 +1,179 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require __DIR__ . '/../../config/config.php';
require __DIR__ . '/../../src/models/FileModel.php';
$token = $argv[1] ?? '';
$token = preg_replace('/[^a-f0-9]/','',$token);
if ($token === '') { fwrite(STDERR, "No token\n"); exit(1); }
$root = rtrim((string)META_DIR, '/\\') . '/ziptmp';
$tokDir = $root . '/.tokens';
$logDir = $root . '/.logs';
@mkdir($tokDir, 0775, true);
@mkdir($logDir, 0775, true);
$tokFile = $tokDir . '/' . $token . '.json';
$logFile = $logDir . '/WORKER-' . $token . '.log';
file_put_contents($logFile, "[".date('c')."] worker start token={$token}\n", FILE_APPEND);
// Keep libzip temp files on same FS as final zip (prevents cross-device rename failures)
@mkdir($root, 0775, true);
@putenv('TMPDIR='.$root);
@ini_set('sys_temp_dir', $root);
// Small janitor: purge old tokens/logs (> 6h)
$now = time();
foreach (glob($tokDir.'/*.json') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
foreach (glob($logDir.'/WORKER-*.log') ?: [] as $f) { if (is_file($f) && ($now - @filemtime($f)) > 21600) @unlink($f); }
// Helpers to read/write the token file safely
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
$save = function() use (&$job, $tokFile) {
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
@clearstatcache(true, $tokFile);
};
$touchPhase = function(string $phase) use (&$job, $save) {
$job['phase'] = $phase;
$save();
};
// Init timing
if (empty($job['startedAt'])) {
$job['startedAt'] = time();
}
$job['status'] = 'working';
$job['error'] = null;
$save();
// Build the list of files to zip using the model (same validation FileRise uses)
try {
// Reuse FileModels validation by calling it but not keeping the zip; well enumerate sizes here.
$folder = (string)($job['folder'] ?? 'root');
$names = (array)($job['files'] ?? []);
// Resolve folder path similarly to createZipArchive
$baseDir = realpath(UPLOAD_DIR);
if ($baseDir === false) {
throw new RuntimeException('Uploads directory not configured correctly.');
}
if (strtolower($folder) === 'root' || $folder === "") {
$folderPathReal = $baseDir;
} else {
if (strpos($folder, '..') !== false) throw new RuntimeException('Invalid folder name.');
$parts = explode('/', trim($folder, "/\\ "));
foreach ($parts as $part) {
if ($part === '' || !preg_match(REGEX_FOLDER_NAME, $part)) {
throw new RuntimeException('Invalid folder name.');
}
}
$folderPath = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$folderPathReal = realpath($folderPath);
if ($folderPathReal === false || strpos($folderPathReal, $baseDir) !== 0) {
throw new RuntimeException('Folder not found.');
}
}
// Collect files (only regular files)
$filesToZip = [];
foreach ($names as $nm) {
$bn = basename(trim((string)$nm));
if (!preg_match(REGEX_FILE_NAME, $bn)) continue;
$fp = $folderPathReal . DIRECTORY_SEPARATOR . $bn;
if (is_file($fp)) $filesToZip[] = $fp;
}
if (!$filesToZip) throw new RuntimeException('No valid files to zip.');
// Totals for progress
$filesTotal = count($filesToZip);
$bytesTotal = 0;
foreach ($filesToZip as $fp) {
$sz = @filesize($fp);
if ($sz !== false) $bytesTotal += (int)$sz;
}
$job['filesTotal'] = $filesTotal;
$job['bytesTotal'] = $bytesTotal;
$job['filesDone'] = 0;
$job['bytesDone'] = 0;
$job['pct'] = 0;
$job['current'] = null;
$job['phase'] = 'zipping';
$save();
// Create final zip path in META_DIR/ziptmp
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
$zipPath = $root . DIRECTORY_SEPARATOR . $zipName;
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new RuntimeException('Could not create zip archive.');
}
// Enumerate files; report up to 98%
$bytesDone = 0;
$filesDone = 0;
foreach ($filesToZip as $fp) {
$bn = basename($fp);
$zip->addFile($fp, $bn);
$filesDone++;
$sz = @filesize($fp);
if ($sz !== false) $bytesDone += (int)$sz;
$job['filesDone'] = $filesDone;
$job['bytesDone'] = $bytesDone;
$job['current'] = $bn;
$pct = ($bytesTotal > 0) ? (int) floor(($bytesDone / $bytesTotal) * 98) : 0;
if ($pct < 0) $pct = 0;
if ($pct > 98) $pct = 98;
if ($pct > (int)($job['pct'] ?? 0)) $job['pct'] = $pct;
$save();
}
// Finalizing (this is where libzip writes & renames)
$job['pct'] = max((int)($job['pct'] ?? 0), 99);
$job['phase'] = 'finalizing';
$job['finalizeAt'] = time();
// Publish selected totals for a truthful UI during finalizing,
// and clear incremental fields so the UI doesn't show "7/7 14 GB / 14 GB" prematurely.
$job['selectedFiles'] = $filesTotal;
$job['selectedBytes'] = $bytesTotal;
$job['filesDone'] = null;
$job['bytesDone'] = null;
$job['current'] = null;
$save();
// ---- finalize the zip on disk ----
$ok = $zip->close();
$statusStr = method_exists($zip, 'getStatusString') ? $zip->getStatusString() : '';
if (!$ok || !is_file($zipPath)) {
$job['status'] = 'error';
$job['error'] = 'Failed to finalize ZIP' . ($statusStr ? " ($statusStr)" : '');
$save();
file_put_contents($logFile, "[".date('c')."] error: ".$job['error']."\n", FILE_APPEND);
exit(0);
}
$job['status'] = 'done';
$job['zipPath'] = $zipPath;
$job['pct'] = 100;
$job['phase'] = 'finalized';
$save();
file_put_contents($logFile, "[".date('c')."] done zip={$zipPath}\n", FILE_APPEND);
} catch (Throwable $e) {
$job['status'] = 'error';
$job['error'] = 'Worker exception: '.$e->getMessage();
$save();
file_put_contents($logFile, "[".date('c')."] exception: ".$e->getMessage()."\n", FILE_APPEND);
}

View File

@@ -57,12 +57,26 @@ class AuthController
$oidcAction = 'callback';
}
if ($oidcAction) {
$cfg = AdminModel::getConfig();
$cfg = AdminModel::getConfig();
$clientId = $cfg['oidc']['clientId'] ?? null;
$clientSecret = $cfg['oidc']['clientSecret'] ?? null;
// When configured as a public client (no secret), pass null, not an empty string.
if ($clientSecret === '') { $clientSecret = null; }
$oidc = new OpenIDConnectClient(
$cfg['oidc']['providerUrl'],
$cfg['oidc']['clientId'],
$cfg['oidc']['clientSecret']
$clientId ?: null,
$clientSecret
);
// Always send PKCE (S256). Required by Authelia for public clients, safe for confidential ones.
if (method_exists($oidc, 'setCodeChallengeMethod')) {
$oidc->setCodeChallengeMethod('S256');
}
// client_secret_post with Authelia using config.php
if (method_exists($oidc, 'setTokenEndpointAuthMethod') && OIDC_TOKEN_ENDPOINT_AUTH_METHOD) {
$oidc->setTokenEndpointAuthMethod(OIDC_TOKEN_ENDPOINT_AUTH_METHOD);
}
$oidc->setRedirectURL($cfg['oidc']['redirectUri']);
$oidc->addScope(['openid','profile','email']);

View File

@@ -190,6 +190,59 @@ class FileController
return $ok ? null : "Forbidden: folder scope violation.";
}
private function spawnZipWorker(string $token, string $tokFile, string $logDir): array
{
$worker = realpath(PROJECT_ROOT . '/src/cli/zip_worker.php');
if (!$worker || !is_file($worker)) {
return ['ok'=>false, 'error'=>'zip_worker.php not found'];
}
// Find a PHP CLI binary that actually works
$candidates = array_values(array_filter([
PHP_BINARY ?: null,
'/usr/local/bin/php',
'/usr/bin/php',
'/bin/php'
]));
$php = null;
foreach ($candidates as $bin) {
if (!$bin) continue;
$rc = 1;
@exec(escapeshellcmd($bin).' -v >/dev/null 2>&1', $o, $rc);
if ($rc === 0) { $php = $bin; break; }
}
if (!$php) {
return ['ok'=>false, 'error'=>'No working php CLI found'];
}
$logFile = $logDir . DIRECTORY_SEPARATOR . 'WORKER-' . $token . '.log';
// Ensure TMPDIR is on the same FS as the final zip; actually apply it to the child process.
$tmpDir = rtrim((string)META_DIR, '/\\') . '/ziptmp';
@mkdir($tmpDir, 0775, true);
// Build one sh -c string so env + nohup + echo $! are in the same shell
$cmdStr =
'export TMPDIR=' . escapeshellarg($tmpDir) . ' ; ' .
'nohup ' . escapeshellcmd($php) . ' ' . escapeshellarg($worker) . ' ' . escapeshellarg($token) .
' >> ' . escapeshellarg($logFile) . ' 2>&1 & echo $!';
$pid = @shell_exec('/bin/sh -c ' . escapeshellarg($cmdStr));
$pid = is_string($pid) ? (int)trim($pid) : 0;
// Persist spawn metadata into token (best-effort)
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
$job['spawn'] = [
'ts' => time(),
'php' => $php,
'pid' => $pid,
'log' => $logFile
];
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
return $pid > 0 ? ['ok'=>true] : ['ok'=>false, 'error'=>'spawn returned no PID'];
}
// --- small helpers ---
private function _jsonStart(): void {
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
@@ -665,78 +718,214 @@ public function deleteFiles()
exit;
}
public function downloadZip()
{
$this->_jsonStart();
try {
if (!$this->_checkCsrf()) return;
if (!$this->_requireAuth()) return;
public function zipStatus()
{
if (!$this->_requireAuth()) { http_response_code(401); header('Content-Type: application/json'); echo json_encode(["error"=>"Unauthorized"]); return; }
$username = $_SESSION['username'] ?? '';
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
if ($token === '' || strlen($token) < 8) { http_response_code(400); header('Content-Type: application/json'); echo json_encode(["error"=>"Bad token"]); return; }
$data = $this->_readJsonBody();
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
$this->_jsonOut(["error" => "Invalid input."], 400); return;
}
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
if (!is_file($tokFile)) { http_response_code(404); header('Content-Type: application/json'); echo json_encode(["error"=>"Not found"]); return; }
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
if (($job['user'] ?? '') !== $username) { http_response_code(403); header('Content-Type: application/json'); echo json_encode(["error"=>"Forbidden"]); return; }
$folder = $this->_normalizeFolder($data['folder']);
$files = $data['files'];
if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; }
$ready = (($job['status'] ?? '') === 'done') && !empty($job['zipPath']) && is_file($job['zipPath']);
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
$out = [
'status' => $job['status'] ?? 'unknown',
'error' => $job['error'] ?? null,
'ready' => $ready,
// progress (if present)
'pct' => $job['pct'] ?? null,
'filesDone' => $job['filesDone'] ?? null,
'filesTotal' => $job['filesTotal'] ?? null,
'bytesDone' => $job['bytesDone'] ?? null,
'bytesTotal' => $job['bytesTotal'] ?? null,
'current' => $job['current'] ?? null,
'phase' => $job['phase'] ?? null,
// timing (always include for UI)
'startedAt' => $job['startedAt'] ?? null,
'finalizeAt' => $job['finalizeAt'] ?? null,
];
// Optional zip gate by account flag
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
}
if ($ready) {
$out['size'] = @filesize($job['zipPath']) ?: null;
$out['downloadUrl'] = '/api/file/downloadZipFile.php?k=' . urlencode($token);
}
$ignoreOwnership = $this->isAdmin($perms)
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
header('Content-Type: application/json');
header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
header('Pragma: no-cache');
header('Expires: 0');
echo json_encode($out);
}
// Ancestor-owner counts as full view
$fullView = $ignoreOwnership
|| ACL::canRead($username, $perms, $folder)
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
public function downloadZipFile()
{
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) { http_response_code(401); echo "Unauthorized"; return; }
$username = $_SESSION['username'] ?? '';
$token = isset($_GET['k']) ? preg_replace('/[^a-f0-9]/','',(string)$_GET['k']) : '';
if ($token === '' || strlen($token) < 8) { http_response_code(400); echo "Bad token"; return; }
if (!$fullView && !$ownOnly) {
$this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return;
}
$tokFile = rtrim((string)META_DIR, '/\\') . '/ziptmp/.tokens/' . $token . '.json';
if (!is_file($tokFile)) { http_response_code(404); echo "Not found"; return; }
$job = json_decode((string)@file_get_contents($tokFile), true) ?: [];
@unlink($tokFile); // one-shot token
// If own-only, ensure all files are owned by the user
if ($ownOnly) {
$meta = $this->loadFolderMetadata($folder);
foreach ($files as $f) {
$bn = basename((string)$f);
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
$this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return;
}
if (($job['user'] ?? '') !== $username) { http_response_code(403); echo "Forbidden"; return; }
$zip = (string)($job['zipPath'] ?? '');
$zipReal = realpath($zip);
$root = realpath(rtrim((string)META_DIR, '/\\') . '/ziptmp');
if (!$zipReal || !$root || strpos($zipReal, $root) !== 0 || !is_file($zipReal)) { http_response_code(404); echo "Not found"; return; }
@session_write_close();
@set_time_limit(0);
@ignore_user_abort(true);
if (function_exists('apache_setenv')) @apache_setenv('no-gzip','1');
@ini_set('zlib.output_compression','0');
@ini_set('output_buffering','off');
while (ob_get_level()>0) @ob_end_clean();
@clearstatcache(true, $zipReal);
$name = isset($_GET['name']) ? preg_replace('/[^A-Za-z0-9._-]/','_', (string)$_GET['name']) : 'files.zip';
if ($name === '' || str_ends_with($name,'.')) $name = 'files.zip';
$size = (int)@filesize($zipReal);
header('X-Accel-Buffering: no');
header('X-Content-Type-Options: nosniff');
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="'.$name.'"');
if ($size>0) header('Content-Length: '.$size);
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
readfile($zipReal);
@unlink($zipReal);
}
public function downloadZip()
{
try {
if (!$this->_checkCsrf()) { $this->_jsonOut(["error"=>"Bad CSRF"],400); return; }
if (!$this->_requireAuth()) { $this->_jsonOut(["error"=>"Unauthorized"],401); return; }
$data = $this->_readJsonBody();
if (!is_array($data) || !isset($data['folder'], $data['files']) || !is_array($data['files'])) {
$this->_jsonOut(["error" => "Invalid input."], 400); return;
}
$folder = $this->_normalizeFolder($data['folder']);
$files = $data['files'];
if (!$this->_validFolder($folder)) { $this->_jsonOut(["error"=>"Invalid folder name."], 400); return; }
$username = $_SESSION['username'] ?? '';
$perms = $this->loadPerms($username);
// Optional zip gate by account flag
if (!$this->isAdmin($perms) && !empty($perms['disableZip'])) {
$this->_jsonOut(["error" => "ZIP downloads are not allowed for your account."], 403); return;
}
$ignoreOwnership = $this->isAdmin($perms)
|| ($perms['bypassOwnership'] ?? (defined('DEFAULT_BYPASS_OWNERSHIP') ? DEFAULT_BYPASS_OWNERSHIP : false));
// Ancestor-owner counts as full view
$fullView = $ignoreOwnership
|| ACL::canRead($username, $perms, $folder)
|| $this->ownsFolderOrAncestor($folder, $username, $perms);
$ownOnly = !$fullView && ACL::hasGrant($username, $folder, 'read_own');
if (!$fullView && !$ownOnly) { $this->_jsonOut(["error" => "Forbidden: no view access to this folder."], 403); return; }
// If own-only, ensure all files are owned by the user
if ($ownOnly) {
$meta = $this->loadFolderMetadata($folder);
foreach ($files as $f) {
$bn = basename((string)$f);
if (!isset($meta[$bn]['uploader']) || strcasecmp((string)$meta[$bn]['uploader'], $username) !== 0) {
$this->_jsonOut(["error" => "Forbidden: you are not the owner of '{$bn}'."], 403); return;
}
}
}
$result = FileModel::createZipArchive($folder, $files);
if (isset($result['error'])) {
$this->_jsonOut(["error" => $result['error']], 400); return;
$root = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
$tokDir = $root . DIRECTORY_SEPARATOR . '.tokens';
$logDir = $root . DIRECTORY_SEPARATOR . '.logs';
if (!is_dir($tokDir)) @mkdir($tokDir, 0700, true);
if (!is_dir($logDir)) @mkdir($logDir, 0700, true);
@chmod($tokDir, 0700);
@chmod($logDir, 0700);
if (!is_dir($tokDir) || !is_writable($tokDir)) {
$this->_jsonOut(["error"=>"ZIP token dir not writable."],500); return;
}
// Light janitor: purge old tokens/logs > 6h (best-effort)
$now = time();
foreach ((glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: []) as $tf) {
if (is_file($tf) && ($now - (int)@filemtime($tf)) > 21600) { @unlink($tf); }
}
foreach ((glob($logDir . DIRECTORY_SEPARATOR . 'WORKER-*.log') ?: []) as $lf) {
if (is_file($lf) && ($now - (int)@filemtime($lf)) > 21600) { @unlink($lf); }
}
// Per-user and global caps (simple anti-DoS)
$perUserCap = 2; // tweak if desired
$globalCap = 8; // tweak if desired
$tokens = glob($tokDir . DIRECTORY_SEPARATOR . '*.json') ?: [];
$mine = 0; $all = 0;
foreach ($tokens as $tf) {
$job = json_decode((string)@file_get_contents($tf), true) ?: [];
$st = $job['status'] ?? 'unknown';
if ($st === 'queued' || $st === 'working' || $st === 'finalizing') {
$all++;
if (($job['user'] ?? '') === $username) $mine++;
}
}
if ($mine >= $perUserCap) { $this->_jsonOut(["error"=>"You already have ZIP jobs running. Try again shortly."], 429); return; }
if ($all >= $globalCap) { $this->_jsonOut(["error"=>"ZIP queue is busy. Try again shortly."], 429); return; }
$zipPath = $result['zipPath'] ?? null;
if (!$zipPath || !file_exists($zipPath)) { $this->_jsonOut(["error"=>"ZIP archive not found."], 500); return; }
// Create job token
$token = bin2hex(random_bytes(16));
$tokFile = $tokDir . DIRECTORY_SEPARATOR . $token . '.json';
$job = [
'user' => $username,
'folder' => $folder,
'files' => array_values($files),
'status' => 'queued',
'ctime' => time(),
'startedAt' => null,
'finalizeAt' => null,
'zipPath' => null,
'error' => null
];
if (file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX) === false) {
$this->_jsonOut(["error"=>"Failed to create zip job."],500); return;
}
// switch to file streaming
header_remove('Content-Type');
header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="files.zip"');
header('Content-Length: ' . filesize($zipPath));
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
// Robust spawn (detect php CLI, log, record PID)
$spawn = $this->spawnZipWorker($token, $tokFile, $logDir);
if (!$spawn['ok']) {
$job['status'] = 'error';
$job['error'] = 'Spawn failed: '.$spawn['error'];
@file_put_contents($tokFile, json_encode($job, JSON_PRETTY_PRINT), LOCK_EX);
$this->_jsonOut(["error"=>"Failed to enqueue ZIP: ".$spawn['error']], 500);
return;
}
readfile($zipPath);
@unlink($zipPath);
exit;
} catch (Throwable $e) {
error_log('FileController::downloadZip error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal server error while preparing ZIP.'], 500);
} finally { $this->_jsonEnd(); }
$this->_jsonOut([
'ok' => true,
'token' => $token,
'status' => 'queued',
'statusUrl' => '/api/file/zipStatus.php?k=' . urlencode($token),
'downloadUrl' => '/api/file/downloadZipFile.php?k=' . urlencode($token)
]);
} catch (Throwable $e) {
error_log('FileController::downloadZip enqueue error: '.$e->getMessage().' @ '.$e->getFile().':'.$e->getLine());
$this->_jsonOut(['error' => 'Internal error while queuing ZIP.'], 500);
}
}
public function extractZip()
{

File diff suppressed because it is too large Load Diff

View File

@@ -44,9 +44,6 @@ class MediaController
$f = trim((string)$f);
return ($f==='' || strtolower($f)==='root') ? 'root' : $f;
}
private function validFolder($f): bool {
return $f==='root' || (bool)preg_match(REGEX_FOLDER_NAME, $f);
}
private function validFile($f): bool {
$f = basename((string)$f);
return $f !== '' && (bool)preg_match(REGEX_FILE_NAME, $f);
@@ -56,6 +53,24 @@ class MediaController
return ACL::canRead($username, $perms, $folder) ? null : "Forbidden";
}
private function validFolder($f): bool {
if ($f === 'root') return true;
// Validate per-segment against your REGEX_FOLDER_NAME
$parts = array_filter(explode('/', (string)$f), fn($p) => $p !== '');
if (!$parts) return false;
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) return false;
}
return true;
}
/** “View” means read OR read_own */
private function canViewFolder(string $folder, string $username): bool {
$perms = loadUserPermissions($username) ?: [];
return ACL::canRead($username, $perms, $folder)
|| ACL::canReadOwn($username, $perms, $folder);
}
/** POST /api/media/updateProgress.php */
public function updateProgress(): void {
$this->jsonStart();
@@ -67,15 +82,15 @@ class MediaController
$d = $this->readJson();
$folder = $this->normalizeFolder($d['folder'] ?? 'root');
$file = (string)($d['file'] ?? '');
$seconds = isset($d['seconds']) ? floatval($d['seconds']) : 0.0;
$duration = isset($d['duration']) ? floatval($d['duration']) : null;
$seconds = isset($d['seconds']) ? (float)$d['seconds'] : 0.0;
$duration = isset($d['duration']) ? (float)$d['duration'] : null;
$completed = isset($d['completed']) ? (bool)$d['completed'] : null;
$clear = isset($d['clear']) ? (bool)$d['clear'] : false;
$clear = !empty($d['clear']);
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if ($clear) {
$ok = MediaModel::clearProgress($u, $folder, $file);
@@ -102,7 +117,7 @@ class MediaController
if (!$this->validFolder($folder) || !$this->validFile($file)) {
$this->out(['error'=>'Invalid folder/file'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
if (!$this->canViewFolder($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
$row = MediaModel::getProgress($u, $folder, $file);
$this->out(['state'=>$row]);
@@ -123,7 +138,12 @@ class MediaController
if (!$this->validFolder($folder)) {
$this->out(['error'=>'Invalid folder'], 400); return;
}
if ($this->enforceRead($folder, $u)) { $this->out(['error'=>'Forbidden'], 403); return; }
// Soft-fail for restricted users: avoid noisy console 403s
if (!$this->canViewFolder($folder, $u)) {
$this->out(['map' => []]); // 200 OK, no leakage
return;
}
$map = MediaModel::getFolderMap($u, $folder);
$this->out(['map'=>$map]);

View File

@@ -16,6 +16,23 @@ private const OO_SUPPORTED_EXTS = [
'ppt','pptx','odp',
'pdf'
];
/** Origin that the Document Server should use to reach FileRise fast (internal URL) */
private function effectiveFileOriginForDocs(): string
{
$cfg = AdminModel::getConfig();
$oo = is_array($cfg['onlyoffice'] ?? null) ? $cfg['onlyoffice'] : [];
// 1) explicit constant
if (defined('ONLYOFFICE_FILE_ORIGIN_FOR_DOCS') && ONLYOFFICE_FILE_ORIGIN_FOR_DOCS !== '') {
return (string)ONLYOFFICE_FILE_ORIGIN_FOR_DOCS;
}
// 2) admin.json setting
if (!empty($oo['fileOriginForDocs'])) return (string)$oo['fileOriginForDocs'];
// 3) fallback: whatever the public sees (may hairpin, but still works)
return $this->effectivePublicOrigin();
}
// Never editable via OO (well always set edit=false for these)
private const OO_NEVER_EDIT = ['pdf'];
@@ -127,117 +144,119 @@ private function ooLog(string $level, string $msg): void
/** GET /api/onlyoffice/status.php */
public function status(): void
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$enabled = $this->effectiveEnabled();
$docsOrig = $this->effectiveDocsOrigin();
$secret = $this->effectiveSecret();
$enabled = $this->effectiveEnabled();
$docsOrig = $this->effectiveDocsOrigin();
$secret = $this->effectiveSecret();
// Must have docs origin and secret to actually function
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
// Must have docs origin and secret to actually function
$enabled = $enabled && ($docsOrig !== '') && ($secret !== '');
$exts = self::OO_SUPPORTED_EXTS;
// If you want the extras:
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
echo json_encode(['enabled' => (bool)$enabled, 'exts' => $exts], JSON_UNESCAPED_SLASHES);
}
$exts = self::OO_SUPPORTED_EXTS;
$exts = array_values(array_unique(array_merge($exts, self::OO_VIEW_ONLY_EXTRAS)));
echo json_encode([
'enabled' => (bool)$enabled,
'exts' => $exts,
'docsOrigin' => $docsOrig, // <-- for preconnect/api.js
'publicOrigin' => $this->effectivePublicOrigin() // <-- informational
], JSON_UNESCAPED_SLASHES);
}
/** GET /api/onlyoffice/config.php?folder=...&file=... */
public function config(): void
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
// --- config(): use the DocServer-facing origin for fileUrl & callbackUrl ---
public function config(): void
{
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
@session_start();
$user = $_SESSION['username'] ?? 'anonymous';
$perms = [];
$isAdmin = \ACL::isAdmin($perms);
@session_start();
$user = $_SESSION['username'] ?? 'anonymous';
$perms = [];
$isAdmin = \ACL::isAdmin($perms);
// Effective toggles
$enabled = $this->effectiveEnabled();
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
$secret = $this->effectiveSecret();
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
$enabled = $this->effectiveEnabled();
$docsOrigin = rtrim($this->effectiveDocsOrigin(), '/');
$secret = $this->effectiveSecret();
// Inputs
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
$file = basename((string)($_GET['file'] ?? ''));
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
if (!$enabled) { http_response_code(404); echo '{"error":"ONLYOFFICE disabled"}'; return; }
if ($secret === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_JWT_SECRET not configured"}'; return; }
if ($docsOrigin === '') { http_response_code(500); echo '{"error":"ONLYOFFICE_DOCS_ORIGIN not configured"}'; return; }
if (!defined('UPLOAD_DIR')) { http_response_code(500); echo '{"error":"UPLOAD_DIR not defined"}'; return; }
// ACL
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
$canEdit = \ACL::canEdit($user, $perms, $folder);
$folder = \ACL::normalizeFolder((string)($_GET['folder'] ?? 'root'));
$file = basename((string)($_GET['file'] ?? ''));
if ($file === '') { http_response_code(400); echo '{"error":"Bad request"}'; return; }
// Path
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/');
$abs = realpath($base . $rel . $file);
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
if (!\ACL::canRead($user, $perms, $folder)) { http_response_code(403); echo '{"error":"Forbidden"}'; return; }
$canEdit = \ACL::canEdit($user, $perms, $folder);
// Public origin
$publicOrigin = $this->effectivePublicOrigin();
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/');
$abs = realpath($base . $rel . $file);
if (!$abs || !is_file($abs)) { http_response_code(404); echo '{"error":"Not found"}'; return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); echo '{"error":"Invalid path"}'; return; }
// Signed download
$exp = time() + 10*60;
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $data, $secret, true);
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
$fileUrl = $publicOrigin . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
// IMPORTANT: use the internal/fast origin for DocServer fetch + callback
$fileOriginForDocs = rtrim($this->effectiveFileOriginForDocs(), '/');
// Callback
$cbExp = time() + 10*60;
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
$callbackUrl = $publicOrigin . '/api/onlyoffice/callback.php'
. '?folder=' . rawurlencode($folder)
. '&file=' . rawurlencode($file)
. '&exp=' . $cbExp
. '&sig=' . $cbSig;
$exp = time() + 10*60;
$data = json_encode(['f'=>$folder,'n'=>$file,'u'=>$user,'adm'=>$isAdmin,'exp'=>$exp], JSON_UNESCAPED_SLASHES);
$sig = hash_hmac('sha256', $data, $secret, true);
$tok = $this->b64uEnc($data) . '.' . $this->b64uEnc($sig);
$fileUrl = $fileOriginForDocs . '/api/onlyoffice/signed-download.php?tok=' . rawurlencode($tok);
// Doc type & key
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
$cbExp = time() + 10*60;
$cbSig = hash_hmac('sha256', $folder.'|'.$file.'|'.$cbExp, $secret);
$callbackUrl = $fileOriginForDocs . '/api/onlyoffice/callback.php'
. '?folder=' . rawurlencode($folder)
. '&file=' . rawurlencode($file)
. '&exp=' . $cbExp
. '&sig=' . $cbSig;
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
$ext = strtolower(pathinfo($file, PATHINFO_EXTENSION) ?: 'docx');
$docType = in_array($ext, ['xls','xlsx','ods','csv'], true) ? 'cell'
: (in_array($ext, ['ppt','pptx','odp'], true) ? 'slide' : 'word');
$key = substr(sha1($abs . '|' . (string)filemtime($abs)), 0, 20);
$cfgOut = [
'document' => [
'fileType' => $ext,
'key' => $key,
'title' => $file,
'url' => $fileUrl,
'permissions' => [
'download' => true,
'print' => true,
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
],
],
'documentType' => $docType,
'editorConfig' => [
'callbackUrl' => $callbackUrl,
'user' => ['id'=>$user, 'name'=>$user],
'lang' => 'en',
],
'type' => 'desktop',
];
$docsApiJs = $docsOrigin . '/web-apps/apps/api/documents/api.js';
// JWT sign cfg
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
$cfgOut['token'] = "$h.$p.$s";
$cfgOut['docs_api_js'] = $docsApiJs;
$cfgOut = [
'document' => [
'fileType' => $ext,
'key' => $key,
'title' => $file,
'url' => $fileUrl,
'permissions' => [
'download' => true,
'print' => true,
'edit' => $canEdit && !in_array($ext, self::OO_NEVER_EDIT, true),
],
],
'documentType' => $docType,
'editorConfig' => [
'callbackUrl' => $callbackUrl,
'user' => ['id'=>$user, 'name'=>$user],
'lang' => 'en',
],
'type' => 'desktop',
];
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
}
// JWT sign cfg
$h = $this->b64uEnc(json_encode(['alg'=>'HS256','typ'=>'JWT']));
$p = $this->b64uEnc(json_encode($cfgOut, JSON_UNESCAPED_SLASHES));
$s = $this->b64uEnc(hash_hmac('sha256', "$h.$p", $secret, true));
$cfgOut['token'] = "$h.$p.$s";
// expose to client for preconnect/script load
$cfgOut['docs_api_js'] = $docsApiJs;
$cfgOut['documentServerOrigin'] = $docsOrigin;
echo json_encode($cfgOut, JSON_UNESCAPED_SLASHES);
}
/** POST /api/onlyoffice/callback.php?folder=...&file=...&exp=...&sig=... */
public function callback(): void
@@ -343,41 +362,52 @@ private function ooLog(string $level, string $msg): void
/** GET /api/onlyoffice/signed-download.php?tok=... */
public function signedDownload(): void
{
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
{
header('X-Content-Type-Options: nosniff');
header('Cache-Control: no-store');
$secret = $this->effectiveSecret();
if ($secret === '') { http_response_code(403); return; }
$secret = $this->effectiveSecret();
if ($secret === '') { http_response_code(403); return; }
$tok = $_GET['tok'] ?? '';
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
[$b64data, $b64sig] = explode('.', $tok, 2);
$data = $this->b64uDec($b64data);
$sig = $this->b64uDec($b64sig);
if ($data === false || $sig === false) { http_response_code(400); return; }
$tok = $_GET['tok'] ?? '';
if (!$tok || strpos($tok, '.') === false) { http_response_code(400); return; }
[$b64data, $b64sig] = explode('.', $tok, 2);
$data = $this->b64uDec($b64data);
$sig = $this->b64uDec($b64sig);
if ($data === false || $sig === false) { http_response_code(400); return; }
$calc = hash_hmac('sha256', $data, $secret, true);
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
$calc = hash_hmac('sha256', $data, $secret, true);
if (!hash_equals($calc, $sig)) { http_response_code(403); return; }
$payload = json_decode($data, true);
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
$payload = json_decode($data, true);
if (!$payload || !isset($payload['f'],$payload['n'],$payload['exp'])) { http_response_code(400); return; }
if (time() > (int)$payload['exp']) { http_response_code(403); return; }
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
if ($folder === '' || $folder === 'root') $folder = 'root';
$file = basename((string)$payload['n']);
$folder = trim(str_replace('\\','/',$payload['f']),"/ \t\r\n");
if ($folder === '' || $folder === 'root') $folder = 'root';
$file = basename((string)$payload['n']);
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/');
$abs = realpath($base . $rel . $file);
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
$base = rtrim(UPLOAD_DIR, "/\\") . DIRECTORY_SEPARATOR;
$rel = ($folder === 'root') ? '' : ($folder . '/');
$abs = realpath($base . $rel . $file);
if (!$abs || !is_file($abs)) { http_response_code(404); return; }
if (strpos($abs, realpath($base)) !== 0) { http_response_code(400); return; }
$mime = mime_content_type($abs) ?: 'application/octet-stream';
header('Content-Type: '.$mime);
header('Content-Length: '.filesize($abs));
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
readfile($abs);
// Common headers
$mime = mime_content_type($abs) ?: 'application/octet-stream';
$len = filesize($abs);
header('Content-Type: '.$mime);
header('Content-Length: '.$len);
header('Content-Disposition: inline; filename="' . rawurlencode($file) . '"');
header('Accept-Ranges: none'); // OO doesnt require ranges; avoids partial edge-cases
// ---- Key change: for HEAD, do NOT read the file ----
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'HEAD') {
// send headers only; no body
return;
}
// GET → stream the file
readfile($abs);
}
}

View File

@@ -5,116 +5,143 @@ require_once __DIR__ . '/../../config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/models/UploadModel.php';
class UploadController {
public function handleUpload(): void {
class UploadController
{
public function handleUpload(): void
{
header('Content-Type: application/json');
// ---- 1) CSRF (header or form field) ----
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']);
} elseif (!empty($_POST['csrf_token'])) {
$received = trim($_POST['csrf_token']);
} elseif (!empty($_POST['upload_token'])) {
// legacy alias
$received = trim($_POST['upload_token']);
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$requestParams = ($method === 'GET') ? $_GET : $_POST;
// Detect Resumable.js chunk "test" requests (testChunks=true, default GET)
$isResumableTest =
($method === 'GET'
&& isset($requestParams['resumableChunkNumber'])
&& isset($requestParams['resumableIdentifier']));
// ---- 1) CSRF (skip for resumable GET tests Resumable only cares about HTTP status) ----
if (!$isResumableTest) {
$headersArr = array_change_key_case(getallheaders() ?: [], CASE_LOWER);
$received = '';
if (!empty($headersArr['x-csrf-token'])) {
$received = trim($headersArr['x-csrf-token']);
} elseif (!empty($requestParams['csrf_token'])) {
$received = trim((string)$requestParams['csrf_token']);
} elseif (!empty($requestParams['upload_token'])) {
// legacy alias
$received = trim((string)$requestParams['upload_token']);
}
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// Soft-fail so client can retry with refreshed token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token'],
]);
return;
}
}
if (!isset($_SESSION['csrf_token']) || $received !== $_SESSION['csrf_token']) {
// Soft-fail so client can retry with refreshed token
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
http_response_code(200);
echo json_encode([
'csrf_expired' => true,
'csrf_token' => $_SESSION['csrf_token']
]);
return;
}
// ---- 2) Auth + account-level flags ----
if (empty($_SESSION['authenticated'])) {
http_response_code(401);
echo json_encode(['error' => 'Unauthorized']);
return;
}
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
// Admins should never be blocked by account-level "disableUpload"
if (!$isAdmin && !empty($userPerms['disableUpload'])) {
http_response_code(403);
echo json_encode(['error' => 'Upload disabled for this user.']);
return;
}
// ---- 3) Folder-level WRITE permission (ACL) ----
// Always require client to send the folder; fall back to GET if needed.
$folderParam = isset($_POST['folder'])
? (string)$_POST['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Prefer the unified param array, fall back to GET only if needed.
$folderParam = isset($requestParams['folder'])
? (string)$requestParams['folder']
: (isset($_GET['folder']) ? (string)$_GET['folder'] : 'root');
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
// Decode %xx (e.g., "test%20folder") then normalize
$folderParam = rawurldecode($folderParam);
$targetFolder = ACL::normalizeFolder($folderParam);
// Admins bypass folder canWrite checks
$username = (string)($_SESSION['username'] ?? '');
$userPerms = loadUserPermissions($username) ?: [];
$isAdmin = ACL::isAdmin($userPerms);
// Admins bypass folder canWrite checks
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode([
'error' => 'Forbidden: no write access to folder "' . $targetFolder . '".',
]);
return;
}
if (!$isAdmin && !ACL::canUpload($username, $userPerms, $targetFolder)) {
http_response_code(403);
echo json_encode(['error' => 'Forbidden: no write access to folder "'.$targetFolder.'".']);
return;
// ---- 4) Delegate to model (force the sanitized folder) ----
$requestParams['folder'] = $targetFolder;
// Keep legacy behavior for anything still reading $_POST directly
$_POST['folder'] = $targetFolder;
$result = UploadModel::handleUpload($requestParams, $_FILES);
// ---- 5) Special handling for Resumable.js GET tests ----
// Resumable only inspects HTTP status:
// 200 => chunk exists (skip)
// 404/other => chunk missing (upload)
if ($isResumableTest && isset($result['status'])) {
if ($result['status'] === 'found') {
http_response_code(200);
} else {
http_response_code(202); // 202 Accepted = chunk not found
}
echo json_encode($result);
return;
}
// ---- 6) Normal response handling ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
}
if (isset($result['status'])) {
echo json_encode($result);
return;
}
echo json_encode([
'success' => $result['success'] ?? 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null,
]);
}
// ---- 4) Delegate to model (force the sanitized folder) ----
$_POST['folder'] = $targetFolder; // in case model reads superglobal
$post = $_POST;
$post['folder'] = $targetFolder;
public function removeChunks(): void
{
header('Content-Type: application/json');
$result = UploadModel::handleUpload($post, $_FILES);
$receivedToken = isset($_POST['csrf_token']) ? trim((string)$_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
// ---- 5) Response (unchanged) ----
if (isset($result['error'])) {
http_response_code(400);
echo json_encode($result);
return;
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode(UploadModel::removeChunks($folder));
}
if (isset($result['status'])) {
echo json_encode($result);
return;
}
echo json_encode([
'success' => 'File uploaded successfully',
'newFilename' => $result['newFilename'] ?? null
]);
}
public function removeChunks(): void {
header('Content-Type: application/json');
$receivedToken = isset($_POST['csrf_token']) ? trim($_POST['csrf_token']) : '';
if ($receivedToken !== ($_SESSION['csrf_token'] ?? '')) {
http_response_code(403);
echo json_encode(['error' => 'Invalid CSRF token']);
return;
}
if (!isset($_POST['folder'])) {
http_response_code(400);
echo json_encode(['error' => 'No folder specified']);
return;
}
$folderRaw = (string)$_POST['folder'];
$folder = ACL::normalizeFolder(rawurldecode($folderRaw));
echo json_encode(UploadModel::removeChunks($folder));
}
}

View File

@@ -10,23 +10,38 @@ class ACL
private static $path = null;
private const BUCKETS = [
'owners','read','write','share','read_own',
'create','upload','edit','rename','copy','move','delete','extract',
'share_file','share_folder'
'owners',
'read',
'write',
'share',
'read_own',
'create',
'upload',
'edit',
'rename',
'copy',
'move',
'delete',
'extract',
'share_file',
'share_folder'
];
private static function path(): string {
private static function path(): string
{
if (!self::$path) self::$path = rtrim(META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_acl.json';
return self::$path;
}
public static function normalizeFolder(string $f): string {
public static function normalizeFolder(string $f): string
{
$f = trim(str_replace('\\', '/', $f), "/ \t\r\n");
if ($f === '' || $f === 'root') return 'root';
return $f;
}
public static function purgeUser(string $user): bool {
public static function purgeUser(string $user): bool
{
$user = (string)$user;
$acl = self::$cache ?? self::loadFresh();
$changed = false;
@@ -41,49 +56,107 @@ class ACL
return $changed ? self::save($acl) : true;
}
public static function ownsFolderOrAncestor(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true;
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
if (self::hasGrant($user, $folder, 'owners')) return true;
$folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false;
$folder = trim($folder, "/\\ ");
if ($folder === '' || $folder === 'root') return false;
$parts = explode('/', $folder);
while (count($parts) > 1) {
array_pop($parts);
$parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true;
$parts = explode('/', $folder);
while (count($parts) > 1) {
array_pop($parts);
$parent = implode('/', $parts);
if (self::hasGrant($user, $parent, 'owners')) return true;
}
return false;
}
public static function migrateSubtree(string $source, string $target): array
{
// PHP <8 polyfill
if (!function_exists('str_starts_with')) {
function str_starts_with(string $h, string $n): bool
{
return $n === '' || strncmp($h, $n, strlen($n)) === 0;
}
}
$src = self::normalizeFolder($source);
$dst = self::normalizeFolder($target);
if ($src === 'root') return ['changed' => false, 'moved' => 0];
$file = self::path(); // e.g. META_DIR/folder_acl.json
$raw = @file_get_contents($file);
$map = is_string($raw) ? json_decode($raw, true) : [];
if (!is_array($map)) $map = [];
$prefix = $src;
$needle = $src . '/';
$new = $map;
$changed = false;
$moved = 0;
foreach ($map as $key => $entry) {
$isMatch = ($key === $prefix) || str_starts_with($key . '/', $needle);
if (!$isMatch) continue;
unset($new[$key]);
$suffix = substr($key, strlen($prefix)); // '' or '/sub/...'
$newKey = ($dst === 'root') ? ltrim($suffix, '/\\') : rtrim($dst, '/\\') . $suffix;
// keep only known buckets (defensive)
if (is_array($entry)) {
$clean = [];
foreach (self::BUCKETS as $b) if (array_key_exists($b, $entry)) $clean[$b] = $entry[$b];
$entry = $clean ?: $entry;
}
// overwrite any existing entry at destination path (safer than union)
$new[$newKey] = $entry;
$changed = true;
$moved++;
}
if ($changed) {
@file_put_contents($file, json_encode($new, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
@chmod($file, 0664);
self::$cache = $new; // keep in-process cache fresh if you use it
}
return ['changed' => $changed, 'moved' => $moved];
}
return false;
}
/** Re-key explicit ACL entries for an entire subtree: old/... → new/... */
public static function renameTree(string $oldFolder, string $newFolder): void
{
$old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root
public static function renameTree(string $oldFolder, string $newFolder): void
{
$old = self::normalizeFolder($oldFolder);
$new = self::normalizeFolder($newFolder);
if ($old === '' || $old === 'root') return; // nothing to re-key for root
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders']) || !is_array($acl['folders'])) return;
$rebased = [];
foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec;
} else {
$rebased[$k] = $rec;
$rebased = [];
foreach ($acl['folders'] as $k => $rec) {
if ($k === $old || strpos($k, $old . '/') === 0) {
$suffix = substr($k, strlen($old));
$suffix = ltrim((string)$suffix, '/');
$newKey = $new . ($suffix !== '' ? '/' . $suffix : '');
$rebased[$newKey] = $rec;
} else {
$rebased[$k] = $rec;
}
}
$acl['folders'] = $rebased;
self::save($acl);
}
$acl['folders'] = $rebased;
self::save($acl);
}
private static function loadFresh(): array {
private static function loadFresh(): array
{
$path = self::path();
if (!is_file($path)) {
@mkdir(dirname($path), 0755, true);
@@ -94,7 +167,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
'read' => ['admin'],
'write' => ['admin'],
'share' => ['admin'],
'read_own'=> [],
'read_own' => [],
'create' => [],
'upload' => [],
'edit' => [],
@@ -130,12 +203,21 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$healed = false;
foreach ($data['folders'] as $folder => &$rec) {
if (!is_array($rec)) { $rec = []; $healed = true; }
if (!is_array($rec)) {
$rec = [];
$healed = true;
}
foreach (self::BUCKETS as $k) {
$v = $rec[$k] ?? [];
if (!is_array($v)) { $v = []; $healed = true; }
if (!is_array($v)) {
$v = [];
$healed = true;
}
$v = array_values(array_unique(array_map('strval', $v)));
if (($rec[$k] ?? null) !== $v) { $rec[$k] = $v; $healed = true; }
if (($rec[$k] ?? null) !== $v) {
$rec[$k] = $v;
$healed = true;
}
}
}
unset($rec);
@@ -145,19 +227,22 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return $data;
}
private static function save(array $acl): bool {
private static function save(array $acl): bool
{
$ok = @file_put_contents(self::path(), json_encode($acl, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) !== false;
if ($ok) self::$cache = $acl;
return $ok;
}
private static function listFor(string $folder, string $key): array {
private static function listFor(string $folder, string $key): array
{
$acl = self::$cache ?? self::loadFresh();
$f = $acl['folders'][$folder] ?? null;
return is_array($f[$key] ?? null) ? $f[$key] : [];
}
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void {
public static function ensureFolderRecord(string $folder, string $owner = 'admin'): void
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
if (!isset($acl['folders'][$folder])) {
@@ -182,19 +267,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
}
public static function isAdmin(array $perms = []): bool {
public static function isAdmin(array $perms = []): bool
{
if (!empty($_SESSION['isAdmin'])) return true;
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (isset($perms['role']) && (string)$perms['role'] === '1') return true;
if (!empty($_SESSION['role']) && (string)$_SESSION['role'] === '1') return true;
if (defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0) {
if (
defined('DEFAULT_ADMIN_USER') && !empty($_SESSION['username'])
&& strcasecmp((string)$_SESSION['username'], (string)DEFAULT_ADMIN_USER) === 0
) {
return true;
}
return false;
}
public static function hasGrant(string $user, string $folder, string $cap): bool {
public static function hasGrant(string $user, string $folder, string $cap): bool
{
$folder = self::normalizeFolder($folder);
$capKey = ($cap === 'owner') ? 'owners' : $cap;
$arr = self::listFor($folder, $capKey);
@@ -202,35 +291,41 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return false;
}
public static function isOwner(string $user, array $perms, string $folder): bool {
public static function isOwner(string $user, array $perms, string $folder): bool
{
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners');
}
public static function canManage(string $user, array $perms, string $folder): bool {
public static function canManage(string $user, array $perms, string $folder): bool
{
return self::isOwner($user, $perms, $folder);
}
public static function canRead(string $user, array $perms, string $folder): bool {
public static function canRead(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'read');
}
public static function canReadOwn(string $user, array $perms, string $folder): bool {
public static function canReadOwn(string $user, array $perms, string $folder): bool
{
if (self::canRead($user, $perms, $folder)) return true;
return self::hasGrant($user, $folder, 'read_own');
}
public static function canWrite(string $user, array $perms, string $folder): bool {
public static function canWrite(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'write');
}
public static function canShare(string $user, array $perms, string $folder): bool {
public static function canShare(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
@@ -238,7 +333,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
// Legacy-only explicit (to avoid breaking existing callers)
public static function explicit(string $folder): array {
public static function explicit(string $folder): array
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
@@ -257,7 +353,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
// New: full explicit including granular
public static function explicitAll(string $folder): array {
public static function explicitAll(string $folder): array
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$rec = $acl['folders'][$folder] ?? [];
@@ -285,7 +382,8 @@ public static function renameTree(string $oldFolder, string $newFolder): void
];
}
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool {
public static function upsert(string $folder, array $owners, array $read, array $write, array $share): bool
{
$folder = self::normalizeFolder($folder);
$acl = self::$cache ?? self::loadFresh();
$existing = $acl['folders'][$folder] ?? ['read_own' => []];
@@ -314,19 +412,23 @@ public static function renameTree(string $oldFolder, string $newFolder): void
return self::save($acl);
}
public static function applyUserGrantsAtomic(string $user, array $grants): array {
public static function applyUserGrantsAtomic(string $user, array $grants): array
{
$user = (string)$user;
$path = self::path();
$fh = @fopen($path, 'c+');
if (!$fh) throw new RuntimeException('Cannot open ACL storage');
if (!flock($fh, LOCK_EX)) { fclose($fh); throw new RuntimeException('Cannot lock ACL storage'); }
if (!flock($fh, LOCK_EX)) {
fclose($fh);
throw new RuntimeException('Cannot lock ACL storage');
}
try {
$raw = stream_get_contents($fh);
if ($raw === false) $raw = '';
$acl = json_decode($raw, true);
if (!is_array($acl)) $acl = ['folders'=>[], 'groups'=>[]];
if (!is_array($acl)) $acl = ['folders' => [], 'groups' => []];
if (!isset($acl['folders']) || !is_array($acl['folders'])) $acl['folders'] = [];
if (!isset($acl['groups']) || !is_array($acl['groups'])) $acl['groups'] = [];
@@ -335,7 +437,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
foreach ($grants as $folder => $caps) {
$ff = self::normalizeFolder((string)$folder);
if (!isset($acl['folders'][$ff]) || !is_array($acl['folders'][$ff])) $acl['folders'][$ff] = [];
$rec =& $acl['folders'][$ff];
$rec = &$acl['folders'][$ff];
foreach (self::BUCKETS as $k) {
if (!isset($rec[$k]) || !is_array($rec[$k])) $rec[$k] = [];
@@ -365,10 +467,16 @@ public static function renameTree(string $oldFolder, string $newFolder): void
$sf = !empty($caps['shareFile']) || !empty($caps['share_file']);
$sfo = !empty($caps['shareFolder']) || !empty($caps['share_folder']);
if ($m) { $v = true; $w = true; $u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true; }
if ($m) {
$v = true;
$w = true;
$u = $c = $ed = $rn = $cp = $dl = $ex = $sf = $sfo = true;
}
if ($u && !$v && !$vo) $vo = true;
//if ($s && !$v) $v = true;
if ($w) { $c = $u = $ed = $rn = $cp = $dl = $ex = true; }
if ($w) {
$c = $u = $ed = $rn = $cp = $dl = $ex = true;
}
if ($m) $rec['owners'][] = $user;
if ($v) $rec['read'][] = $user;
@@ -385,7 +493,7 @@ public static function renameTree(string $oldFolder, string $newFolder): void
if ($dl) $rec['delete'][] = $user;
if ($ex) $rec['extract'][] = $user;
if ($sf) $rec['share_file'][] = $user;
if ($sfo)$rec['share_folder'][] = $user;
if ($sfo) $rec['share_folder'][] = $user;
foreach (self::BUCKETS as $k) {
$rec[$k] = array_values(array_unique(array_map('strval', $rec[$k])));
@@ -409,90 +517,102 @@ public static function renameTree(string $oldFolder, string $newFolder): void
}
}
// --- Granular write family -----------------------------------------------
// --- Granular write family -----------------------------------------------
public static function canCreate(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'create')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCreate(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'create')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners');
}
public static function canCreateFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
// Only owners/managers can create subfolders under $folder
return self::hasGrant($user, $folder, 'owners');
}
public static function canUpload(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'upload')
|| self::hasGrant($user, $folder, 'write');
}
public static function canUpload(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'upload')
|| self::hasGrant($user, $folder, 'write');
}
public static function canEdit(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'edit')
|| self::hasGrant($user, $folder, 'write');
}
public static function canEdit(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'edit')
|| self::hasGrant($user, $folder, 'write');
}
public static function canRename(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'rename')
|| self::hasGrant($user, $folder, 'write');
}
public static function canRename(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'rename')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCopy(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'copy')
|| self::hasGrant($user, $folder, 'write');
}
public static function canCopy(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'copy')
|| self::hasGrant($user, $folder, 'write');
}
public static function canMove(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMove(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canMoveFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::ownsFolderOrAncestor($user, $perms, $folder);
}
public static function canDelete(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'delete')
|| self::hasGrant($user, $folder, 'write');
}
public static function canDelete(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'delete')
|| self::hasGrant($user, $folder, 'write');
}
public static function canExtract(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'extract')
|| self::hasGrant($user, $folder, 'write');
}
public static function canExtract(string $user, array $perms, string $folder): bool {
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners')
|| self::hasGrant($user, $folder, 'extract')
|| self::hasGrant($user, $folder, 'write');
}
/** Sharing: files use share, folders require share + full-view. */
public static function canShareFile(string $user, array $perms, string $folder): bool {
public static function canShareFile(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
return self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');
}
public static function canShareFolder(string $user, array $perms, string $folder): bool {
public static function canShareFolder(string $user, array $perms, string $folder): bool
{
$folder = self::normalizeFolder($folder);
if (self::isAdmin($perms)) return true;
$can = self::hasGrant($user, $folder, 'owners') || self::hasGrant($user, $folder, 'share');

87
src/lib/FS.php Normal file
View File

@@ -0,0 +1,87 @@
<?php
// src/lib/FS.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
final class FS
{
/** Hidden/system names to ignore entirely */
public static function IGNORE(): array {
return ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
}
/** App-specific names to skip from UI */
public static function SKIP(): array {
return ['trash','profile_pics'];
}
public static function isSafeSegment(string $name): bool {
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
if (strpos($name, "\0") !== false) return false;
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
$len = mb_strlen($name);
return $len > 0 && $len <= 255;
}
/** realpath($p) and ensure it remains inside $base (defends symlink escape). */
public static function safeReal(string $baseReal, string $p): ?string {
$rp = realpath($p);
if ($rp === false) return null;
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($rp2, $base) !== 0) return null;
return rtrim($rp, DIRECTORY_SEPARATOR);
}
/**
* Small bounded DFS to learn if an unreadable folder has any readable descendant (for “locked” rows).
* $maxDepth intentionally small to avoid expensive scans.
*/
public static function hasReadableDescendant(
string $baseReal,
string $absPath,
string $relPath,
string $user,
array $perms,
int $maxDepth = 2
): bool {
if ($maxDepth <= 0 || !is_dir($absPath)) return false;
$IGNORE = self::IGNORE();
$SKIP = self::SKIP();
$items = @scandir($absPath) ?: [];
foreach ($items as $child) {
if ($child === '.' || $child === '..') continue;
if ($child[0] === '.') continue;
if (in_array($child, $IGNORE, true)) continue;
if (!self::isSafeSegment($child)) continue;
$lower = strtolower($child);
if (in_array($lower, $SKIP, true)) continue;
$abs = $absPath . DIRECTORY_SEPARATOR . $child;
if (!@is_dir($abs)) continue;
// Resolve symlink safely
if (@is_link($abs)) {
$safe = self::safeReal($baseReal, $abs);
if ($safe === null || !is_dir($safe)) continue;
$abs = $safe;
}
$rel = ($relPath === 'root') ? $child : ($relPath . '/' . $child);
if (ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel)) {
return true;
}
if ($maxDepth > 1 && self::hasReadableDescendant($baseReal, $abs, $rel, $user, $perms, $maxDepth - 1)) {
return true;
}
}
return false;
}
}

View File

@@ -557,6 +557,13 @@ class FileModel {
* @return array An associative array with either an "error" key or a "zipPath" key.
*/
public static function createZipArchive($folder, $files) {
// Purge old temp zips > 6h (best-effort)
$zipRoot = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
$now = time();
foreach ((glob($zipRoot . DIRECTORY_SEPARATOR . 'download-*.zip') ?: []) as $zp) {
if (is_file($zp) && ($now - (int)@filemtime($zp)) > 21600) { @unlink($zp); }
}
// Normalize and validate target folder
$folder = trim((string)$folder) ?: 'root';
$baseDir = realpath(UPLOAD_DIR);
@@ -567,7 +574,6 @@ class FileModel {
if (strtolower($folder) === 'root' || $folder === "") {
$folderPathReal = $baseDir;
} else {
// Prevent traversal and validate each segment against folder regex
if (strpos($folder, '..') !== false) {
return ["error" => "Invalid folder name."];
}
@@ -592,6 +598,10 @@ class FileModel {
continue;
}
$fullPath = $folderPathReal . DIRECTORY_SEPARATOR . $fileName;
// Skip symlinks (avoid archiving outside targets via links)
if (is_link($fullPath)) {
continue;
}
if (is_file($fullPath)) {
$filesToZip[] = $fullPath;
}
@@ -602,9 +612,7 @@ class FileModel {
// Workspace on the big disk: META_DIR/ziptmp
$work = rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'ziptmp';
if (!is_dir($work)) {
@mkdir($work, 0775, true);
}
if (!is_dir($work)) { @mkdir($work, 0775, true); }
if (!is_dir($work) || !is_writable($work)) {
return ["error" => "ZIP temp dir not writable: " . $work];
}
@@ -626,7 +634,7 @@ class FileModel {
@set_time_limit(0);
// Create the ZIP path inside META_DIR/ziptmp
// Create the ZIP path inside META_DIR/ziptmp (libzip temp stays on same FS)
$zipName = 'download-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.zip';
$zipPath = $work . DIRECTORY_SEPARATOR . $zipName;
@@ -636,7 +644,7 @@ class FileModel {
}
foreach ($filesToZip as $filePath) {
// Add using basename at the root of the zip (matches your current behavior)
// Add using basename at the root of the zip (matches current behavior)
$zip->addFile($filePath, basename($filePath));
}

90
src/models/FolderMeta.php Normal file
View File

@@ -0,0 +1,90 @@
<?php
// src/models/FolderMeta.php
declare(strict_types=1);
require_once PROJECT_ROOT . '/config/config.php';
require_once __DIR__ . '/../../src/lib/ACL.php';
class FolderMeta
{
private static function path(): string {
return rtrim((string)META_DIR, '/\\') . DIRECTORY_SEPARATOR . 'folder_colors.json';
}
public static function normalizeFolder(string $folder): string {
$f = trim(str_replace('\\','/',$folder), "/ \t\r\n");
return ($f === '' || $f === 'root') ? 'root' : $f;
}
/** Normalize hex (accepts #RGB or #RRGGBB, returns #RRGGBB) */
public static function normalizeHex(?string $hex): ?string {
if ($hex === null || $hex === '') return null;
if (!preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $hex)) {
throw new \InvalidArgumentException('Invalid color hex');
}
if (strlen($hex) === 4) {
$hex = '#' . $hex[1].$hex[1] . $hex[2].$hex[2] . $hex[3].$hex[3];
}
return strtoupper($hex);
}
/** Read full map from disk */
public static function getMap(): array {
$file = self::path();
$raw = @file_get_contents($file);
$map = is_string($raw) ? json_decode($raw, true) : [];
return is_array($map) ? $map : [];
}
/** Write full map to disk (atomic-ish) */
private static function writeMap(array $map): void {
$file = self::path();
$dir = dirname($file);
if (!is_dir($dir)) @mkdir($dir, 0775, true);
$tmp = $file . '.tmp';
@file_put_contents($tmp, json_encode($map, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES), LOCK_EX);
@rename($tmp, $file);
@chmod($file, 0664);
}
/** Set or clear a color for one folder */
public static function setColor(string $folder, ?string $hex): array {
$folder = self::normalizeFolder($folder);
$hex = self::normalizeHex($hex);
$map = self::getMap();
if ($hex === null) unset($map[$folder]);
else $map[$folder] = $hex;
self::writeMap($map);
return ['folder'=>$folder, 'color'=>$map[$folder] ?? null];
}
/** Migrate color entries for a whole subtree (used by move/rename) */
public static function migrateSubtree(string $source, string $target): array {
$src = self::normalizeFolder($source);
$dst = self::normalizeFolder($target);
if ($src === 'root') return ['changed'=>false, 'moved'=>0];
$map = self::getMap();
if (!$map) return ['changed'=>false, 'moved'=>0];
$new = $map;
$moved = 0;
foreach ($map as $key => $hex) {
$isSelf = ($key === $src);
$isSub = str_starts_with($key.'/', $src.'/');
if (!$isSelf && !$isSub) continue;
unset($new[$key]);
$suffix = substr($key, strlen($src)); // '' or '/child/...'
$newKey = $dst === 'root' ? ltrim($suffix,'/') : rtrim($dst,'/') . $suffix;
$new[$newKey] = $hex;
$moved++;
}
if ($moved) self::writeMap($new);
return ['changed'=> (bool)$moved, 'moved'=> $moved];
}
}

View File

@@ -3,6 +3,7 @@
require_once PROJECT_ROOT . '/config/config.php';
require_once PROJECT_ROOT . '/src/lib/ACL.php';
require_once PROJECT_ROOT . '/src/lib/FS.php';
class FolderModel
{
@@ -10,6 +11,229 @@ class FolderModel
* Ownership mapping helpers (stored in META_DIR/folder_owners.json)
* ============================================================ */
public static function countVisible(string $folder, string $user, array $perms): array
{
$folder = ACL::normalizeFolder($folder);
// If the user can't view this folder at all, short-circuit (admin/read/read_own)
$canViewFolder = ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $folder)
|| ACL::canReadOwn($user, $perms, $folder);
if (!$canViewFolder) return ['folders' => 0, 'files' => 0];
$base = realpath((string)UPLOAD_DIR);
if ($base === false) return ['folders' => 0, 'files' => 0];
// Resolve target dir + ACL-relative prefix
if ($folder === 'root') {
$dir = $base;
$relPrefix = '';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!self::isSafeSegment($seg)) return ['folders' => 0, 'files' => 0];
}
$guess = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dir = self::safeReal($base, $guess);
if ($dir === null || !is_dir($dir)) return ['folders' => 0, 'files' => 0];
$relPrefix = implode('/', $parts);
}
// Ignore lists (expandable)
$IGNORE = ['@eaDir', '#recycle', '.DS_Store', 'Thumbs.db'];
$SKIP = ['trash', 'profile_pics'];
$entries = @scandir($dir);
if ($entries === false) return ['folders' => 0, 'files' => 0];
$hasChildFolder = false;
$hasFile = false;
// Cap scanning to avoid pathological dirs
$MAX_SCAN = 4000;
$scanned = 0;
foreach ($entries as $name) {
if (++$scanned > $MAX_SCAN) break;
if ($name === '.' || $name === '..') continue;
if ($name[0] === '.') continue;
if (in_array($name, $IGNORE, true)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
if (!self::isSafeSegment($name)) continue;
$abs = $dir . DIRECTORY_SEPARATOR . $name;
if (@is_dir($abs)) {
// Symlink defense on children
if (@is_link($abs)) {
$safe = self::safeReal($base, $abs);
if ($safe === null || !is_dir($safe)) continue;
}
// Only count child dirs the user can view (admin/read/read_own)
$childRel = ($relPrefix === '' ? $name : $relPrefix . '/' . $name);
if (
ACL::isAdmin($perms)
|| ACL::canRead($user, $perms, $childRel)
|| ACL::canReadOwn($user, $perms, $childRel)
) {
$hasChildFolder = true;
}
} elseif (@is_file($abs)) {
// Any file present is enough for the "files" flag once the folder itself is viewable
$hasFile = true;
}
if ($hasChildFolder && $hasFile) break; // early exit
}
return [
'folders' => $hasChildFolder ? 1 : 0,
'files' => $hasFile ? 1 : 0,
];
}
/* Helpers (private) */
private static function isSafeSegment(string $name): bool
{
if ($name === '.' || $name === '..') return false;
if (strpos($name, '/') !== false || strpos($name, '\\') !== false) return false;
if (strpos($name, "\0") !== false) return false;
if (preg_match('/[\x00-\x1F]/u', $name)) return false;
$len = mb_strlen($name);
return $len > 0 && $len <= 255;
}
private static function safeReal(string $baseReal, string $p): ?string
{
$rp = realpath($p);
if ($rp === false) return null;
$base = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$rp2 = rtrim($rp, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($rp2, $base) !== 0) return null;
return rtrim($rp, DIRECTORY_SEPARATOR);
}
public static function listChildren(string $folder, string $user, array $perms, ?string $cursor = null, int $limit = 500): array
{
$folder = ACL::normalizeFolder($folder);
$limit = max(1, min(2000, $limit));
$cursor = ($cursor !== null && $cursor !== '') ? $cursor : null;
$baseReal = realpath((string)UPLOAD_DIR);
if ($baseReal === false) return ['items' => [], 'nextCursor' => null];
// Resolve target directory
if ($folder === 'root') {
$dirReal = $baseReal;
$relPrefix = 'root';
} else {
$parts = array_filter(explode('/', $folder), fn($p) => $p !== '');
foreach ($parts as $seg) {
if (!FS::isSafeSegment($seg)) return ['items'=>[], 'nextCursor'=>null];
}
$relPrefix = implode('/', $parts);
$dirGuess = $baseReal . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $parts);
$dirReal = FS::safeReal($baseReal, $dirGuess);
if ($dirReal === null || !is_dir($dirReal)) return ['items'=>[], 'nextCursor'=>null];
}
$IGNORE = FS::IGNORE();
$SKIP = FS::SKIP(); // lowercased names to skip (e.g. 'trash', 'profile_pics')
$entries = @scandir($dirReal);
if ($entries === false) return ['items'=>[], 'nextCursor'=>null];
$rows = []; // each: ['name'=>..., 'locked'=>bool, 'hasSubfolders'=>bool?, 'nonEmpty'=>bool?]
foreach ($entries as $item) {
if ($item === '.' || $item === '..') continue;
if ($item[0] === '.') continue;
if (in_array($item, $IGNORE, true)) continue;
if (!FS::isSafeSegment($item)) continue;
$lower = strtolower($item);
if (in_array($lower, $SKIP, true)) continue;
$full = $dirReal . DIRECTORY_SEPARATOR . $item;
if (!@is_dir($full)) continue;
// Symlink defense
if (@is_link($full)) {
$safe = FS::safeReal($baseReal, $full);
if ($safe === null || !is_dir($safe)) continue;
$full = $safe;
}
// ACL-relative path (for checks)
$rel = ($relPrefix === 'root') ? $item : $relPrefix . '/' . $item;
$canView = ACL::canRead($user, $perms, $rel) || ACL::canReadOwn($user, $perms, $rel);
$locked = !$canView;
// ---- quick per-child stats (single-level scan, early exit) ----
$hasSubs = false; // at least one subdirectory
$nonEmpty = false; // any direct entry (file or folder)
try {
$it = new \FilesystemIterator($full, \FilesystemIterator::SKIP_DOTS);
foreach ($it as $child) {
$name = $child->getFilename();
if (!$name) continue;
if ($name[0] === '.') continue;
if (!FS::isSafeSegment($name)) continue;
if (in_array(strtolower($name), $SKIP, true)) continue;
$nonEmpty = true;
$isDir = $child->isDir();
if (!$isDir && $child->isLink()) {
$linkReal = FS::safeReal($baseReal, $child->getPathname());
$isDir = ($linkReal !== null && is_dir($linkReal));
}
if ($isDir) { $hasSubs = true; break; } // early exit once we know there's a subfolder
}
} catch (\Throwable $e) {
// keep defaults
}
// ---------------------------------------------------------------
if ($locked) {
// Show a locked row ONLY when this folder has a readable descendant
if (FS::hasReadableDescendant($baseReal, $full, $rel, $user, $perms, 2)) {
$rows[] = [
'name' => $item,
'locked' => true,
'hasSubfolders' => $hasSubs, // fine to keep structural chevrons
// nonEmpty intentionally omitted for locked nodes
];
}
} else {
$rows[] = [
'name' => $item,
'locked' => false,
'hasSubfolders' => $hasSubs,
'nonEmpty' => $nonEmpty,
];
}
}
// natural order + cursor pagination
usort($rows, fn($a, $b) => strnatcasecmp($a['name'], $b['name']));
$start = 0;
if ($cursor !== null) {
$n = count($rows);
for ($i = 0; $i < $n; $i++) {
if (strnatcasecmp($rows[$i]['name'], $cursor) > 0) { $start = $i; break; }
$start = $i + 1;
}
}
$page = array_slice($rows, $start, $limit);
$nextCursor = null;
if ($start + count($page) < count($rows)) {
$last = $page[count($page)-1];
$nextCursor = $last['name'];
}
return ['items' => $page, 'nextCursor' => $nextCursor];
}
/** Load the folder → owner map. */
public static function getFolderOwners(): array
{
@@ -174,40 +398,42 @@ class FolderModel
// -------- Normalize incoming values (use ONLY the parameters) --------
$folderName = trim((string)$folderName);
$parentIn = trim((string)$parent);
// If the client sent a path in folderName (e.g., "bob/new-sub") and parent is root/empty,
// derive parent = "bob" and folderName = "new-sub" so permission checks hit "bob".
$normalized = ACL::normalizeFolder($folderName);
if ($normalized !== 'root' && strpos($normalized, '/') !== false &&
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)) {
if (
$normalized !== 'root' && strpos($normalized, '/') !== false &&
($parentIn === '' || strcasecmp($parentIn, 'root') === 0)
) {
$parentIn = trim(str_replace('\\', '/', dirname($normalized)), '/');
$folderName = basename($normalized);
if ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) $parentIn = 'root';
}
$parent = ($parentIn === '' || strcasecmp($parentIn, 'root') === 0) ? 'root' : $parentIn;
$folderName = trim($folderName);
if ($folderName === '') return ['success'=>false, 'error' => 'Folder name required'];
if ($folderName === '') return ['success' => false, 'error' => 'Folder name required'];
// ACL key for new folder
$newKey = ($parent === 'root') ? $folderName : ($parent . '/' . $folderName);
// -------- Compose filesystem paths --------
$base = rtrim((string)UPLOAD_DIR, "/\\");
$parentRel = ($parent === 'root') ? '' : str_replace('/', DIRECTORY_SEPARATOR, $parent);
$parentAbs = $parentRel ? ($base . DIRECTORY_SEPARATOR . $parentRel) : $base;
$newAbs = $parentAbs . DIRECTORY_SEPARATOR . $folderName;
// -------- Exists / sanity checks --------
if (!is_dir($parentAbs)) return ['success'=>false, 'error' => 'Parent folder does not exist'];
if (is_dir($newAbs)) return ['success'=>false, 'error' => 'Folder already exists'];
if (!is_dir($parentAbs)) return ['success' => false, 'error' => 'Parent folder does not exist'];
if (is_dir($newAbs)) return ['success' => false, 'error' => 'Folder already exists'];
// -------- Create directory --------
if (!@mkdir($newAbs, 0775, true)) {
$err = error_get_last();
return ['success'=>false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': '.$err['message']) : '')];
return ['success' => false, 'error' => 'Failed to create folder' . (!empty($err['message']) ? (': ' . $err['message']) : '')];
}
// -------- Seed ACL --------
$inherit = defined('ACL_INHERIT_ON_CREATE') && ACL_INHERIT_ON_CREATE;
try {
@@ -226,9 +452,9 @@ class FolderModel
} catch (Throwable $e) {
// Roll back FS if ACL seeding fails
@rmdir($newAbs);
return ['success'=>false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
return ['success' => false, 'error' => 'Failed to seed ACL: ' . $e->getMessage()];
}
return ['success' => true, 'folder' => $newKey];
}
@@ -279,7 +505,7 @@ class FolderModel
// Validate names (per-segment)
foreach ([$oldFolder, $newFolder] as $f) {
$parts = array_filter(explode('/', $f), fn($p)=>$p!=='');
$parts = array_filter(explode('/', $f), fn($p) => $p !== '');
if (empty($parts)) return ["error" => "Invalid folder name(s)."];
foreach ($parts as $seg) {
if (!preg_match(REGEX_FOLDER_NAME, $seg)) {
@@ -294,7 +520,7 @@ class FolderModel
$base = realpath(UPLOAD_DIR);
if ($base === false) return ["error" => "Uploads directory not configured correctly."];
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p!=='');
$newParts = array_filter(explode('/', $newFolder), fn($p) => $p !== '');
$newRel = implode('/', $newParts);
$newPath = $base . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $newParts);
@@ -469,7 +695,7 @@ class FolderModel
return [
"record" => $record,
"folder" => $relative,
"realFolderPath"=> $realFolderPath,
"realFolderPath" => $realFolderPath,
"files" => $filesOnPage,
"currentPage" => $currentPage,
"totalPages" => $totalPages
@@ -493,7 +719,7 @@ class FolderModel
}
$expires = time() + max(1, $expirationSeconds);
$hashedPassword= $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
$hashedPassword = $password !== "" ? password_hash($password, PASSWORD_DEFAULT) : "";
$shareFile = META_DIR . "share_folder_links.json";
$links = file_exists($shareFile)
@@ -521,7 +747,7 @@ class FolderModel
// Build URL
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
$scheme = $https ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? gethostbyname(gethostname());
$baseUrl = $scheme . '://' . rtrim($host, '/');
@@ -548,7 +774,7 @@ class FolderModel
return ["error" => "This share link has expired."];
}
[$realFolderPath, , $err] = self::resolveFolderPath((string)$record['folder'], false);
[$realFolderPath,, $err] = self::resolveFolderPath((string)$record['folder'], false);
if ($err || !is_dir($realFolderPath)) {
return ["error" => "Shared folder not found."];
}
@@ -576,8 +802,26 @@ class FolderModel
// Max size & allowed extensions (mirror FileModels common types)
$maxSize = 50 * 1024 * 1024; // 50 MB
$allowedExtensions = [
'jpg','jpeg','png','gif','pdf','doc','docx','txt','xls','xlsx','ppt','pptx',
'mp4','webm','mp3','mkv','csv','json','xml','md'
'jpg',
'jpeg',
'png',
'gif',
'pdf',
'doc',
'docx',
'txt',
'xls',
'xlsx',
'ppt',
'pptx',
'mp4',
'webm',
'mp3',
'mkv',
'csv',
'json',
'xml',
'md'
];
$shareFile = META_DIR . "share_folder_links.json";
@@ -616,7 +860,7 @@ class FolderModel
// New safe filename
$safeBase = preg_replace('/[^A-Za-z0-9_\-\.]/', '_', $uploadedName);
$newFilename= uniqid('', true) . "_" . $safeBase;
$newFilename = uniqid('', true) . "_" . $safeBase;
$targetPath = $targetDir . DIRECTORY_SEPARATOR . $newFilename;
if (!move_uploaded_file($fileUpload['tmp_name'], $targetPath)) {
@@ -658,4 +902,4 @@ class FolderModel
file_put_contents($shareFile, json_encode($links, JSON_PRETTY_PRINT), LOCK_EX);
return true;
}
}
}

View File

@@ -3,14 +3,17 @@
require_once PROJECT_ROOT . '/config/config.php';
class UploadModel {
private static function sanitizeFolder(string $folder): string {
class UploadModel
{
private static function sanitizeFolder(string $folder): string
{
// decode "%20", normalise slashes & trim via ACL helper
$f = ACL::normalizeFolder(rawurldecode($folder));
// model uses '' to represent root
if ($f === 'root') return '';
if ($f === 'root') {
return '';
}
// forbid dot segments / empty parts
foreach (explode('/', $f) as $seg) {
@@ -28,9 +31,13 @@ class UploadModel {
return $f; // safe, normalised, with spaces allowed
}
public static function handleUpload(array $post, array $files): array {
// --- GET resumable test (make folder handling consistent)
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($post['resumableTest'])) {
public static function handleUpload(array $post, array $files): array
{
// --- GET resumable test (make folder handling consistent) ---
if (
(($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET')
&& isset($post['resumableChunkNumber'], $post['resumableIdentifier'])
) {
$chunkNumber = (int)($post['resumableChunkNumber'] ?? 0);
$resumableIdentifier = $post['resumableIdentifier'] ?? '';
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
@@ -38,15 +45,16 @@ class UploadModel {
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
$chunkFile = $tempDir . $chunkNumber;
return ["status" => file_exists($chunkFile) ? "found" : "not found"];
return ['status' => file_exists($chunkFile) ? 'found' : 'not found'];
}
// --- CHUNKED ---
// --- CHUNKED (Resumable.js POST uploads) ---
if (isset($post['resumableChunkNumber'])) {
$chunkNumber = (int)$post['resumableChunkNumber'];
$totalChunks = (int)$post['resumableTotalChunks'];
@@ -54,109 +62,126 @@ class UploadModel {
$resumableFilename = urldecode(basename($post['resumableFilename'] ?? ''));
if (!preg_match(REGEX_FILE_NAME, $resumableFilename)) {
return ["error" => "Invalid file name: $resumableFilename"];
return ['error' => "Invalid file name: $resumableFilename"];
}
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
if (empty($files['file']) || !isset($files['file']['name'])) {
return ["error" => "No files received"];
return ['error' => 'No files received'];
}
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
return ['error' => 'Failed to create upload directory'];
}
$tempDir = $baseUploadDir . 'resumable_' . $resumableIdentifier . DIRECTORY_SEPARATOR;
if (!is_dir($tempDir) && !mkdir($tempDir, 0775, true)) {
return ["error" => "Failed to create temporary chunk directory"];
return ['error' => 'Failed to create temporary chunk directory'];
}
$chunkErr = $files['file']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($chunkErr !== UPLOAD_ERR_OK) {
return ["error" => "Upload error on chunk $chunkNumber"];
return ['error' => "Upload error on chunk $chunkNumber"];
}
$chunkFile = $tempDir . $chunkNumber;
$tmpName = $files['file']['tmp_name'] ?? null;
if (!$tmpName || !move_uploaded_file($tmpName, $chunkFile)) {
return ["error" => "Failed to move uploaded chunk $chunkNumber"];
return ['error' => "Failed to move uploaded chunk $chunkNumber"];
}
// all chunks present?
// All chunks present?
for ($i = 1; $i <= $totalChunks; $i++) {
if (!file_exists($tempDir . $i)) {
return ["status" => "chunk uploaded"];
return ['status' => 'chunk uploaded'];
}
}
// merge
// Merge
$targetPath = $baseUploadDir . $resumableFilename;
if (!$out = fopen($targetPath, "wb")) {
return ["error" => "Failed to open target file for writing"];
if (!$out = fopen($targetPath, 'wb')) {
return ['error' => 'Failed to open target file for writing'];
}
for ($i = 1; $i <= $totalChunks; $i++) {
$chunkPath = $tempDir . $i;
if (!file_exists($chunkPath)) { fclose($out); return ["error" => "Chunk $i missing during merge"]; }
if (!$in = fopen($chunkPath, "rb")) { fclose($out); return ["error" => "Failed to open chunk $i"]; }
while ($buff = fread($in, 4096)) { fwrite($out, $buff); }
if (!file_exists($chunkPath)) {
fclose($out);
return ['error' => "Chunk $i missing during merge"];
}
if (!$in = fopen($chunkPath, 'rb')) {
fclose($out);
return ['error' => "Failed to open chunk $i"];
}
while ($buff = fread($in, 4096)) {
fwrite($out, $buff);
}
fclose($in);
}
fclose($out);
// metadata
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
// Metadata
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$collection = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($collection)) $collection = [];
$uploader = $_SESSION['username'] ?? 'Unknown';
$collection = file_exists($metadataFile)
? json_decode(file_get_contents($metadataFile), true)
: [];
if (!is_array($collection)) {
$collection = [];
}
if (!isset($collection[$resumableFilename])) {
$collection[$resumableFilename] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
$collection[$resumableFilename] = [
'uploaded' => $uploadedDate,
'uploader' => $uploader,
];
file_put_contents($metadataFile, json_encode($collection, JSON_PRETTY_PRINT));
}
// cleanup temp
// Cleanup temp
self::rrmdir($tempDir);
return ["success" => "File uploaded successfully"];
return ['success' => 'File uploaded successfully'];
}
// --- NON-CHUNKED ---
// --- NON-CHUNKED (drag-and-drop / folder uploads) ---
$folderSan = self::sanitizeFolder((string)($post['folder'] ?? 'root'));
$baseUploadDir = UPLOAD_DIR;
if ($folderSan !== '') {
$baseUploadDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $folderSan) . DIRECTORY_SEPARATOR;
}
if (!is_dir($baseUploadDir) && !mkdir($baseUploadDir, 0775, true)) {
return ["error" => "Failed to create upload directory"];
return ['error' => 'Failed to create upload directory'];
}
$safeFileNamePattern = REGEX_FILE_NAME;
$metadataCollection = [];
$metadataChanged = [];
foreach ($files["file"]["name"] as $index => $fileName) {
foreach ($files['file']['name'] as $index => $fileName) {
if (($files['file']['error'][$index] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) {
return ["error" => "Error uploading file"];
return ['error' => 'Error uploading file'];
}
$safeFileName = trim(urldecode(basename($fileName)));
if (!preg_match($safeFileNamePattern, $safeFileName)) {
return ["error" => "Invalid file name: " . $fileName];
return ['error' => 'Invalid file name: ' . $fileName];
}
$relativePath = '';
if (isset($post['relativePath'])) {
$relativePath = is_array($post['relativePath']) ? ($post['relativePath'][$index] ?? '') : $post['relativePath'];
$relativePath = is_array($post['relativePath'])
? ($post['relativePath'][$index] ?? '')
: $post['relativePath'];
}
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR;
@@ -164,34 +189,41 @@ class UploadModel {
$subDir = dirname($relativePath);
if ($subDir !== '.' && $subDir !== '') {
$uploadDir = rtrim($baseUploadDir, '/\\') . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
. str_replace('/', DIRECTORY_SEPARATOR, $subDir) . DIRECTORY_SEPARATOR;
}
$safeFileName = basename($relativePath);
}
if (!is_dir($uploadDir) && !@mkdir($uploadDir, 0775, true)) {
return ["error" => "Failed to create subfolder: " . $uploadDir];
return ['error' => 'Failed to create subfolder: ' . $uploadDir];
}
$targetPath = $uploadDir . $safeFileName;
if (!move_uploaded_file($files["file"]["tmp_name"][$index], $targetPath)) {
return ["error" => "Error uploading file"];
if (!move_uploaded_file($files['file']['tmp_name'][$index], $targetPath)) {
return ['error' => 'Error uploading file'];
}
$metadataKey = ($folderSan === '') ? "root" : $folderSan;
$metadataKey = ($folderSan === '') ? 'root' : $folderSan;
$metadataFileName = str_replace(['/', '\\', ' '], '-', $metadataKey) . '_metadata.json';
$metadataFile = META_DIR . $metadataFileName;
if (!isset($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = file_exists($metadataFile) ? json_decode(file_get_contents($metadataFile), true) : [];
if (!is_array($metadataCollection[$metadataKey])) $metadataCollection[$metadataKey] = [];
$metadataCollection[$metadataKey] = file_exists($metadataFile)
? json_decode(file_get_contents($metadataFile), true)
: [];
if (!is_array($metadataCollection[$metadataKey])) {
$metadataCollection[$metadataKey] = [];
}
$metadataChanged[$metadataKey] = false;
}
if (!isset($metadataCollection[$metadataKey][$safeFileName])) {
$uploadedDate = date(DATE_TIME_FORMAT);
$uploader = $_SESSION['username'] ?? "Unknown";
$metadataCollection[$metadataKey][$safeFileName] = ["uploaded" => $uploadedDate, "uploader" => $uploader];
$uploader = $_SESSION['username'] ?? 'Unknown';
$metadataCollection[$metadataKey][$safeFileName] = [
'uploaded' => $uploadedDate,
'uploader' => $uploader,
];
$metadataChanged[$metadataKey] = true;
}
}
@@ -204,17 +236,17 @@ class UploadModel {
}
}
return ["success" => "Files uploaded successfully"];
return ['success' => 'Files uploaded successfully'];
}
/**
/**
* Recursively removes a directory and its contents.
*
* @param string $dir The directory to remove.
* @return void
*/
private static function rrmdir(string $dir): void {
private static function rrmdir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
@@ -231,7 +263,7 @@ class UploadModel {
}
rmdir($dir);
}
/**
* Removes the temporary chunk directory for resumable uploads.
*
@@ -240,25 +272,26 @@ class UploadModel {
* @param string $folder The folder name provided (URL-decoded).
* @return array Returns a status array indicating success or error.
*/
public static function removeChunks(string $folder): array {
public static function removeChunks(string $folder): array
{
$folder = urldecode($folder);
// The folder name should exactly match the "resumable_" pattern.
$regex = "/^resumable_" . PATTERN_FOLDER_NAME . "$/u";
if (!preg_match($regex, $folder)) {
return ["error" => "Invalid folder name"];
return ['error' => 'Invalid folder name'];
}
$tempDir = rtrim(UPLOAD_DIR, '/\\') . DIRECTORY_SEPARATOR . $folder;
if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder already removed."];
return ['success' => true, 'message' => 'Temporary folder already removed.'];
}
self::rrmdir($tempDir);
if (!is_dir($tempDir)) {
return ["success" => true, "message" => "Temporary folder removed."];
} else {
return ["error" => "Failed to remove temporary folder."];
return ['success' => true, 'message' => 'Temporary folder removed.'];
}
return ['error' => 'Failed to remove temporary folder.'];
}
}