Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b20a9f1d | ||
|
|
0ec8103fbf | ||
|
|
3b1ebdd77f | ||
|
|
3726e2423d | ||
|
|
5613710411 | ||
|
|
08f7ffccbc | ||
|
|
ad1d41fad8 | ||
|
|
99662cd2f2 | ||
|
|
060a548af4 | ||
|
|
9880adb417 | ||
|
|
a56641e81c | ||
|
|
3b636f69d8 | ||
|
|
930ed954ec | ||
|
|
402f590163 | ||
|
|
ef47ad2b52 | ||
|
|
8cdff954d5 | ||
|
|
01cfa597b9 | ||
|
|
f5e42a2e81 | ||
|
|
f1dcc0df24 | ||
|
|
ba9ead666d | ||
|
|
dbdf760d4d | ||
|
|
a031fc99c2 | ||
|
|
db73cf2876 | ||
|
|
062f34dd3d | ||
|
|
63b24ba698 | ||
|
|
567d2f62e8 | ||
|
|
9be53ba033 | ||
|
|
de925e6fc2 | ||
|
|
bd7ff4d9cd | ||
|
|
6727cc66ac | ||
|
|
f3269877c7 | ||
|
|
5ffe9b3ffc | ||
|
|
abd3dad5a5 | ||
|
|
4c849b1dc3 | ||
|
|
7cc314179f | ||
|
|
9ddb633cca | ||
|
|
448e246689 | ||
|
|
dc7797e50d | ||
|
|
913d370ef2 | ||
|
|
488b5cb532 | ||
|
|
15b5aa6d8d | ||
|
|
8f03cc7456 | ||
|
|
c9a99506d7 | ||
|
|
04ec0a0830 | ||
|
|
429cd0314a | ||
|
|
ba29cc4822 | ||
|
|
e2cd304158 | ||
|
|
ca8788a694 | ||
|
|
dc45fed886 | ||
|
|
a9fe342175 | ||
|
|
7669f5a10b | ||
|
|
34a4e06a23 | ||
|
|
d00faf5fe7 |
169
.github/workflows/release-on-version.yml
vendored
@@ -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 isn’t 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: |
|
||||
|
||||
570
CHANGELOG.md
@@ -1,5 +1,575 @@
|
||||
# Changelog
|
||||
|
||||
## Changes 11/18/2025 (v1.9.11)
|
||||
|
||||
release(v1.9.11): fix(media): HTTP Range streaming; feat(ui): paged folder strip (closes #68)
|
||||
|
||||
- media: add proper HTTP Range support to /api/file/download.php so HTML5
|
||||
video/audio can seek correctly across all browsers (Brave/Chrome/Android/Windows).
|
||||
- media: avoid buffering the entire file in memory; stream from disk with
|
||||
200/206 responses and Accept-Ranges for smoother playback and faster start times.
|
||||
- media: keep video progress tracking, watched badges, and status chip behavior
|
||||
unchanged but now compatible with the new streaming endpoint.
|
||||
|
||||
- ui: update the folder strip to be responsive:
|
||||
- desktop: keep the existing "chip" layout with icon above name.
|
||||
- mobile: switch to inline rows `[icon] [name]` with reduced whitespace.
|
||||
- ui: add simple lazy-loading for the folder strip so only the first batch of
|
||||
folders is rendered initially, with a "Load more…" button to append chunks for
|
||||
very large folder sets (stays friendly with 100k+ folders).
|
||||
|
||||
- misc: small CSS tidy-up around the folder strip classes to remove duplicates
|
||||
and keep mobile/desktop behavior clearly separated.
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/18/2025 (v1.9.10)
|
||||
|
||||
release(v1.9.10): add Pro bundle installer and admin panel polish
|
||||
|
||||
- Add FileRise Pro section in admin panel with license management and bundle upload
|
||||
- Persist Pro bundle under users/pro and sync public/api/pro endpoints on container startup
|
||||
- Improve admin config API: Pro metadata, license file handling, hardened auth/CSRF helpers
|
||||
- Update Pro badge/version UI with “update available” hint and link to filerise.net
|
||||
- Change Pro bundle installer to always overwrite existing bundle files for clean upgrades
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/16/2025 (v1.9.9)
|
||||
|
||||
release(v1.9.9): fix(branding): sanitize custom logo URL preview
|
||||
|
||||
- Sanitize branding.customLogoUrl on the server before writing siteConfig.json
|
||||
- Allow only http/https or site-relative paths; strip invalid/sneaky values
|
||||
- Update adminPanel.js live logo preview to set img src/alt safely
|
||||
- Addresses CodeQL XSS warning while keeping Pro branding logo overrides working
|
||||
|
||||
---
|
||||
|
||||
## Changes 11/16/2025 (v1.9.8)
|
||||
|
||||
release(v1.9.8): feat(pro): wire core to Pro licensing + branding hooks
|
||||
|
||||
- Add Pro feature flags + bootstrap wiring
|
||||
- Define FR_PRO_ACTIVE/FR_PRO_TYPE/FR_PRO_EMAIL/FR_PRO_VERSION/FR_PRO_LICENSE_FILE
|
||||
in config.php and optionally require src/pro/bootstrap_pro.php.
|
||||
- Expose a `pro` block from AdminController::getConfig() so the UI can show
|
||||
license status, type, email, and bundle version without leaking the raw key.
|
||||
|
||||
- Implement license save endpoint
|
||||
- Add AdminController::setLicense() and /api/admin/setLicense.php to accept a
|
||||
FRP1 license string via JSON, validate basic shape, and persist it to
|
||||
FR_PRO_LICENSE_FILE with strict 0600 permissions.
|
||||
- Return structured JSON success/error responses for the admin UI.
|
||||
|
||||
- Extend admin config model with branding + safer validation
|
||||
- Add `branding.customLogoUrl`, `branding.headerBgLight`, and
|
||||
`branding.headerBgDark` fields to AdminModel defaults and updateConfig().
|
||||
- Introduce AdminModel::sanitizeLogoUrl() to allow only site-relative /uploads
|
||||
paths or http(s) URLs; reject absolute filesystem paths, data: URLs, and
|
||||
javascript: URLs.
|
||||
- Continue to validate ONLYOFFICE docsOrigin as http(s) only, keeping core
|
||||
config hardening intact.
|
||||
|
||||
- New Pro-aware Admin Panel UI
|
||||
- Rework User Management section to group:
|
||||
- Add user / Remove user
|
||||
- Folder Access (per-folder ACL)
|
||||
- User Permissions (account-level flags)
|
||||
- Add Pro-only actions with clear gating:
|
||||
- “User groups” button (Pro)
|
||||
- “Client upload portal” button with “Pro · Coming soon” pill
|
||||
- Add “FileRise Pro” section:
|
||||
- Show current Pro status (Free vs Active) + license metadata.
|
||||
- Textarea for pasting license key, file upload helper, and “Save license”
|
||||
action wired to /api/admin/setLicense.php.
|
||||
- Optional “Copy current license” button when a license is present.
|
||||
- Add “Sponsor / Donations” section with fixed GitHub Sponsors and Ko-fi URLs
|
||||
and one-click copy/open buttons.
|
||||
|
||||
- Header branding controls (Pro)
|
||||
- Add Header Logo + Header Colors controls under Header Settings, gated by
|
||||
`config.pro.active`.
|
||||
- Allow uploading a logo via /api/pro/uploadBrandLogo.php and auto-filling the
|
||||
normalized /uploads path.
|
||||
- Add live-preview helpers to update the header logo and header background
|
||||
colors in the running UI after saving.
|
||||
|
||||
- Apply branding on app boot
|
||||
- Update main.js to read branding config on load and apply:
|
||||
- Custom header logo (or fallback to /assets/logo.svg).
|
||||
- Light/dark header background colors via CSS variables.
|
||||
- Keeps header consistent with saved branding across reloads and before
|
||||
opening the admin panel.
|
||||
|
||||
- Styling + UX polish
|
||||
- Add styles for new admin sections: collapsible headers, dark-mode aware
|
||||
modal content, and refined folder access grid.
|
||||
- Introduce .btn-pro-admin and .btn-pro-pill classes to render “Pro” and
|
||||
“Pro · Coming soon” pills overlayed on buttons, matching the existing
|
||||
header “Core/Pro” badge treatment.
|
||||
- Minor spacing/typography tweaks in admin panel and ACL UI.
|
||||
|
||||
Note: Core code remains MIT-licensed; Pro functionality is enabled via optional
|
||||
runtime hooks and separate closed-source bundle, without changing the core
|
||||
license text.
|
||||
|
||||
---
|
||||
|
||||
## 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 Resumable’s 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 can’t 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 modal’s #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 isn’t 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; don’t 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 Authelia’s 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 + in‑modal 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 multi‑GB ZIP failures caused by request timeouts or cross‑device renames, and provides a resilient in‑modal progress experience. It also adds a 6‑hour janitor for temporary tokens/logs.
|
||||
|
||||
**Backend** changes:
|
||||
|
||||
- Add **zip status** endpoint that returns progress and readiness, and **tokenized download** endpoint for one‑shot 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 per‑token 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 **0–98%** 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 multi‑GB archives.
|
||||
|
||||
Additional **Notes**
|
||||
|
||||
- Download tokens are **one‑shot** 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
|
||||
|
||||
532
README.md
@@ -10,483 +10,181 @@
|
||||
[](https://github.com/sponsors/error311)
|
||||
[](https://ko-fi.com/error311)
|
||||
|
||||
**Quick links:** [Demo](#live-demo) • [Install](#installation--setup) • [Docker](#1-running-with-docker-recommended) • [Unraid](#unraid) • [WebDAV](#quick-start-mount-via-webdav) • [ONLYOFFICE](#quick-start-onlyoffice-optional) • [FAQ](#faq--troubleshooting)
|
||||
**FileRise** is a modern, self‑hosted web file manager / WebDAV server.
|
||||
Drag & drop uploads, ACL‑aware sharing, OnlyOffice integration, and a clean UI — all in a single PHP app that you control.
|
||||
|
||||
**Elevate your File Management** – A modern, self-hosted web file manager.
|
||||
Upload, organize, and share files or folders through a sleek, responsive web interface.
|
||||
**FileRise** is lightweight yet powerful — your personal cloud drive that you fully control.
|
||||
- 💾 **Self‑hosted “cloud drive”** – Runs anywhere with PHP (or via Docker). No external DB required.
|
||||
- 🔐 **Granular per‑folder ACLs** – View / Own / Upload / Edit / Delete / Share, enforced across UI, API, and WebDAV.
|
||||
- 🔄 **Fast drag‑and‑drop uploads** – Chunked, resumable uploads with pause/resume and progress.
|
||||
- 🌳 **Scales to huge trees** – Tested with **100k+ folders** in the sidebar tree.
|
||||
- 🧩 **ONLYOFFICE support (optional)** – Edit DOCX/XLSX/PPTX using your own Document Server.
|
||||
- 🌍 **WebDAV** – Mount FileRise as a drive from macOS, Windows, Linux, or Cyberduck/WinSCP.
|
||||
- 🎨 **Polished UI** – Dark/light mode, responsive layout, in‑browser previews & code editor.
|
||||
- 🔑 **Login + SSO** – Local users, TOTP 2FA, and OIDC (Auth0 / Authentik / Keycloak / etc.).
|
||||
|
||||
Now featuring **Granular Access Control (ACL)** with per-folder permissions, inheritance, and live admin editing.
|
||||
Grant precise capabilities like *view*, *upload*, *rename*, *delete*, or *manage* on a per-user, per-folder basis — enforced across the UI, API, and WebDAV.
|
||||

|
||||
|
||||
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 you’re on ≤1.4.x, please upgrade.
|
||||
|
||||
**10/25/2025 Video demo:**
|
||||
|
||||
<https://github.com/user-attachments/assets/a2240300-6348-4de7-b72f-1b85b7da3a08>
|
||||
|
||||
**Dark mode:**
|
||||

|
||||
> 💡 Looking for **FileRise Pro** (brandable header, Pro features, license handling)?
|
||||
> Check out [filerise.net](https://filerise.net) – FileRise Core stays fully open‑source (MIT).
|
||||
|
||||
---
|
||||
|
||||
## Features at a Glance or [Full Features Wiki](https://github.com/error311/FileRise/wiki/Features)
|
||||
## Quick links
|
||||
|
||||
- 🚀 **Easy File Uploads:** Upload multiple files and folders via drag & drop or file picker. Supports large files with resumable chunked uploads, pause/resume, and real-time progress. If your connection drops, FileRise resumes automatically.
|
||||
|
||||
- 🗂️ **File Management:** Full suite of operations — move/copy (via drag-drop or dialogs), rename, and batch delete. Download selected files as ZIPs or extract uploaded ZIPs server-side. Organize with an interactive folder tree and breadcrumbs for instant navigation.
|
||||
|
||||
- 🗃️ **Folder & File Sharing:** Share folders or individual files with expiring, optionally password-protected links. Shared folders can accept external uploads (if enabled). Listings are paginated (10 items/page) with file sizes shown in MB.
|
||||
|
||||
- 🔐 **Granular Access Control (ACL):**
|
||||
Per-folder permissions for **owners**, **view**, **view (own)**, **write**, **manage**, **share**, and extended granular capabilities.
|
||||
Each grant controls specific actions across the UI, API, and WebDAV:
|
||||
|
||||
| Permission | Description |
|
||||
|-------------|-------------|
|
||||
| **Manage (Owner)** | Full control of folder and subfolders. Can edit ACLs, rename/delete/create folders, and share items. Implies all other permissions for that folder and below. |
|
||||
| **View (All)** | Allows viewing all files within the folder. Required for folder-level sharing. |
|
||||
| **View (Own)** | Restricts visibility to files uploaded by the user only. Ideal for drop zones or limited-access users. |
|
||||
| **Write** | Grants general write access — enables renaming, editing, moving, copying, deleting, and extracting files. |
|
||||
| **Create** | Allows creating subfolders. Automatically granted to *Manage* users. |
|
||||
| **Upload** | Allows uploading new files without granting full write privileges. |
|
||||
| **Edit / Rename / Copy / Move / Delete / Extract** | Individually toggleable granular file operations. |
|
||||
| **Share File / Share Folder** | Controls sharing capabilities. Folder shares require full View (All). |
|
||||
|
||||
- **Automatic Propagation:** Enabling **Manage** on a folder applies to all subfolders; deselecting subfolder permissions overrides inheritance in the UI.
|
||||
|
||||
ACL enforcement is centralized and atomic across:
|
||||
- **Admin Panel:** Interactive ACL editor with batch save and dynamic inheritance visualization.
|
||||
- **API Endpoints:** All file/folder operations validate server-side.
|
||||
- **WebDAV:** Uses the same ACL engine — View / Own determine listings, granular permissions control upload/edit/delete/create.
|
||||
|
||||
- 🔌 **WebDAV (ACL-Aware):** Mount FileRise as a drive (Cyberduck, WinSCP, Finder, etc.) or access via `curl`.
|
||||
- Listings require **View** or **View (Own)**.
|
||||
- Uploads require **Upload**.
|
||||
- Overwrites require **Edit**.
|
||||
- Deletes require **Delete**.
|
||||
- Creating folders requires **Create** or **Manage**.
|
||||
- All ACLs and ownership rules are enforced exactly as in the web UI.
|
||||
|
||||
- 📚 **API Documentation:** Auto-generated OpenAPI spec (`openapi.json`) with interactive HTML docs (`api.html`) via Redoc.
|
||||
|
||||
- 📝 **Built-in Editor & Preview:** Inline preview for images, video, audio, and PDFs. CodeMirror-based editor for text/code with syntax highlighting and line numbers.
|
||||
|
||||
- 🧩 **Office Docs (ONLYOFFICE, optional):** View/edit DOCX, XLSX, PPTX (and ODT/ODS/ODP, PDF view) using your self-hosted ONLYOFFICE Document Server. Enforced by the same ACLs as the web UI & WebDAV.
|
||||
|
||||
- 🏷️ **Tags & Search:** Add color-coded tags and search by name, tag, uploader, or content. Advanced fuzzy search indexes metadata and file contents.
|
||||
|
||||
- 🔒 **Authentication & SSO:** Username/password, optional TOTP 2FA, and OIDC (Google, Authentik, Keycloak).
|
||||
|
||||
- 🗑️ **Trash & Recovery:** Deleted items move to Trash for recovery (default 3-day retention). Admins can restore or purge globally.
|
||||
|
||||
- 🎨 **Responsive UI (Dark/Light Mode):** Modern, mobile-friendly design with persistent preferences (theme, layout, last folder, etc.).
|
||||
|
||||
- 🌐 **Internationalization:** English, Spanish, French, German & Simplified Chinese available. Community translations welcome.
|
||||
|
||||
- ⚙️ **Lightweight & Self-Contained:** Runs on PHP 8.3+, no external DB required. Single-folder or Docker deployment with minimal footprint, optimized for Unraid and self-hosting.
|
||||
|
||||
(For full features and changelogs, see the [Wiki](https://github.com/error311/FileRise/wiki), [CHANGELOG](https://github.com/error311/FileRise/blob/master/CHANGELOG.md) or [Releases](https://github.com/error311/FileRise/releases).)
|
||||
- 🚀 **Live demo:** [Demo](https://demo.filerise.net) (username: `demo` / password: `demo`)
|
||||
- 📚 **Docs & Wiki:** [Wiki](https://github.com/error311/FileRise/wiki)
|
||||
- [Features overview](https://github.com/error311/FileRise/wiki/Features)
|
||||
- [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
- [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||
- 🐳 **Docker image:** [Docker](https://github.com/error311/filerise-docker)
|
||||
- 📝 **Changelog:** [Changes](https://github.com/error311/FileRise/blob/master/CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
## 1. What FileRise does
|
||||
|
||||
[](https://demo.filerise.net)
|
||||
**Demo credentials:** `demo` / `demo`
|
||||
FileRise turns a folder on your server into a **web‑based file explorer** with:
|
||||
|
||||
Curious about the UI? **Check out the live demo:** <https://demo.filerise.net> (login with username “demo” and password “demo”). **The demo is read-only for security.** Explore the interface, switch themes, preview files, and see FileRise in action!
|
||||
- Folder tree + breadcrumbs for fast navigation
|
||||
- Multi‑file/folder drag‑and‑drop uploads
|
||||
- Move / copy / rename / delete / extract ZIP
|
||||
- Public share links (optionally password‑protected & expiring)
|
||||
- Tagging and search by name, tag, uploader, and content
|
||||
- Trash with restore/purge
|
||||
- Inline previews (images, audio, video, PDF) and a built‑in code editor
|
||||
|
||||
Everything flows through a single ACL engine, so permissions are enforced consistently whether users are in the browser UI, using WebDAV, or hitting the API.
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
## 2. Install (Docker – recommended)
|
||||
|
||||
Deploy FileRise using the **Docker image** (quickest) or a **manual install** on a PHP web server.
|
||||
|
||||
---
|
||||
|
||||
### Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `TIMEZONE` | `UTC` | PHP/app timezone. |
|
||||
| `DATE_TIME_FORMAT` | `m/d/y h:iA` | Display format used in UI. |
|
||||
| `TOTAL_UPLOAD_SIZE` | `5G` | Max combined upload per request (resumable). |
|
||||
| `SECURE` | `false` | Set `true` if served behind HTTPS proxy (affects link generation). |
|
||||
| `PERSISTENT_TOKENS_KEY` | *(required)* | Secret for “Remember Me” tokens. Change from the example! |
|
||||
| `PUID` / `PGID` | `1000` / `1000` | Map `www-data` to host uid:gid (Unraid: often `99:100`). |
|
||||
| `CHOWN_ON_START` | `true` | First run: try to chown mounted dirs to PUID:PGID. |
|
||||
| `SCAN_ON_START` | `true` | Reindex files added outside UI at boot. |
|
||||
| `SHARE_URL` | *(blank)* | Override base URL for share links; blank = auto-detect. |
|
||||
|
||||
---
|
||||
|
||||
### 1) Running with Docker (Recommended)
|
||||
|
||||
#### Pull the image
|
||||
The easiest way to run FileRise is the official Docker image.
|
||||
|
||||
```bash
|
||||
docker pull error311/filerise-docker:latest
|
||||
docker run -d --name filerise -p 8080:80 -e TIMEZONE="America/New_York" -e PERSISTENT_TOKENS_KEY="change_me_to_a_random_string" -v ~/filerise/uploads:/var/www/uploads -v ~/filerise/users:/var/www/users -v ~/filerise/metadata:/var/www/metadata error311/filerise-docker:latest
|
||||
```
|
||||
|
||||
#### Run a container
|
||||
Then visit:
|
||||
|
||||
```bash
|
||||
docker run -d \
|
||||
--name filerise \
|
||||
-p 8080:80 \
|
||||
-e TIMEZONE="America/New_York" \
|
||||
-e DATE_TIME_FORMAT="m/d/y h:iA" \
|
||||
-e TOTAL_UPLOAD_SIZE="5G" \
|
||||
-e SECURE="false" \
|
||||
-e PERSISTENT_TOKENS_KEY="default_please_change_this_key" \
|
||||
-e PUID="1000" \
|
||||
-e PGID="1000" \
|
||||
-e CHOWN_ON_START="true" \
|
||||
-e SCAN_ON_START="true" \
|
||||
-e SHARE_URL="" \
|
||||
-v ~/filerise/uploads:/var/www/uploads \
|
||||
-v ~/filerise/users:/var/www/users \
|
||||
-v ~/filerise/metadata:/var/www/metadata \
|
||||
error311/filerise-docker:latest
|
||||
```text
|
||||
http://your-server-ip:8080
|
||||
```
|
||||
|
||||
The app runs as www-data mapped to PUID/PGID. Ensure your mounted uploads/, users/, metadata/ are owned by PUID:PGID (e.g., chown -R 1000:1000 …), or set PUID/PGID to match existing host ownership (e.g., 99:100 on Unraid). On NAS/NFS, apply the ownership change on the host/NAS.
|
||||
On first launch you’ll be guided through creating the **initial admin user**.
|
||||
|
||||
This starts FileRise on port **8080** → visit `http://your-server-ip:8080`.
|
||||
|
||||
**Notes**
|
||||
|
||||
- **Do not use** Docker `--user`. Use **PUID/PGID** to map on-disk ownership (e.g., `1000:1000`; on Unraid typically `99:100`).
|
||||
- `CHOWN_ON_START=true` is recommended on **first run**. Set to **false** later for faster restarts.
|
||||
- `SCAN_ON_START=true` indexes files added outside the UI so their metadata appears.
|
||||
- `SHARE_URL` optional; leave blank to auto-detect host/scheme. Set to site root (e.g., `https://files.example.com`) if needed.
|
||||
- Set `SECURE="true"` if you serve via HTTPS at your proxy layer.
|
||||
|
||||
**Verify ownership mapping (optional)**
|
||||
|
||||
```bash
|
||||
docker exec -it filerise id www-data
|
||||
# expect: uid=1000 gid=1000 (or 99/100 on Unraid)
|
||||
```
|
||||
|
||||
#### Using Docker Compose
|
||||
|
||||
Save as `docker-compose.yml`, then `docker-compose up -d`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
filerise:
|
||||
image: error311/filerise-docker:latest
|
||||
container_name: filerise
|
||||
ports:
|
||||
- "8080:80"
|
||||
environment:
|
||||
TIMEZONE: "UTC"
|
||||
DATE_TIME_FORMAT: "m/d/y h:iA"
|
||||
TOTAL_UPLOAD_SIZE: "10G"
|
||||
SECURE: "false"
|
||||
PERSISTENT_TOKENS_KEY: "default_please_change_this_key"
|
||||
# Ownership & indexing
|
||||
PUID: "1000" # Unraid users often use 99
|
||||
PGID: "1000" # Unraid users often use 100
|
||||
CHOWN_ON_START: "true" # first run; set to "false" afterwards
|
||||
SCAN_ON_START: "true" # index files added outside the UI at boot
|
||||
# Sharing URL (optional): leave blank to auto-detect from host/scheme
|
||||
SHARE_URL: ""
|
||||
volumes:
|
||||
- ./uploads:/var/www/uploads
|
||||
- ./users:/var/www/users
|
||||
- ./metadata:/var/www/metadata
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Access at `http://localhost:8080` (or your server’s IP).
|
||||
The example sets a custom `PERSISTENT_TOKENS_KEY`—change it to a strong random string.
|
||||
|
||||
- “`CHOWN_ON_START=true` attempts to align ownership **inside the container**; if the host/NAS disallows changes, set the correct UID/GID on the host.”
|
||||
|
||||
**First-time Setup**
|
||||
On first launch, if no users exist, you’ll be prompted to create an **Admin account**. Then use **User Management** to add more users.
|
||||
**More Docker options (Unraid, docker‑compose, env vars, reverse proxy, etc.)**
|
||||
See the Docker repo: [docker repo](https://github.com/error311/filerise-docker)
|
||||
|
||||
---
|
||||
|
||||
### 2) Manual Installation (PHP/Apache)
|
||||
## 3. Manual install (PHP web server)
|
||||
|
||||
If you prefer a traditional web server (LAMP stack or similar):
|
||||
Prefer bare‑metal or your own stack? FileRise is just PHP + a few extensions.
|
||||
|
||||
**Requirements**
|
||||
|
||||
- PHP **8.3+**
|
||||
- Apache (mod_php) or another web server configured for PHP
|
||||
- PHP extensions: `json`, `curl`, `zip` (and typical defaults). No database required.
|
||||
- Web server (Apache / Nginx / Caddy + PHP‑FPM)
|
||||
- PHP extensions: `json`, `curl`, `zip` (and usual defaults)
|
||||
- No database required
|
||||
|
||||
**Download Files**
|
||||
**Steps**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/error311/FileRise.git
|
||||
```
|
||||
1. Clone or download FileRise into your web root:
|
||||
|
||||
Place the files in your web root (e.g., `/var/www/`). Subfolder installs are fine.
|
||||
|
||||
**Composer (if applicable)**
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
**Folders & Permissions**
|
||||
|
||||
```bash
|
||||
mkdir -p uploads users metadata
|
||||
chown -R www-data:www-data uploads users metadata # use your web user
|
||||
chmod -R 775 uploads users metadata
|
||||
```
|
||||
|
||||
- `uploads/`: actual files
|
||||
- `users/`: credentials & token storage
|
||||
- `metadata/`: file metadata (tags, share links, etc.)
|
||||
|
||||
**Configuration**
|
||||
|
||||
Edit `config.php`:
|
||||
|
||||
- `TIMEZONE`, `DATE_TIME_FORMAT` for your locale.
|
||||
- `TOTAL_UPLOAD_SIZE` (ensure PHP `upload_max_filesize` and `post_max_size` meet/exceed this).
|
||||
- `PERSISTENT_TOKENS_KEY` for “Remember Me” tokens.
|
||||
|
||||
**Share link base URL**
|
||||
|
||||
- Set **`SHARE_URL`** via web-server env vars (preferred),
|
||||
**or** keep using `BASE_URL` in `config.php` as a fallback.
|
||||
- If neither is set, FileRise auto-detects from the current host/scheme.
|
||||
|
||||
**Web server config**
|
||||
|
||||
- Apache: allow `.htaccess` or merge its rules; ensure `mod_rewrite` is enabled.
|
||||
- Nginx/other: replicate basic protections (no directory listing, deny sensitive files). See Wiki for examples.
|
||||
|
||||
Browse to your FileRise URL; you’ll be prompted to create the Admin user on first load.
|
||||
|
||||
---
|
||||
|
||||
### 3) Admins
|
||||
|
||||
> **Admins in ACL UI**
|
||||
> Admin accounts appear in the Folder Access and User Permissions modals as **read-only** with full access implied. This is by design—admins always have full control and are excluded from save payloads.
|
||||
|
||||
---
|
||||
|
||||
## Unraid
|
||||
|
||||
- Install from **Community Apps** → search **FileRise**.
|
||||
- Default **bridge**: access at `http://SERVER_IP:8080/`.
|
||||
- **Custom br0** (own IP): map host ports to **80/443** if you want bare `http://CONTAINER_IP/` without a port.
|
||||
- See the [support thread](https://forums.unraid.net/topic/187337-support-filerise/) for Unraid-specific help.
|
||||
|
||||
---
|
||||
|
||||
## Upgrade
|
||||
|
||||
```bash
|
||||
docker pull error311/filerise-docker:latest
|
||||
docker stop filerise && docker rm filerise
|
||||
# re-run with the same -v and -e flags you used originally
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick-start: Mount via WebDAV
|
||||
|
||||
Once FileRise is running, enable WebDAV in the admin panel.
|
||||
|
||||
```bash
|
||||
# Linux (GVFS/GIO)
|
||||
gio mount dav://demo@your-host/webdav.php/
|
||||
|
||||
# macOS (Finder → Go → Connect to Server…)
|
||||
https://your-host/webdav.php/
|
||||
```
|
||||
|
||||
> Finder typically uses `https://` (or `http://`) URLs for WebDAV, while GNOME/KDE use `dav://` / `davs://`.
|
||||
|
||||
### Windows (File Explorer)
|
||||
|
||||
- Open **File Explorer** → Right-click **This PC** → **Map network drive…**
|
||||
- Choose a drive letter (e.g., `Z:`).
|
||||
- In **Folder**, enter:
|
||||
|
||||
```text
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Quick start: ONLYOFFICE (optional)
|
||||
|
||||
FileRise can open & edit office docs using your **self-hosted ONLYOFFICE Document Server**.
|
||||
|
||||
**What you need**
|
||||
|
||||
- A reachable ONLYOFFICE Document Server (Community/Enterprise).
|
||||
- A shared **JWT secret** used by FileRise and your Document Server.
|
||||
|
||||
**Setup (2–3 minutes)**
|
||||
|
||||
1. In FileRise go to **Admin → ONLYOFFICE** and:
|
||||
- ✅ Enable ONLYOFFICE
|
||||
- 🔗 Set **Document Server Origin** (e.g., `https://docs.example.com`)
|
||||
- 🔑 Enter **JWT Secret** (click “Replace” to set)
|
||||
2. (Recommended) Click **Run tests** in the ONLYOFFICE card:
|
||||
- Checks FileRise status, callback reachability, `api.js` load, and iframe embed.
|
||||
3. Update your **Content-Security-Policy** to allow the DS origin.
|
||||
The Admin panel shows a ready-to-copy line for Apache & Nginx. Example:
|
||||
|
||||
**Apache**
|
||||
|
||||
```apache
|
||||
Header always set Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com"
|
||||
```bash
|
||||
git clone https://github.com/error311/FileRise.git
|
||||
```
|
||||
|
||||
**Nginx**
|
||||
2. Create data directories and set permissions:
|
||||
|
||||
```nginx
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self' 'sha256-ajmGY+5VJOY6+8JHgzCqsqI8w9dCQfAmqIkFesOKItM=' https://your-onlyoffice-server.example.com https://your-onlyoffice-server.example.com/web-apps/apps/api/documents/api.js; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' https://your-onlyoffice-server.example.com; media-src 'self' blob:; worker-src 'self' blob:; form-action 'self'; frame-src 'self' https://your-onlyoffice-server.example.com" always;
|
||||
```bash
|
||||
cd FileRise
|
||||
mkdir -p uploads users metadata
|
||||
chown -R www-data:www-data uploads users metadata # adjust for your web user
|
||||
chmod -R 775 uploads users metadata
|
||||
```
|
||||
|
||||
**Notes**
|
||||
- If your site is https://, your Document Server must also be https:// (or the browser will block it as mixed content).
|
||||
- Editor access respects FileRise ACLs (view/edit/share) exactly like the rest of the app.
|
||||
3. (Optional) Install PHP dependencies with Composer:
|
||||
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
|
||||
4. Configure PHP (upload limits / timeouts) and ensure rewrites are enabled.
|
||||
- Apache: allow `.htaccess` or copy its rules into your vhost.
|
||||
- Nginx/Caddy: mirror the basic protections (no directory listing, block sensitive files).
|
||||
|
||||
5. Browse to your FileRise URL and follow the **admin setup** screen.
|
||||
|
||||
For detailed examples and reverse proxy snippets, see the **Installation** page in the Wiki.
|
||||
|
||||
---
|
||||
|
||||
## FAQ / Troubleshooting
|
||||
## 4. WebDAV & ONLYOFFICE (optional)
|
||||
|
||||
- **ONLYOFFICE editor won’t load / blank frame:** Verify CSP allows your DS origin (`script-src`, `frame-src`, `connect-src`) and that the DS is reachable over HTTPS if your site is HTTPS.
|
||||
- **“Disabled — check JWT Secret / Origin” in tests:** In **Admin → ONLYOFFICE**, set the Document Server Origin and click “Replace” to save a JWT secret. Then re-run tests.
|
||||
### WebDAV
|
||||
|
||||
- **“Upload failed” or large files not uploading:** Ensure `TOTAL_UPLOAD_SIZE` in config and PHP’s `post_max_size` / `upload_max_filesize` are set high enough. For extremely large files, you might need to increase `max_execution_time` or rely on resumable uploads in smaller chunks.
|
||||
Once enabled in the Admin panel, FileRise exposes a WebDAV endpoint (e.g. `/webdav.php`). Use it with:
|
||||
|
||||
- **How to enable HTTPS?** FileRise doesn’t terminate TLS itself. Run it behind a reverse proxy (Nginx, Caddy, Apache with SSL) or use a companion like nginx-proxy or Caddy in Docker. Set `SECURE="true"` in Docker so FileRise generates HTTPS links.
|
||||
- **macOS Finder** – Go → Connect to Server → `https://your-host/webdav.php/`
|
||||
- **Windows File Explorer** – Map Network Drive → `https://your-host/webdav.php/`
|
||||
- **Linux (GVFS/Nautilus)** – `dav://your-host/webdav.php/`
|
||||
- Clients like **Cyberduck**, **WinSCP**, etc.
|
||||
|
||||
- **Changing Admin or resetting password:** Admin can change any user’s password via **User Management**. If you lose admin access, edit the `users/users.txt` file on the server – passwords are hashed (bcrypt), but you can delete the admin line and restart the app to trigger the setup flow again.
|
||||
WebDAV operations honor the same ACLs as the web UI.
|
||||
|
||||
- **Where are my files stored?** In the `uploads/` directory (or the path you set). Deleted files move to `uploads/trash/`. Tag information is in `metadata/file_metadata.json` and trash metadata in `metadata/trash.json`, etc. Backups are recommended.
|
||||
|
||||
- **Updating FileRise:** For Docker, pull the new image and recreate the container. For manual installs, download the latest release and replace files (keep your `config.php` and `uploads/users/metadata`). Clear your browser cache if UI assets changed.
|
||||
|
||||
For more Q&A or to ask for help, open a Discussion or Issue.
|
||||
|
||||
---
|
||||
|
||||
## Security posture
|
||||
|
||||
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 you’re running ≤1.4.x, please upgrade.
|
||||
|
||||
See also: [SECURITY.md](./SECURITY.md) for how to report vulnerabilities.
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
Areas to help: translations, bug fixes, UI polish, integrations.
|
||||
If you like FileRise, a ⭐ star on GitHub is much appreciated!
|
||||
|
||||
---
|
||||
|
||||
## 💖 Sponsor FileRise
|
||||
|
||||
If FileRise saves you time (or sparks joy 😄), please consider supporting ongoing development:
|
||||
|
||||
- ❤️ [**GitHub Sponsors:**](https://github.com/sponsors/error311) recurring or one-time - helps fund new features and docs.
|
||||
- ☕ [**Ko-fi:**](https://ko-fi.com/error311) buy me a coffee.
|
||||
|
||||
Every bit helps me keep FileRise fast, polished, and well-maintained. Thank you!
|
||||
|
||||
---
|
||||
|
||||
## Community and Support
|
||||
|
||||
- **Reddit:** [r/selfhosted: FileRise Discussion](https://www.reddit.com/r/selfhosted/comments/1kfxo9y/filerise_v131_major_updates_sneak_peek_at_whats/) – (Announcement and user feedback thread).
|
||||
- **Unraid Forums:** [FileRise Support Thread](https://forums.unraid.net/topic/187337-support-filerise/) – for Unraid-specific support or issues.
|
||||
- **GitHub Discussions:** Use Q&A for setup questions, Ideas for enhancements.
|
||||
|
||||
[](https://star-history.com/#error311/FileRise&Date)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
See: [WebDAV](https://github.com/error311/FileRise/wiki/WebDAV)
|
||||
|
||||
### ONLYOFFICE integration
|
||||
|
||||
FileRise can open office documents using a self-hosted ONLYOFFICE Document Server.
|
||||
If you run an ONLYOFFICE Document Server you can open/edit Office documents directly from FileRise (DOCX, XLSX, PPTX, ODT, ODS, ODP; PDFs view‑only).
|
||||
|
||||
- **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.
|
||||
Configure it in **Admin → ONLYOFFICE**:
|
||||
|
||||
#### Security / CSP
|
||||
- Enable ONLYOFFICE
|
||||
- Set your Document Server origin (e.g. `https://docs.example.com`)
|
||||
- Configure a shared JWT secret
|
||||
- Copy the suggested Content‑Security‑Policy header into your reverse proxy
|
||||
|
||||
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)
|
||||
- **[phpseclib/phpseclib](https://github.com/phpseclib/phpseclib)** (v~3.0.7)
|
||||
- **[robthree/twofactorauth](https://github.com/RobThree/TwoFactorAuth)** (v^3.0)
|
||||
- **[endroid/qr-code](https://github.com/endroid/qr-code)** (v^5.0)
|
||||
- **[sabre/dav](https://github.com/sabre-io/dav)** (^4.4)
|
||||
|
||||
### Client-Side Libraries
|
||||
|
||||
- **Google Fonts** – [Roboto](https://fonts.google.com/specimen/Roboto) and **Material Icons** ([Google Material Icons](https://fonts.google.com/icons))
|
||||
- **[Bootstrap](https://getbootstrap.com/)** (v4.5.2)
|
||||
- **[CodeMirror](https://codemirror.net/)** (v5.65.5) – For code editing functionality.
|
||||
- **[Resumable.js](https://github.com/23/resumable.js/)** (v1.1.0) – For file uploads.
|
||||
- **[DOMPurify](https://github.com/cure53/DOMPurify)** (v2.4.0) – For sanitizing HTML.
|
||||
- **[Fuse.js](https://fusejs.io/)** (v6.6.2) – For indexed, fuzzy searching.
|
||||
Docs: [ONLYOFFICE](https://github.com/error311/FileRise/wiki/ONLYOFFICE)
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
## 5. Security & updates
|
||||
|
||||
- Based on [uploader](https://github.com/sensboston/uploader) by @sensboston.
|
||||
- FileRise is actively maintained and has published security advisories.
|
||||
- See **SECURITY.md** and GitHub Security Advisories for details.
|
||||
- To upgrade:
|
||||
- **Docker:** `docker pull error311/filerise-docker:latest` and recreate the container with the same volumes.
|
||||
- **Manual:** replace app files with the latest release (keep `uploads/`, `users/`, `metadata/`, and your config).
|
||||
|
||||
Please report vulnerabilities responsibly via the channels listed in **SECURITY.md**.
|
||||
|
||||
---
|
||||
|
||||
## License & Credits
|
||||
## 6. Community, support & contributing
|
||||
|
||||
MIT License – see [LICENSE](LICENSE).
|
||||
This project bundles third-party assets such as Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, and Google Fonts (Roboto, Material Icons).
|
||||
All third-party code and fonts remain under their original open-source licenses (MIT or Apache 2.0).
|
||||
- 🧵 **GitHub Discussions & Issues:** ask questions, report bugs, suggest features.
|
||||
- 💬 **Unraid forum thread:** for Unraid‑specific setup and tuning.
|
||||
- 🌍 **Reddit / self‑hosting communities:** occasional release posts & feedback threads.
|
||||
|
||||
See THIRD_PARTY.md and the /licenses directory for full license texts and attributions.
|
||||
Contributions are welcome — from bug fixes and docs to translations and UI polish.
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
If FileRise saves you time or becomes your daily driver, a ⭐ on GitHub or sponsorship is hugely appreciated:
|
||||
|
||||
- ❤️ [GitHub Sponsors](https://github.com/sponsors/error311)
|
||||
- ☕ [Ko‑fi](https://ko-fi.com/error311)
|
||||
|
||||
---
|
||||
|
||||
## 7. License & third‑party code
|
||||
|
||||
FileRise Core is released under the **MIT License** – see [LICENSE](LICENSE).
|
||||
|
||||
It bundles a small set of well‑known client and server libraries (Bootstrap, CodeMirror, DOMPurify, Fuse.js, Resumable.js, sabre/dav, etc.).
|
||||
All third‑party code remains under its original licenses.
|
||||
|
||||
See `THIRD_PARTY.md` and the `licenses/` folder for full details.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -234,4 +238,59 @@ if (strpos(BASE_URL, 'yourwebsite') !== false) {
|
||||
}
|
||||
|
||||
// Final: env var wins, else fallback
|
||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||
define('SHARE_URL', getenv('SHARE_URL') ?: $defaultShare);
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// FileRise Pro bootstrap wiring
|
||||
// ------------------------------------------------------------
|
||||
|
||||
// Inline license (optional; usually set via Admin UI and PRO_LICENSE_FILE)
|
||||
if (!defined('FR_PRO_LICENSE')) {
|
||||
$envLicense = getenv('FR_PRO_LICENSE');
|
||||
define('FR_PRO_LICENSE', $envLicense !== false ? trim((string)$envLicense) : '');
|
||||
}
|
||||
|
||||
// JSON license file used by AdminController::setLicense()
|
||||
if (!defined('PRO_LICENSE_FILE')) {
|
||||
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
|
||||
}
|
||||
|
||||
// Optional plain-text license file (used as fallback in bootstrap)
|
||||
if (!defined('FR_PRO_LICENSE_FILE')) {
|
||||
$lf = getenv('FR_PRO_LICENSE_FILE');
|
||||
if ($lf === false || $lf === '') {
|
||||
$lf = PROJECT_ROOT . '/users/proLicense.txt';
|
||||
}
|
||||
define('FR_PRO_LICENSE_FILE', $lf);
|
||||
}
|
||||
|
||||
// Where Pro code lives by default → inside users volume
|
||||
$proDir = getenv('FR_PRO_BUNDLE_DIR');
|
||||
if ($proDir === false || $proDir === '') {
|
||||
$proDir = PROJECT_ROOT . '/users/pro';
|
||||
}
|
||||
$proDir = rtrim($proDir, "/\\");
|
||||
if (!defined('FR_PRO_BUNDLE_DIR')) {
|
||||
define('FR_PRO_BUNDLE_DIR', $proDir);
|
||||
}
|
||||
|
||||
// Try to load Pro bootstrap if enabled + present
|
||||
$proBootstrap = FR_PRO_BUNDLE_DIR . '/bootstrap_pro.php';
|
||||
if (@is_file($proBootstrap)) {
|
||||
require_once $proBootstrap;
|
||||
}
|
||||
|
||||
// If bootstrap didn’t define these, give safe defaults
|
||||
if (!defined('FR_PRO_ACTIVE')) {
|
||||
define('FR_PRO_ACTIVE', false);
|
||||
}
|
||||
if (!defined('FR_PRO_INFO')) {
|
||||
define('FR_PRO_INFO', [
|
||||
'valid' => false,
|
||||
'error' => null,
|
||||
'payload' => null,
|
||||
]);
|
||||
}
|
||||
if (!defined('FR_PRO_BUNDLE_VERSION')) {
|
||||
define('FR_PRO_BUNDLE_VERSION', null);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
8
public/api/admin/installProBundle.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$controller = new AdminController();
|
||||
$controller->installProBundle();
|
||||
8
public/api/admin/setLicense.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once PROJECT_ROOT . '/src/controllers/AdminController.php';
|
||||
|
||||
$ctrl = new AdminController();
|
||||
$ctrl->setLicense();
|
||||
24
public/api/file/downloadZipFile.php
Normal 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();
|
||||
23
public/api/file/zipStatus.php
Normal 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();
|
||||
@@ -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);
|
||||
17
public/api/folder/getFolderColors.php
Normal 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']);
|
||||
}
|
||||
28
public/api/folder/isEmpty.php
Normal 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);
|
||||
31
public/api/folder/listChildren.php
Normal 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);
|
||||
17
public/api/folder/saveFolderColor.php
Normal 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']);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 17 KiB |
BIN
public/assets/logo-128.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
public/assets/logo-16.png
Normal file
|
After Width: | Height: | Size: 444 B |
BIN
public/assets/logo-192.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |
BIN
public/assets/logo-256.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
public/assets/logo-32.png
Normal file
|
After Width: | Height: | Size: 749 B |
BIN
public/assets/logo-48.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/assets/logo-64.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.5 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -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 can’t 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">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- we will MOVE #uploadCard into here while open -->
|
||||
<div id="uploadModalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,8 +4,80 @@ import { loadAdminConfigFunc } from './auth.js?v={{APP_QVER}}';
|
||||
import { showToast, toggleVisibility, attachEnterKeyListener } from './domUtils.js?v={{APP_QVER}}';
|
||||
import { sendRequest } from './networkUtils.js?v={{APP_QVER}}';
|
||||
|
||||
function normalizeLogoPath(raw) {
|
||||
if (!raw) return '';
|
||||
const parts = String(raw).split(':');
|
||||
let pic = parts[parts.length - 1];
|
||||
pic = pic.replace(/^:+/, '');
|
||||
if (pic && !pic.startsWith('/')) pic = '/' + pic;
|
||||
return pic;
|
||||
}
|
||||
|
||||
const version = window.APP_VERSION || "dev";
|
||||
const adminTitle = `${t("admin_panel")} <small style="font-size:12px;color:gray;">${version}</small>`;
|
||||
// Hard-coded *FOR NOW* latest FileRise Pro bundle version for UI hints only.
|
||||
// Update this when I cut a new Pro ZIP.
|
||||
const PRO_LATEST_BUNDLE_VERSION = 'v1.0.0';
|
||||
|
||||
function getAdminTitle(isPro, proVersion) {
|
||||
const corePill = `
|
||||
<span class="badge badge-pill badge-secondary admin-core-badge">
|
||||
Core ${version}
|
||||
</span>
|
||||
`;
|
||||
|
||||
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
|
||||
const norm = (v) => String(v || '').trim().replace(/^v/i, '');
|
||||
|
||||
const latestRaw = (typeof PRO_LATEST_BUNDLE_VERSION !== 'undefined'
|
||||
? PRO_LATEST_BUNDLE_VERSION
|
||||
: ''
|
||||
);
|
||||
|
||||
const currentRaw = (proVersion && proVersion !== 'not installed')
|
||||
? String(proVersion)
|
||||
: '';
|
||||
|
||||
const hasCurrent = !!norm(currentRaw);
|
||||
const hasLatest = !!norm(latestRaw);
|
||||
const hasUpdate = isPro && hasCurrent && hasLatest &&
|
||||
norm(currentRaw) !== norm(latestRaw);
|
||||
|
||||
if (!isPro) {
|
||||
// Free/core only
|
||||
return `
|
||||
${t("admin_panel")}
|
||||
${corePill}
|
||||
`;
|
||||
}
|
||||
|
||||
const pvLabel = hasCurrent ? `Pro v${norm(currentRaw)}` : 'Pro';
|
||||
|
||||
const proPill = `
|
||||
<span class="badge badge-pill badge-warning admin-pro-badge">
|
||||
${pvLabel}
|
||||
</span>
|
||||
`;
|
||||
|
||||
const updateHint = hasUpdate
|
||||
? `
|
||||
<a
|
||||
href="https://filerise.net/pro/update.php"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="badge badge-pill badge-warning admin-pro-badge"
|
||||
style="cursor:pointer; text-decoration:none; margin-left:4px;">
|
||||
Pro update available
|
||||
</a>
|
||||
`
|
||||
: '';
|
||||
|
||||
return `
|
||||
${t("admin_panel")}
|
||||
${corePill}
|
||||
${proPill}
|
||||
${updateHint}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
function buildFullGrantsForAllFolders(folders) {
|
||||
@@ -16,6 +88,57 @@ function buildFullGrantsForAllFolders(folders) {
|
||||
};
|
||||
return folders.reduce((acc, f) => { acc[f] = { ...allTrue }; return acc; }, {});
|
||||
}
|
||||
function applyHeaderColorsFromAdmin() {
|
||||
try {
|
||||
const lightInput = document.getElementById('brandingHeaderBgLight');
|
||||
const darkInput = document.getElementById('brandingHeaderBgDark');
|
||||
const root = document.documentElement;
|
||||
|
||||
const light = lightInput ? lightInput.value.trim() : '';
|
||||
const dark = darkInput ? darkInput.value.trim() : '';
|
||||
|
||||
if (light) root.style.setProperty('--header-bg-light', light);
|
||||
else root.style.removeProperty('--header-bg-light');
|
||||
|
||||
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||
else root.style.removeProperty('--header-bg-dark');
|
||||
} catch (e) {
|
||||
console.warn('Failed to live-update header colors from admin panel', e);
|
||||
}
|
||||
}
|
||||
function updateHeaderLogoFromAdmin() {
|
||||
try {
|
||||
const input = document.getElementById('brandingCustomLogoUrl');
|
||||
const logoImg = document.querySelector('.header-logo img');
|
||||
if (!logoImg) return;
|
||||
|
||||
let url = (input && input.value.trim()) || '';
|
||||
|
||||
// If they used a bare "uploads/..." path, normalize to "/uploads/..."
|
||||
if (url && !url.startsWith('/') && url.startsWith('uploads/')) {
|
||||
url = '/' + url;
|
||||
}
|
||||
|
||||
// ---- Sanitize URL (mirror AdminModel::sanitizeLogoUrl) ----
|
||||
const isHttp = /^https?:\/\//i.test(url);
|
||||
const isSiteRelative = url.startsWith('/') && !url.includes('://');
|
||||
|
||||
// Strip any CR/LF just in case
|
||||
url = url.replace(/[\r\n]+/g, '');
|
||||
|
||||
if (url && (isHttp || isSiteRelative)) {
|
||||
// safe enough for <img src="...">
|
||||
logoImg.setAttribute('src', url);
|
||||
logoImg.setAttribute('alt', 'Site logo');
|
||||
} else {
|
||||
// fall back to default FileRise logo
|
||||
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||
logoImg.setAttribute('alt', 'FileRise');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to live-update header logo from admin panel', e);
|
||||
}
|
||||
}
|
||||
|
||||
/* === BEGIN: Folder Access helpers (merged + improved) === */
|
||||
function qs(scope, sel) { return (scope || document).querySelector(sel); }
|
||||
@@ -58,7 +181,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>`
|
||||
: '';
|
||||
@@ -175,7 +298,7 @@ async function safeJson(res) {
|
||||
.dark-mode .form-control::placeholder { color:#888; }
|
||||
|
||||
.section-header {
|
||||
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:4px; font-weight:bold;
|
||||
background:#f5f5f5; padding:10px 15px; cursor:pointer; border-radius:12px; font-weight:bold;
|
||||
display:flex; align-items:center; justify-content:space-between; margin-top:16px;
|
||||
}
|
||||
.section-header:first-of-type { margin-top:0; }
|
||||
@@ -301,7 +424,10 @@ function captureInitialAdminConfig() {
|
||||
disableOIDCLogin: !!document.getElementById("disableOIDCLogin")?.checked,
|
||||
enableWebDAV: !!document.getElementById("enableWebDAV")?.checked,
|
||||
sharedMaxUploadSize: (document.getElementById("sharedMaxUploadSize")?.value || "").trim(),
|
||||
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim()
|
||||
globalOtpauthUrl: (document.getElementById("globalOtpauthUrl")?.value || "").trim(),
|
||||
brandingCustomLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
|
||||
brandingHeaderBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
|
||||
brandingHeaderBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
|
||||
};
|
||||
}
|
||||
function hasUnsavedChanges() {
|
||||
@@ -319,7 +445,10 @@ function hasUnsavedChanges() {
|
||||
getChk("disableOIDCLogin") !== o.disableOIDCLogin ||
|
||||
getChk("enableWebDAV") !== o.enableWebDAV ||
|
||||
getVal("sharedMaxUploadSize") !== o.sharedMaxUploadSize ||
|
||||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl
|
||||
getVal("globalOtpauthUrl") !== o.globalOtpauthUrl ||
|
||||
getVal("brandingCustomLogoUrl") !== (o.brandingCustomLogoUrl || "") ||
|
||||
getVal("brandingHeaderBgLight") !== (o.brandingHeaderBgLight || "") ||
|
||||
getVal("brandingHeaderBgDark") !== (o.brandingHeaderBgDark || "")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -355,6 +484,81 @@ function toggleSection(id) {
|
||||
}
|
||||
}
|
||||
|
||||
export function initProBundleInstaller() {
|
||||
try {
|
||||
const fileInput = document.getElementById('proBundleFile');
|
||||
const btn = document.getElementById('btnInstallProBundle');
|
||||
const statusEl = document.getElementById('proBundleStatus');
|
||||
|
||||
if (!fileInput || !btn || !statusEl) return;
|
||||
|
||||
// Allow names like: FileRisePro_v1.0.0.zip or FileRisePro-1.0.0.zip
|
||||
const PRO_ZIP_NAME_RE = /^FileRisePro[_-]v?[0-9]+\.[0-9]+\.[0-9]+\.zip$/i;
|
||||
|
||||
btn.addEventListener('click', async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
|
||||
if (!file) {
|
||||
statusEl.textContent = 'Choose a FileRise Pro .zip bundle first.';
|
||||
statusEl.className = 'small text-danger';
|
||||
return;
|
||||
}
|
||||
|
||||
const name = file.name || '';
|
||||
if (!PRO_ZIP_NAME_RE.test(name)) {
|
||||
statusEl.textContent = 'Bundle must be named like "FileRisePro_v1.0.0.zip".';
|
||||
statusEl.className = 'small text-danger';
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('bundle', file);
|
||||
|
||||
statusEl.textContent = 'Uploading and installing Pro bundle...';
|
||||
statusEl.className = 'small text-muted';
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/admin/installProBundle.php', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRF-Token': window.csrfToken || ''
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await resp.json();
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors; handled below
|
||||
}
|
||||
|
||||
if (!resp.ok || !data || !data.success) {
|
||||
const msg = data && data.error
|
||||
? data.error
|
||||
: `HTTP ${resp.status}`;
|
||||
statusEl.textContent = 'Install failed: ' + msg;
|
||||
statusEl.className = 'small text-danger';
|
||||
return;
|
||||
}
|
||||
|
||||
const versionText = data.proVersion ? ` (version ${data.proVersion})` : '';
|
||||
statusEl.textContent = 'Pro bundle installed' + versionText + '. Reload the page to apply changes.';
|
||||
statusEl.className = 'small text-success';
|
||||
|
||||
if (typeof loadAdminConfigFunc === 'function') {
|
||||
loadAdminConfigFunc();
|
||||
}
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Install failed: ' + (e && e.message ? e.message : String(e));
|
||||
statusEl.className = 'small text-danger';
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to init Pro bundle installer', e);
|
||||
}
|
||||
}
|
||||
|
||||
function loadShareLinksSection() {
|
||||
const container = document.getElementById("shareLinksContent");
|
||||
if (!container) return;
|
||||
@@ -460,12 +664,22 @@ export function openAdminPanel() {
|
||||
if (config.globalOtpauthUrl) window.currentOIDCConfig.globalOtpauthUrl = config.globalOtpauthUrl;
|
||||
|
||||
const dark = document.body.classList.contains("dark-mode");
|
||||
const proInfo = config.pro || {};
|
||||
const isPro = !!proInfo.active;
|
||||
const proType = proInfo.type || '';
|
||||
const proEmail = proInfo.email || '';
|
||||
const proVersion = proInfo.version || 'not installed';
|
||||
const proLicense = proInfo.license || '';
|
||||
const brandingCfg = config.branding || {};
|
||||
const brandingCustomLogoUrl = brandingCfg.customLogoUrl || "";
|
||||
const brandingHeaderBgLight = brandingCfg.headerBgLight || "";
|
||||
const brandingHeaderBgDark = brandingCfg.headerBgDark || "";
|
||||
const bg = dark ? "rgba(0,0,0,0.7)" : "rgba(0,0,0,0.3)";
|
||||
const inner = `
|
||||
background:${dark ? "#2c2c2c" : "#fff"};
|
||||
color:${dark ? "#e0e0e0" : "#000"};
|
||||
padding:20px; max-width:1100px; width:50%;
|
||||
border-radius:8px; position:relative;
|
||||
position:relative;
|
||||
max-height:90vh; overflow:auto;
|
||||
border:1px solid ${dark ? "#555" : "#ccc"};
|
||||
`;
|
||||
@@ -484,7 +698,7 @@ export function openAdminPanel() {
|
||||
mdl.innerHTML = `
|
||||
<div class="modal-content" style="${inner}">
|
||||
<div class="editor-close-btn" id="closeAdminPanel">×</div>
|
||||
<h3>${adminTitle}</h3>
|
||||
<h3>${getAdminTitle(isPro, proVersion)}</h3>
|
||||
<form id="adminPanelForm">
|
||||
${[
|
||||
{ id: "userManagement", label: t("user_management") },
|
||||
@@ -495,6 +709,7 @@ export function openAdminPanel() {
|
||||
{ id: "upload", label: t("shared_max_upload_size_bytes_title") },
|
||||
{ id: "oidc", label: t("oidc_configuration") + " & TOTP" },
|
||||
{ id: "shareLinks", label: t("manage_shared_links") },
|
||||
{ id: "pro", label: "FileRise Pro" },
|
||||
{ id: "sponsor", label: (typeof tf === 'function' ? tf("sponsor_donations", "Sponsor / Donations") : "Sponsor / Donations") }
|
||||
].map(sec => `
|
||||
<div id="${sec.id}Header" class="section-header collapsed">
|
||||
@@ -515,18 +730,100 @@ export function openAdminPanel() {
|
||||
document.getElementById("closeAdminPanel").addEventListener("click", closeAdminPanel);
|
||||
document.getElementById("cancelAdminSettings").addEventListener("click", closeAdminPanel);
|
||||
|
||||
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "sponsor"]
|
||||
["userManagement", "headerSettings", "loginOptions", "webdav", "onlyoffice", "upload", "oidc", "shareLinks", "pro", "sponsor"]
|
||||
.forEach(id => {
|
||||
document.getElementById(id + "Header")
|
||||
.addEventListener("click", () => toggleSection(id));
|
||||
});
|
||||
|
||||
document.getElementById("userManagementContent").innerHTML = `
|
||||
<button type="button" id="adminOpenAddUser" class="btn btn-success me-2">${t("add_user")}</button>
|
||||
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger me-2">${t("remove_user")}</button>
|
||||
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary">${tf("folder_access", "Folder Access")}</button>
|
||||
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary">${tf("user_permissions", "User Permissions")}</button>
|
||||
`;
|
||||
document.getElementById("userManagementContent").innerHTML = `
|
||||
<div class="admin-user-actions">
|
||||
<!-- Core buttons -->
|
||||
<button type="button" id="adminOpenAddUser" class="btn btn-success btn-sm">
|
||||
<i class="material-icons">person_add</i>
|
||||
<span>${t("add_user")}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" id="adminOpenRemoveUser" class="btn btn-danger btn-sm">
|
||||
<i class="material-icons">person_remove</i>
|
||||
<span>${t("remove_user")}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" id="adminOpenUserPermissions" class="btn btn-secondary btn-sm">
|
||||
<i class="material-icons">folder_shared</i>
|
||||
<span>${tf("folder_access", "Folder Access")}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" id="adminOpenUserFlags" class="btn btn-secondary btn-sm">
|
||||
<i class="material-icons">tune</i>
|
||||
<span>${tf("user_permissions", "User Permissions")}</span>
|
||||
</button>
|
||||
|
||||
<!-- Pro-only: User groups -->
|
||||
${
|
||||
isPro
|
||||
? `
|
||||
<div class="btn-pro-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
id="adminOpenUserGroups"
|
||||
class="btn btn-sm btn-pro-admin">
|
||||
<i class="material-icons">groups</i>
|
||||
<span>User groups</span>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div class="btn-pro-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
id="adminOpenUserGroups"
|
||||
class="btn btn-sm btn-pro-admin">
|
||||
<i class="material-icons">groups</i>
|
||||
<span>User groups</span>
|
||||
</button>
|
||||
<span class="btn-pro-pill">Pro · Coming soon</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
<!-- Pro roadmap: Client portal -->
|
||||
${
|
||||
isPro
|
||||
? `
|
||||
<div class="btn-pro-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
id="adminOpenClientPortal"
|
||||
class="btn btn-sm btn-pro-admin"
|
||||
title="Client upload portals are part of FileRise Pro.">
|
||||
<i class="material-icons">cloud_upload</i>
|
||||
<span>Client upload portal</span>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: `
|
||||
<div class="btn-pro-wrapper">
|
||||
<button
|
||||
type="button"
|
||||
id="adminOpenClientPortal"
|
||||
class="btn btn-sm btn-pro-admin"
|
||||
disabled
|
||||
title="Planned FileRise Pro feature: client upload portals">
|
||||
<i class="material-icons">cloud_upload</i>
|
||||
<span>Client upload portal</span>
|
||||
</button>
|
||||
<span class="btn-pro-pill">Pro · Coming soon</span>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
|
||||
<small class="text-muted d-block" style="margin-top:6px;">
|
||||
Use the core tools to manage users and per-folder access.
|
||||
User groups and Client upload portals are planned FileRise Pro features.
|
||||
</small>
|
||||
`;
|
||||
|
||||
document.getElementById("adminOpenAddUser")
|
||||
.addEventListener("click", () => {
|
||||
@@ -541,13 +838,174 @@ export function openAdminPanel() {
|
||||
document.getElementById("adminOpenUserPermissions")
|
||||
.addEventListener("click", openUserPermissionsModal);
|
||||
|
||||
document.getElementById("headerSettingsContent").innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="headerTitle">${t("header_title_text")}:</label>
|
||||
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
|
||||
</div>
|
||||
`;
|
||||
wireHeaderTitleLive();
|
||||
// Pro-only stubs for future features
|
||||
const regBtn = document.getElementById("adminOpenUserRegistration");
|
||||
const groupsBtn = document.getElementById("adminOpenUserGroups");
|
||||
const clientBtn = document.getElementById("adminOpenClientPortal");
|
||||
|
||||
if (regBtn) {
|
||||
regBtn.addEventListener("click", () => {
|
||||
if (!isPro) {
|
||||
showToast("User registration is a FileRise Pro feature. Visit filerise.net to purchase a license.");
|
||||
window.open("https://filerise.net", "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// Placeholder for future Pro UI:
|
||||
showToast("User registration management is coming soon in FileRise Pro.");
|
||||
});
|
||||
}
|
||||
|
||||
if (groupsBtn) {
|
||||
groupsBtn.addEventListener("click", () => {
|
||||
if (!isPro) {
|
||||
showToast("User groups are a FileRise Pro feature. Visit filerise.net to purchase a license.");
|
||||
window.open("https://filerise.net", "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// Placeholder for future Pro UI:
|
||||
showToast("User groups management is coming soon in FileRise Pro.");
|
||||
});
|
||||
}
|
||||
|
||||
if (clientBtn) {
|
||||
clientBtn.addEventListener("click", () => {
|
||||
if (!isPro) {
|
||||
showToast("Client portal uploads are a FileRise Pro feature. Visit filerise.net to purchase a license.");
|
||||
window.open("https://filerise.net", "_blank", "noopener");
|
||||
return;
|
||||
}
|
||||
// Placeholder for future Pro UI:
|
||||
showToast("Client portal uploads are coming soon in FileRise Pro.");
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("headerSettingsContent").innerHTML = `
|
||||
<div class="form-group">
|
||||
<label for="headerTitle">${t("header_title_text")}:</label>
|
||||
<input type="text" id="headerTitle" class="form-control" value="${window.headerTitle || ""}" />
|
||||
</div>
|
||||
|
||||
<!-- Pro: Logo -->
|
||||
<div class="form-group" style="margin-top:16px;">
|
||||
<label for="brandingCustomLogoUrl">
|
||||
Header Logo
|
||||
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
|
||||
</label>
|
||||
<small class="text-muted d-block mb-1">
|
||||
${isPro
|
||||
? 'Upload a logo image or paste a local path.'
|
||||
: 'Requires FileRise Pro to enable custom header branding.'}
|
||||
</small>
|
||||
|
||||
<div class="input-group mb-2">
|
||||
<input
|
||||
type="text"
|
||||
id="brandingCustomLogoUrl"
|
||||
class="form-control"
|
||||
placeholder="/uploads/profile_pics/logo.png"
|
||||
value="${isPro ? (brandingCustomLogoUrl.replace(/"/g, '"')) : ''}"
|
||||
${!isPro ? 'disabled data-disabled-reason="pro"' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="file"
|
||||
id="brandingLogoFile"
|
||||
class="form-control"
|
||||
accept="image/*"
|
||||
${!isPro ? 'disabled' : ''}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-secondary"
|
||||
id="brandingUploadBtn"
|
||||
${!isPro ? 'disabled' : ''}>
|
||||
Upload logo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pro: Header colors -->
|
||||
<div class="form-group" style="margin-top:16px;">
|
||||
<label>
|
||||
Header Colors
|
||||
${!isPro ? '<span class="badge badge-pill badge-warning admin-pro-badge" style="margin-left:6px;">Pro</span>' : ''}
|
||||
</label>
|
||||
<div class="d-flex align-items-center" style="gap: 12px; flex-wrap: wrap;">
|
||||
<div>
|
||||
<label for="brandingHeaderBgLight" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Light mode</label>
|
||||
<input
|
||||
type="color"
|
||||
id="brandingHeaderBgLight"
|
||||
value="${brandingHeaderBgLight || '#2196F3'}"
|
||||
${!isPro ? 'disabled' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="brandingHeaderBgDark" class="d-block" style="font-size: 12px; margin-bottom: 4px;">Dark mode</label>
|
||||
<input
|
||||
type="color"
|
||||
id="brandingHeaderBgDark"
|
||||
value="${brandingHeaderBgDark || '#1f1f1f'}"
|
||||
${!isPro ? 'disabled' : ''}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block mt-1">
|
||||
${isPro
|
||||
? 'If left empty, FileRise uses its default blue and dark header colors.'
|
||||
: 'Requires FileRise Pro to enable custom color branding.'}
|
||||
|
||||
</small>
|
||||
</div>
|
||||
`;
|
||||
wireHeaderTitleLive();
|
||||
|
||||
// Upload logo -> reuse profile picture endpoint, then fill the logo path
|
||||
if (isPro) {
|
||||
const fileInput = document.getElementById('brandingLogoFile');
|
||||
const uploadBtn = document.getElementById('brandingUploadBtn');
|
||||
const urlInput = document.getElementById('brandingCustomLogoUrl');
|
||||
|
||||
if (fileInput && uploadBtn && urlInput) {
|
||||
uploadBtn.addEventListener('click', async () => {
|
||||
const f = fileInput.files && fileInput.files[0];
|
||||
if (!f) {
|
||||
showToast('Please choose an image first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('brand_logo', f); // <- must match PHP field
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/pro/uploadBrandLogo.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'X-CSRF-Token': window.csrfToken },
|
||||
body: fd
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let js = {};
|
||||
try { js = JSON.parse(text || '{}'); } catch (e) { js = {}; }
|
||||
|
||||
if (!res.ok || !js.url) {
|
||||
showToast(js.error || 'Error uploading logo');
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeLogoPath(js.url); // your helper
|
||||
urlInput.value = normalized;
|
||||
showToast('Logo uploaded. Don\'t forget to Save settings.');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Error uploading logo');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("loginOptionsContent").innerHTML = `
|
||||
<div class="form-group"><input type="checkbox" id="disableFormLogin" /> <label for="disableFormLogin">${t("disable_login_form")}</label></div>
|
||||
@@ -912,6 +1370,267 @@ async function ooProbeFrame(docsOrigin, timeoutMs = 4000) {
|
||||
|
||||
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
|
||||
|
||||
document.getElementById("shareLinksContent").textContent = t("loading") + "…";
|
||||
|
||||
// --- FileRise Pro / License section ---
|
||||
const proContent = document.getElementById("proContent");
|
||||
if (proContent) {
|
||||
// Normalize versions so "v1.0.1" and "1.0.1" compare cleanly
|
||||
const norm = (v) => (String(v || '').trim().replace(/^v/i, ''));
|
||||
|
||||
const currentVersionRaw = (proVersion && proVersion !== 'not installed') ? String(proVersion) : '';
|
||||
const latestVersionRaw = PRO_LATEST_BUNDLE_VERSION || '';
|
||||
const hasCurrent = !!norm(currentVersionRaw);
|
||||
const hasLatest = !!norm(latestVersionRaw);
|
||||
const hasUpdate = hasCurrent && hasLatest && norm(currentVersionRaw) !== norm(latestVersionRaw);
|
||||
|
||||
const proMetaHtml =
|
||||
isPro && (proType || proEmail || proVersion)
|
||||
? `
|
||||
<div class="pro-license-meta" style="margin-top:8px;font-size:12px;color:#777;">
|
||||
<div>
|
||||
✅ ${proType ? `License type: ${proType}` : 'License active'}
|
||||
${proType && proEmail ? ' • ' : ''}
|
||||
${proEmail ? `Licensed to: ${proEmail}` : ''}
|
||||
</div>
|
||||
${hasCurrent ? `
|
||||
<div>
|
||||
Installed Pro bundle: v${norm(currentVersionRaw)}
|
||||
</div>` : ''}
|
||||
${hasLatest ? `
|
||||
<div>
|
||||
Latest Pro bundle (UI hint): ${latestVersionRaw}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
|
||||
proContent.innerHTML = `
|
||||
<div class="card pro-card" style="padding:12px; border:1px solid #ddd; border-radius:12px; max-width:620px; margin:8px auto;">
|
||||
<div>
|
||||
<!-- Title row with pill aligned to "FileRise Pro" -->
|
||||
<div class="d-flex align-items-center" style="gap:8px;">
|
||||
<strong>FileRise Pro</strong>
|
||||
<span class="badge badge-pill ${isPro ? 'badge-success' : 'badge-secondary'} admin-pro-badge">
|
||||
${isPro ? 'Active' : 'Free'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Subtitle + meta under the title -->
|
||||
<div style="font-size:12px; color:#777; margin-top:2px;">
|
||||
${isPro
|
||||
? 'Pro features are currently enabled on this instance.'
|
||||
: 'You are running the free edition. Enter a license key to activate FileRise Pro.'}
|
||||
</div>
|
||||
${proMetaHtml}
|
||||
</div>
|
||||
|
||||
${isPro ? `
|
||||
<div style="margin-top:8px;">
|
||||
<a
|
||||
href="https://filerise.net/pro/update.php"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-sm btn-pro-admin d-inline-flex align-items-center"
|
||||
>
|
||||
<span>Download latest Pro bundle</span>
|
||||
${hasUpdate ? `
|
||||
<span class="badge badge-light" style="margin-left:6px;">
|
||||
Update available
|
||||
</span>` : ''}
|
||||
</a>
|
||||
<small class="text-muted d-block" style="margin-top:4px;">
|
||||
Opens filerise.net in a new tab where you can enter your Pro license
|
||||
to download the latest FileRise Pro ZIP.
|
||||
</small>
|
||||
</div>
|
||||
` : `
|
||||
<div style="margin-top:8px;">
|
||||
<a
|
||||
href="https://filerise.net/pro/checkout.php"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-sm btn-pro-admin"
|
||||
>
|
||||
Buy FileRise Pro
|
||||
</a>
|
||||
<small class="text-muted d-block" style="margin-top:4px;">
|
||||
Opens filerise.net in a new tab so you can purchase a FileRise Pro license.
|
||||
</small>
|
||||
</div>
|
||||
`}
|
||||
|
||||
<div class="form-group" style="margin-top:10px;">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<label for="proLicenseInput" style="font-size:12px; margin-bottom:0;">License key</label>
|
||||
${isPro && proLicense ? `
|
||||
<button type="button"
|
||||
class="btn btn-link btn-sm p-0"
|
||||
id="proCopyLicenseBtn">
|
||||
Copy current license
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
<textarea
|
||||
id="proLicenseInput"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Paste your FileRise Pro license key here..."></textarea>
|
||||
<small class="text-muted">
|
||||
You can purchase a license at
|
||||
<a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top:6px;">
|
||||
<label style="font-size:12px;">Or upload license file</label>
|
||||
<input
|
||||
type="file"
|
||||
id="proLicenseFile"
|
||||
class="form-control-file"
|
||||
accept=".lic,.json,.txt,.filerise-lic"
|
||||
/>
|
||||
<small class="text-muted">
|
||||
Supported: FileRise.lic, plain text with FRP1... or JSON containing a <code>license</code> field.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-primary btn-sm" id="proSaveLicenseBtn" style="margin-top:8px;">
|
||||
Save license
|
||||
</button>
|
||||
|
||||
<div class="mt-3 border-top pt-3" style="margin-top:14px;">
|
||||
<h6 class="mb-1">Install / update Pro bundle</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
Upload the <code>.zip</code> bundle you downloaded from <a href="https://filerise.net" target="_blank" rel="noopener noreferrer">filerise.net</a>.
|
||||
This runs locally on your server and never contacts an external update service.
|
||||
</p>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2" style="margin-top:4px;">
|
||||
<input type="file"
|
||||
id="proBundleFile"
|
||||
accept=".zip"
|
||||
class="form-control-file mb-2 mb-sm-0" />
|
||||
<button type="button"
|
||||
id="btnInstallProBundle"
|
||||
class="btn btn-sm btn-pro-admin">
|
||||
Install Pro bundle
|
||||
</button>
|
||||
</div>
|
||||
<div id="proBundleStatus" class="small mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire up local Pro bundle installer (upload .zip into core)
|
||||
initProBundleInstaller();
|
||||
|
||||
// Pre-fill textarea with saved license if present
|
||||
const licenseTextarea = document.getElementById('proLicenseInput');
|
||||
if (licenseTextarea && proLicense) {
|
||||
licenseTextarea.value = proLicense;
|
||||
}
|
||||
|
||||
// Auto-load license when a file is selected
|
||||
const fileInput = document.getElementById('proLicenseFile');
|
||||
if (fileInput && licenseTextarea) {
|
||||
fileInput.addEventListener('change', () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
let raw = String(e.target.result || '').trim();
|
||||
let license = raw;
|
||||
|
||||
try {
|
||||
const js = JSON.parse(raw);
|
||||
if (js && typeof js.license === 'string') {
|
||||
license = js.license.trim();
|
||||
}
|
||||
} catch (_) {
|
||||
// not JSON, treat as plain text
|
||||
}
|
||||
|
||||
if (!license || !license.startsWith('FRP1.')) {
|
||||
showToast('Could not find a valid FRP1 license in that file.');
|
||||
return;
|
||||
}
|
||||
|
||||
licenseTextarea.value = license;
|
||||
showToast('License loaded from file. Click "Save license" to apply.');
|
||||
};
|
||||
|
||||
reader.onerror = () => {
|
||||
showToast('Error reading license file.');
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy current license button (now inline next to the label)
|
||||
const proCopyBtn = document.getElementById('proCopyLicenseBtn');
|
||||
if (proCopyBtn && proLicense) {
|
||||
proCopyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(proLicense);
|
||||
} else {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = proLicense;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.left = '-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
}
|
||||
showToast('License copied to clipboard.');
|
||||
} catch {
|
||||
showToast('Could not copy license. Please copy it manually.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Save license handler (unchanged)
|
||||
const proSaveBtn = document.getElementById('proSaveLicenseBtn');
|
||||
if (proSaveBtn) {
|
||||
proSaveBtn.addEventListener('click', async () => {
|
||||
const ta = document.getElementById('proLicenseInput');
|
||||
const license = (ta && ta.value.trim()) || '';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/setLicense.php', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]')?.content || '')
|
||||
},
|
||||
body: JSON.stringify({ license }),
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
let data = {};
|
||||
try { data = JSON.parse(text || '{}'); } catch (e) { data = {}; }
|
||||
|
||||
if (!res.ok || !data.success) {
|
||||
console.error('setLicense error:', res.status, text);
|
||||
showToast(data.error || 'Error saving license');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('License saved. Reloading…');
|
||||
window.location.reload();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast('Error saving license');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
// --- end FileRise Pro section ---
|
||||
|
||||
document.getElementById("saveAdminSettings")
|
||||
.addEventListener("click", handleSave);
|
||||
["disableFormLogin", "disableBasicAuth", "disableOIDCLogin"].forEach(id => {
|
||||
@@ -1065,16 +1784,25 @@ function handleSave() {
|
||||
// clientId/clientSecret: only include when replacing
|
||||
},
|
||||
globalOtpauthUrl: document.getElementById("globalOtpauthUrl").value.trim(),
|
||||
branding: {
|
||||
customLogoUrl: (document.getElementById("brandingCustomLogoUrl")?.value || "").trim(),
|
||||
headerBgLight: (document.getElementById("brandingHeaderBgLight")?.value || "").trim(),
|
||||
headerBgDark: (document.getElementById("brandingHeaderBgDark")?.value || "").trim(),
|
||||
},
|
||||
};
|
||||
|
||||
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");
|
||||
@@ -1115,6 +1843,8 @@ function handleSave() {
|
||||
if (j.error) { showToast('Error: ' + j.error); return; }
|
||||
showToast('Settings saved.');
|
||||
closeAdminPanel();
|
||||
applyHeaderColorsFromAdmin();
|
||||
updateHeaderLogoFromAdmin();
|
||||
})
|
||||
.catch(() => showToast('Save failed.'));
|
||||
}
|
||||
@@ -1521,7 +2251,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 +2379,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"
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 name‐input modal, show the spinner modal
|
||||
// b) Hide the name‐input 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 browser’s 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 0–98% 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;
|
||||
@@ -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 row’s 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).');
|
||||
}
|
||||
}
|
||||
@@ -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"}">×</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();
|
||||
|
||||
// We’ll 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 there’s 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"}">×</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 => {
|
||||
|
||||
@@ -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 can’t 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 });
|
||||
}
|
||||
})();
|
||||
@@ -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')}">×</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;
|
||||
">×</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);
|
||||
@@ -396,106 +469,120 @@ export function previewFile(fileUrl, fileName) {
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video"); // let so we can rebind
|
||||
video.controls = true;
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
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);
|
||||
|
||||
const videos = (Array.isArray(fileData) ? fileData : []).filter(f => VID_RE.test(f.name));
|
||||
overlay.mediaType = 'video';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
const setVideoSrc = (nm) => { video.src = buildPreviewUrl(folder, nm); setTitle(overlay, nm); };
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) { pending = false; console.error(e); return null; }
|
||||
}
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
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';
|
||||
return;
|
||||
/* -------------------- VIDEOS -------------------- */
|
||||
if (isVideo) {
|
||||
let video = document.createElement("video");
|
||||
video.controls = true;
|
||||
video.preload = 'auto'; // hint browser to start fetching quickly
|
||||
video.style.maxWidth = "88vw";
|
||||
video.style.maxHeight = "88vh";
|
||||
video.style.objectFit = "contain";
|
||||
container.appendChild(video);
|
||||
|
||||
// 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';
|
||||
overlay.mediaList = videos;
|
||||
overlay.mediaIndex = Math.max(0, videos.findIndex(f => f.name === name));
|
||||
setNavVisibility(overlay, videos.length > 1, videos.length > 1);
|
||||
|
||||
// Track which file is currently active
|
||||
let currentName = name;
|
||||
|
||||
const setVideoSrc = (nm) => {
|
||||
currentName = nm;
|
||||
video.src = buildPreviewUrl(folder, nm);
|
||||
setTitle(overlay, nm);
|
||||
};
|
||||
|
||||
const SAVE_INTERVAL_MS = 5000;
|
||||
let lastSaveAt = 0;
|
||||
let pending = false;
|
||||
|
||||
async function getProgress(nm) {
|
||||
try {
|
||||
const res = await fetch(`/api/media/getProgress.php?folder=${encodeURIComponent(folder)}&file=${encodeURIComponent(nm)}&t=${Date.now()}`, { credentials: "include" });
|
||||
const data = await res.json();
|
||||
return data && data.state ? data.state : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
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';
|
||||
return;
|
||||
|
||||
async function sendProgress({nm, seconds, duration, completed, clear}) {
|
||||
try {
|
||||
pending = true;
|
||||
const res = await fetch("/api/media/updateProgress.php", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": window.csrfToken },
|
||||
body: JSON.stringify({ folder, file: nm, seconds, duration, completed, clear })
|
||||
});
|
||||
const data = await res.json();
|
||||
pending = false;
|
||||
return data;
|
||||
} catch (e) {
|
||||
pending = false;
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// No progress
|
||||
statusChip.style.display = 'none';
|
||||
markBtn.style.display = '';
|
||||
clearBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
function bindVideoEvents(nm) {
|
||||
const nv = video.cloneNode(true);
|
||||
video.replaceWith(nv);
|
||||
video = nv;
|
||||
|
||||
|
||||
const lsKey = (nm) => `videoProgress-${folder}/${nm}`;
|
||||
|
||||
function renderStatus(state) {
|
||||
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';
|
||||
markBtnIcon.style.display = 'none';
|
||||
clearBtnIcon.style.display = '';
|
||||
clearBtnIcon.title = t('reset_progress') || t('clear_progress') || 'Reset';
|
||||
return;
|
||||
}
|
||||
|
||||
// In progress
|
||||
if (state && Number.isFinite(state.seconds) && Number.isFinite(state.duration) && state.duration > 0) {
|
||||
const pct = Math.max(1, Math.min(99, Math.round((state.seconds / state.duration) * 100)));
|
||||
statusChip.textContent = `${pct}%`;
|
||||
statusChip.style.display = 'inline-block';
|
||||
|
||||
const dark = document.documentElement.classList.contains('dark-mode');
|
||||
const ORANGE_HEX = '#ea580c';
|
||||
statusChip.style.color = ORANGE_HEX;
|
||||
statusChip.style.borderColor = dark ? 'rgba(234,88,12,.55)' : 'rgba(234,88,12,.45)';
|
||||
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';
|
||||
markBtnIcon.style.display = '';
|
||||
clearBtnIcon.style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- Event handlers (use currentName instead of rebinding per file) ----
|
||||
video.addEventListener("loadedmetadata", async () => {
|
||||
const nm = currentName;
|
||||
try {
|
||||
const state = await getProgress(nm);
|
||||
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 seconds = Math.floor(video.currentTime || 0);
|
||||
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));
|
||||
@@ -506,20 +593,24 @@ setFileProgressBadge(nm, seconds, duration);
|
||||
renderStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
video.addEventListener("timeupdate", async () => {
|
||||
const now = Date.now();
|
||||
if ((now - lastSaveAt) < SAVE_INTERVAL_MS || pending) return;
|
||||
lastSaveAt = now;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
|
||||
const nm = currentName;
|
||||
const seconds = Math.floor(video.currentTime || 0);
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
|
||||
sendProgress({ nm, seconds, duration });
|
||||
setFileProgressBadge(nm, seconds, duration);
|
||||
try { localStorage.setItem(lsKey(nm), String(seconds)); } catch {}
|
||||
renderStatus({ seconds, duration, completed: false });
|
||||
});
|
||||
|
||||
|
||||
video.addEventListener("ended", async () => {
|
||||
const nm = currentName;
|
||||
const duration = Math.floor(video.duration || 0);
|
||||
await sendProgress({ nm, seconds: duration, duration, completed: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
@@ -527,50 +618,54 @@ setFileProgressBadge(nm, seconds, duration);
|
||||
setFileWatchedBadge(nm, true);
|
||||
renderStatus({ seconds: duration, duration, completed: true });
|
||||
});
|
||||
|
||||
markBtn.onclick = async () => {
|
||||
|
||||
markBtnIcon.onclick = async () => {
|
||||
const nm = currentName;
|
||||
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 () => {
|
||||
const nm = currentName;
|
||||
await sendProgress({ nm, seconds: 0, duration: null, completed: false, clear: true });
|
||||
try { localStorage.removeItem(lsKey(nm)); } catch {}
|
||||
showToast(t("progress_cleared") || "Progress cleared");
|
||||
setFileWatchedBadge(nm, false);
|
||||
renderStatus(null);
|
||||
};
|
||||
}
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
bindVideoEvents(nm);
|
||||
};
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) { window.removeEventListener("keydown", onKey); return; }
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
|
||||
const navigate = (dir) => {
|
||||
if (!overlay.mediaList || overlay.mediaList.length < 2) return;
|
||||
overlay.mediaIndex = (overlay.mediaIndex + dir + overlay.mediaList.length) % overlay.mediaList.length;
|
||||
const nm = overlay.mediaList[overlay.mediaIndex].name;
|
||||
setVideoSrc(nm);
|
||||
renderStatus(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
|
||||
if (videos.length > 1) {
|
||||
prevBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(-1); });
|
||||
nextBtn.addEventListener('click', (e) => { e.stopPropagation(); navigate(+1); });
|
||||
const onKey = (e) => {
|
||||
if (!document.body.contains(overlay)) {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowLeft") navigate(-1);
|
||||
if (e.key === "ArrowRight") navigate(+1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
overlay._onKey = onKey;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoSrc(name);
|
||||
renderStatus(null);
|
||||
bindVideoEvents(name);
|
||||
overlay.style.display = "flex";
|
||||
return;
|
||||
}
|
||||
|
||||
/* -------------------- AUDIO / OTHER -------------------- */
|
||||
if (isAudio) {
|
||||
const audio = document.createElement("audio");
|
||||
|
||||
@@ -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">×</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">×</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));
|
||||
}
|
||||
@@ -312,7 +312,26 @@ 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 can’t 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.",
|
||||
"load_more_folders": "Load More Folders"
|
||||
},
|
||||
es: {
|
||||
"please_log_in_to_continue": "Por favor, inicie sesión para continuar.",
|
||||
|
||||
@@ -403,39 +403,91 @@ 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;
|
||||
|
||||
// --- Header logo (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const customLogoUrl = branding.customLogoUrl || "";
|
||||
const logoImg = document.querySelector('.header-logo img');
|
||||
if (logoImg) {
|
||||
if (customLogoUrl) {
|
||||
logoImg.setAttribute('src', customLogoUrl);
|
||||
logoImg.setAttribute('alt', 'Site logo');
|
||||
} else {
|
||||
// fall back to default FileRise logo
|
||||
logoImg.setAttribute('src', '/assets/logo.svg?v={{APP_QVER}}');
|
||||
logoImg.setAttribute('alt', 'FileRise');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// non-fatal; ignore branding issues
|
||||
}
|
||||
// --- Header colors (branding) in BOTH phases ---
|
||||
try {
|
||||
const branding = (cfg && cfg.branding) ? cfg.branding : {};
|
||||
const root = document.documentElement;
|
||||
|
||||
const light = branding.headerBgLight || '';
|
||||
const dark = branding.headerBgDark || '';
|
||||
|
||||
if (light) root.style.setProperty('--header-bg-light', light);
|
||||
else root.style.removeProperty('--header-bg-light');
|
||||
|
||||
if (dark) root.style.setProperty('--header-bg-dark', dark);
|
||||
else root.style.removeProperty('--header-bg-dark');
|
||||
} catch (e) {
|
||||
// non-fatal
|
||||
}
|
||||
|
||||
// --- 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 +1089,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 +1169,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(() => { });
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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 Drag–and–Drop 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 };
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// generated by CI
|
||||
window.APP_VERSION = 'v1.8.5';
|
||||
window.APP_VERSION = 'v1.9.11';
|
||||
|
||||
|
Before Width: | Height: | Size: 500 KiB After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 623 KiB After Width: | Height: | Size: 645 KiB |
|
Before Width: | Height: | Size: 269 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 687 KiB After Width: | Height: | Size: 694 KiB |
BIN
resources/filerise-v1.9.0.gif
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
resources/filerise-v1.9.7.png
Normal file
|
After Width: | Height: | Size: 656 KiB |
|
Before Width: | Height: | Size: 552 KiB After Width: | Height: | Size: 546 KiB |
BIN
resources/light-color-folder.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 754 KiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 608 KiB After Width: | Height: | Size: 706 KiB |
|
Before Width: | Height: | Size: 538 KiB After Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 610 KiB After Width: | Height: | Size: 632 KiB |
|
Before Width: | Height: | Size: 554 KiB After Width: | Height: | Size: 666 KiB |
54
scripts/manual-sync.sh
Normal 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 (don’t fail the whole script if reload isn’t 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
@@ -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 FileModel’s validation by calling it but not keeping the zip; we’ll 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);
|
||||
}
|
||||
@@ -6,91 +6,530 @@ require_once PROJECT_ROOT . '/src/models/AdminModel.php';
|
||||
|
||||
class AdminController
|
||||
{
|
||||
public function getConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$config = AdminModel::getConfig();
|
||||
if (isset($config['error'])) {
|
||||
http_response_code(500);
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
/** Enforce authentication (401). */
|
||||
private static function requireAuth(): void
|
||||
{
|
||||
if (empty($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Unauthorized']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Enforce admin (401). */
|
||||
private static function requireAdmin(): void
|
||||
{
|
||||
self::requireAuth();
|
||||
|
||||
// Prefer the session flag
|
||||
$isAdmin = (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true);
|
||||
|
||||
// Fallback: check the user’s role in storage (e.g., users.txt/DB)
|
||||
if (!$isAdmin) {
|
||||
$u = $_SESSION['username'] ?? '';
|
||||
if ($u) {
|
||||
try {
|
||||
// UserModel::getUserRole($u) should return '1' for admins
|
||||
$isAdmin = (UserModel::getUserRole($u) === '1');
|
||||
if ($isAdmin) {
|
||||
// Normalize session so downstream ACL checks see admin
|
||||
$_SESSION['isAdmin'] = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore and continue to deny
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$isAdmin) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Admin privileges required.']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
/** Get headers in lowercase, robust across SAPIs. */
|
||||
private static function headersLower(): array
|
||||
{
|
||||
$headers = function_exists('getallheaders') ? getallheaders() : [];
|
||||
$out = [];
|
||||
foreach ($headers as $k => $v) {
|
||||
$out[strtolower($k)] = $v;
|
||||
}
|
||||
// Fallbacks from $_SERVER if needed
|
||||
foreach ($_SERVER as $k => $v) {
|
||||
if (strpos($k, 'HTTP_') === 0) {
|
||||
$h = strtolower(str_replace('_', '-', substr($k, 5)));
|
||||
if (!isset($out[$h])) $out[$h] = $v;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Enforce CSRF using X-CSRF-Token header (or csrfToken param as fallback). */
|
||||
private static function requireCsrf(): void
|
||||
{
|
||||
$h = self::headersLower();
|
||||
$token = trim($h['x-csrf-token'] ?? ($_POST['csrfToken'] ?? ''));
|
||||
if (empty($_SESSION['csrf_token']) || $token !== $_SESSION['csrf_token']) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['error' => 'Invalid CSRF token']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read JSON body (empty array if not valid). */
|
||||
private static function readJson(): array
|
||||
{
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
|
||||
public function getConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
$config = AdminModel::getConfig();
|
||||
if (isset($config['error'])) {
|
||||
http_response_code(500);
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode(['error' => $config['error']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
|
||||
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
|
||||
$effEnabled = defined('ONLYOFFICE_ENABLED')
|
||||
? (bool) ONLYOFFICE_ENABLED
|
||||
: (bool) ($ooCfg['enabled'] ?? false);
|
||||
|
||||
$effDocs = (defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== '')
|
||||
? (string) ONLYOFFICE_DOCS_ORIGIN
|
||||
: (string) ($ooCfg['docsOrigin'] ?? '');
|
||||
|
||||
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
|
||||
? (ONLYOFFICE_JWT_SECRET !== '')
|
||||
: (!empty($ooCfg['jwtSecret']));
|
||||
|
||||
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
|
||||
|
||||
// ---- Pro / license info (all guarded for clean core installs) ----
|
||||
$licenseString = null;
|
||||
if (defined('PRO_LICENSE_FILE') && PRO_LICENSE_FILE && @is_file(PRO_LICENSE_FILE)) {
|
||||
$json = @file_get_contents(PRO_LICENSE_FILE);
|
||||
if ($json !== false) {
|
||||
$decoded = json_decode($json, true);
|
||||
if (is_array($decoded) && !empty($decoded['license'])) {
|
||||
$licenseString = (string) $decoded['license'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||
|
||||
// FR_PRO_INFO is only defined when bootstrap_pro.php has run; guard it
|
||||
$proPayload = [];
|
||||
if (defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)) {
|
||||
$p = FR_PRO_INFO['payload'] ?? null;
|
||||
if (is_array($p)) {
|
||||
$proPayload = $p;
|
||||
}
|
||||
}
|
||||
|
||||
$proType = $proPayload['type'] ?? null;
|
||||
$proEmail = $proPayload['email'] ?? null;
|
||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||
|
||||
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
||||
$public = [
|
||||
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||
],
|
||||
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
|
||||
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||
'oidc' => [
|
||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
// never include clientId/clientSecret
|
||||
],
|
||||
'onlyoffice' => [
|
||||
// Public only needs to know if it’s on; no secrets/origins here.
|
||||
'enabled' => $effEnabled,
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => (string)($config['branding']['customLogoUrl'] ?? ''),
|
||||
'headerBgLight' => (string)($config['branding']['headerBgLight'] ?? ''),
|
||||
'headerBgDark' => (string)($config['branding']['headerBgDark'] ?? ''),
|
||||
],
|
||||
'pro' => [
|
||||
'active' => $proActive,
|
||||
'type' => $proType,
|
||||
'email' => $proEmail,
|
||||
'version' => $proVersion,
|
||||
'license' => $licenseString,
|
||||
],
|
||||
];
|
||||
|
||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||
|
||||
if ($isAdmin) {
|
||||
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
|
||||
$adminExtra = [
|
||||
'loginOptions' => array_merge($public['loginOptions'], [
|
||||
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
||||
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
||||
]),
|
||||
'oidc' => array_merge($public['oidc'], [
|
||||
'hasClientId' => !empty($config['oidc']['clientId']),
|
||||
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
|
||||
]),
|
||||
'onlyoffice' => [
|
||||
'enabled' => $effEnabled,
|
||||
'docsOrigin' => $effDocs, // effective (constants win)
|
||||
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
|
||||
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
|
||||
'lockedByPhp' => (
|
||||
defined('ONLYOFFICE_ENABLED')
|
||||
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|
||||
|| defined('ONLYOFFICE_JWT_SECRET')
|
||||
|| defined('ONLYOFFICE_PUBLIC_ORIGIN')
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
header('Cache-Control: no-store'); // don’t cache admin config
|
||||
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-admins / unauthenticated: only the public subset
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
}
|
||||
|
||||
public function setLicense(): void
|
||||
{
|
||||
// Always respond JSON
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
// Same guards as other admin endpoints
|
||||
self::requireAuth();
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
$raw = file_get_contents('php://input');
|
||||
$data = json_decode($raw ?: '{}', true);
|
||||
if (!is_array($data)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid JSON body']);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Effective ONLYOFFICE values (constants override adminConfig) ----
|
||||
$ooCfg = is_array($config['onlyoffice'] ?? null) ? $config['onlyoffice'] : [];
|
||||
$effEnabled = defined('ONLYOFFICE_ENABLED')
|
||||
? (bool) ONLYOFFICE_ENABLED
|
||||
: (bool) ($ooCfg['enabled'] ?? false);
|
||||
$license = isset($data['license']) ? trim((string)$data['license']) : '';
|
||||
|
||||
$effDocs = defined('ONLYOFFICE_DOCS_ORIGIN') && ONLYOFFICE_DOCS_ORIGIN !== ''
|
||||
? (string) ONLYOFFICE_DOCS_ORIGIN
|
||||
: (string) ($ooCfg['docsOrigin'] ?? '');
|
||||
// Store license + updatedAt in JSON file
|
||||
if (!defined('PRO_LICENSE_FILE')) {
|
||||
// Fallback if constant not defined for some reason
|
||||
define('PRO_LICENSE_FILE', PROJECT_ROOT . '/users/proLicense.json');
|
||||
}
|
||||
|
||||
$hasSecret = defined('ONLYOFFICE_JWT_SECRET')
|
||||
? (ONLYOFFICE_JWT_SECRET !== '')
|
||||
: (!empty($ooCfg['jwtSecret']));
|
||||
|
||||
$publicOriginCfg = (string) ($ooCfg['publicOrigin'] ?? '');
|
||||
|
||||
// Whitelisted public subset only (+ ONLYOFFICE enabled flag)
|
||||
$public = [
|
||||
'header_title' => (string)($config['header_title'] ?? 'FileRise'),
|
||||
'loginOptions' => [
|
||||
'disableFormLogin' => (bool)($config['loginOptions']['disableFormLogin'] ?? false),
|
||||
'disableBasicAuth' => (bool)($config['loginOptions']['disableBasicAuth'] ?? false),
|
||||
'disableOIDCLogin' => (bool)($config['loginOptions']['disableOIDCLogin'] ?? false),
|
||||
],
|
||||
'globalOtpauthUrl' => (string)($config['globalOtpauthUrl'] ?? ''),
|
||||
'enableWebDAV' => (bool)($config['enableWebDAV'] ?? false),
|
||||
'sharedMaxUploadSize' => (int)($config['sharedMaxUploadSize'] ?? 0),
|
||||
'oidc' => [
|
||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
// never include clientId/clientSecret
|
||||
],
|
||||
'onlyoffice' => [
|
||||
// Public only needs to know if it’s on; no secrets/origins here.
|
||||
'enabled' => $effEnabled,
|
||||
],
|
||||
$payload = [
|
||||
'license' => $license,
|
||||
'updatedAt' => gmdate('c'),
|
||||
];
|
||||
|
||||
$isAdmin = !empty($_SESSION['authenticated']) && !empty($_SESSION['isAdmin']);
|
||||
|
||||
if ($isAdmin) {
|
||||
// admin-only extras: presence flags + proxy options + ONLYOFFICE effective view
|
||||
$adminExtra = [
|
||||
'loginOptions' => array_merge($public['loginOptions'], [
|
||||
'authBypass' => (bool)($config['loginOptions']['authBypass'] ?? false),
|
||||
'authHeaderName' => (string)($config['loginOptions']['authHeaderName'] ?? 'X-Remote-User'),
|
||||
]),
|
||||
'oidc' => array_merge($public['oidc'], [
|
||||
'hasClientId' => !empty($config['oidc']['clientId']),
|
||||
'hasClientSecret' => !empty($config['oidc']['clientSecret']),
|
||||
]),
|
||||
'onlyoffice' => [
|
||||
'enabled' => $effEnabled,
|
||||
'docsOrigin' => $effDocs, // effective (constants win)
|
||||
'publicOrigin' => $publicOriginCfg, // optional override from adminConfig
|
||||
'hasJwtSecret' => (bool)$hasSecret, // boolean only; never leak secret
|
||||
'lockedByPhp' => (
|
||||
defined('ONLYOFFICE_ENABLED')
|
||||
|| defined('ONLYOFFICE_DOCS_ORIGIN')
|
||||
|| defined('ONLYOFFICE_JWT_SECRET')
|
||||
),
|
||||
],
|
||||
];
|
||||
header('Cache-Control: no-store'); // don’t cache admin config
|
||||
echo json_encode(array_merge($public, $adminExtra), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
$dir = dirname(PRO_LICENSE_FILE);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create license dir']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-admins / unauthenticated: only the public subset
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode($public, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
return;
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT);
|
||||
if ($json === false || file_put_contents(PRO_LICENSE_FILE, $json) === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to write license file']);
|
||||
return;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true]);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Exception: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function installProBundle(): void
|
||||
{
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
try {
|
||||
// Guard rails: method + auth + CSRF
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
|
||||
http_response_code(405);
|
||||
echo json_encode(['success' => false, 'error' => 'Method not allowed']);
|
||||
return;
|
||||
}
|
||||
|
||||
self::requireAuth();
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
// Ensure ZipArchive is available
|
||||
if (!class_exists('\\ZipArchive')) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'ZipArchive extension is required on the server.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic upload validation
|
||||
if (empty($_FILES['bundle']) || !is_array($_FILES['bundle'])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Missing uploaded bundle (field "bundle").']);
|
||||
return;
|
||||
}
|
||||
|
||||
$f = $_FILES['bundle'];
|
||||
|
||||
if (!empty($f['error']) && $f['error'] !== UPLOAD_ERR_OK) {
|
||||
$msg = 'Upload error.';
|
||||
switch ($f['error']) {
|
||||
case UPLOAD_ERR_INI_SIZE:
|
||||
case UPLOAD_ERR_FORM_SIZE:
|
||||
$msg = 'Uploaded file exceeds size limit.';
|
||||
break;
|
||||
case UPLOAD_ERR_PARTIAL:
|
||||
$msg = 'Uploaded file was only partially received.';
|
||||
break;
|
||||
case UPLOAD_ERR_NO_FILE:
|
||||
$msg = 'No file was uploaded.';
|
||||
break;
|
||||
default:
|
||||
$msg = 'Upload failed with error code ' . (int)$f['error'];
|
||||
break;
|
||||
}
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => $msg]);
|
||||
return;
|
||||
}
|
||||
|
||||
$tmpName = $f['tmp_name'] ?? '';
|
||||
if ($tmpName === '' || !is_uploaded_file($tmpName)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid uploaded file.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Guard against unexpectedly large bundles (e.g., >100MB)
|
||||
$size = isset($f['size']) ? (int)$f['size'] : 0;
|
||||
if ($size <= 0 || $size > 100 * 1024 * 1024) {
|
||||
http_response_code(413);
|
||||
echo json_encode(['success' => false, 'error' => 'Bundle size is invalid or too large (max 100MB).']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Optional: require .zip extension by name (best-effort)
|
||||
$origName = (string)($f['name'] ?? '');
|
||||
if ($origName !== '' && !preg_match('/\.zip$/i', $origName)) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Bundle must be a .zip file.']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare temp working dir
|
||||
$tempRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR);
|
||||
$workDir = $tempRoot . DIRECTORY_SEPARATOR . 'filerise_pro_' . bin2hex(random_bytes(8));
|
||||
if (!@mkdir($workDir, 0700, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to prepare temp dir.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$zipPath = $workDir . DIRECTORY_SEPARATOR . 'bundle.zip';
|
||||
if (!@move_uploaded_file($tmpName, $zipPath)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to move uploaded bundle.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to open ZIP bundle.']);
|
||||
return;
|
||||
}
|
||||
|
||||
$installed = [
|
||||
'src' => [],
|
||||
'public' => [],
|
||||
'docs' => [],
|
||||
];
|
||||
|
||||
$projectRoot = rtrim(PROJECT_ROOT, DIRECTORY_SEPARATOR);
|
||||
|
||||
// Where Pro bundle code lives (defaults to PROJECT_ROOT . '/users/pro')
|
||||
$bundleRoot = defined('FR_PRO_BUNDLE_DIR')
|
||||
? rtrim(FR_PRO_BUNDLE_DIR, DIRECTORY_SEPARATOR)
|
||||
: ($projectRoot . DIRECTORY_SEPARATOR . 'users' . DIRECTORY_SEPARATOR . 'pro');
|
||||
|
||||
// Put README-Pro.txt / LICENSE-Pro.txt inside the bundle dir as well
|
||||
$proDocsDir = $bundleRoot;
|
||||
if (!is_dir($proDocsDir)) {
|
||||
@mkdir($proDocsDir, 0755, true);
|
||||
}
|
||||
|
||||
$allowedTopLevel = ['LICENSE-Pro.txt', 'README-Pro.txt'];
|
||||
|
||||
// Iterate entries and selectively extract/copy expected files only
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if ($name === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalise and guard
|
||||
$name = ltrim($name, "/\\");
|
||||
if ($name === '' || substr($name, -1) === '/') {
|
||||
continue; // skip directories
|
||||
}
|
||||
if (strpos($name, '../') !== false || strpos($name, '..\\') !== false) {
|
||||
continue; // path traversal guard
|
||||
}
|
||||
|
||||
// Ignore macOS Finder junk: __MACOSX and "._" resource forks
|
||||
$base = basename($name);
|
||||
if (
|
||||
str_starts_with($name, '__MACOSX/') ||
|
||||
str_contains($name, '/__MACOSX/') ||
|
||||
str_starts_with($base, '._')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$targetPath = null;
|
||||
$category = null;
|
||||
|
||||
if (in_array($name, $allowedTopLevel, true)) {
|
||||
// Docs → bundle dir (under /users/pro)
|
||||
$targetPath = $proDocsDir . DIRECTORY_SEPARATOR . $name;
|
||||
$category = 'docs';
|
||||
|
||||
} elseif (strpos($name, 'src/pro/') === 0) {
|
||||
// e.g. src/pro/bootstrap_pro.php -> FR_PRO_BUNDLE_DIR/bootstrap_pro.php
|
||||
$relative = substr($name, strlen('src/pro/'));
|
||||
if ($relative === '' || substr($relative, -1) === '/') {
|
||||
continue;
|
||||
}
|
||||
$targetPath = $bundleRoot . DIRECTORY_SEPARATOR . $relative;
|
||||
$category = 'src';
|
||||
|
||||
} elseif (strpos($name, 'public/api/pro/') === 0) {
|
||||
// e.g. public/api/pro/uploadBrandLogo.php
|
||||
$relative = substr($name, strlen('public/api/pro/'));
|
||||
if ($relative === '' || substr($relative, -1) === '/') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Persist under bundle dir so it survives image rebuilds:
|
||||
// users/pro/public/api/pro/...
|
||||
$targetPath = $bundleRoot
|
||||
. DIRECTORY_SEPARATOR . 'public'
|
||||
. DIRECTORY_SEPARATOR . 'api'
|
||||
. DIRECTORY_SEPARATOR . 'pro'
|
||||
. DIRECTORY_SEPARATOR . $relative;
|
||||
$category = 'public';
|
||||
} else {
|
||||
// Skip anything outside these prefixes
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$targetPath || !$category) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Track whether we're overwriting an existing file (for reporting only)
|
||||
$wasExisting = is_file($targetPath);
|
||||
|
||||
// Read from ZIP entry
|
||||
$stream = $zip->getStream($name);
|
||||
if (!$stream) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$dir = dirname($targetPath);
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||
fclose($stream);
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to create destination directory for ' . $name]);
|
||||
return;
|
||||
}
|
||||
|
||||
$data = stream_get_contents($stream);
|
||||
fclose($stream);
|
||||
if ($data === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to read data for ' . $name]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Always overwrite target file on install/upgrade
|
||||
if (@file_put_contents($targetPath, $data) === false) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to write ' . $name]);
|
||||
return;
|
||||
}
|
||||
|
||||
@chmod($targetPath, 0644);
|
||||
|
||||
// Track what we installed (and whether it was overwritten)
|
||||
if (!isset($installed[$category])) {
|
||||
$installed[$category] = [];
|
||||
}
|
||||
$installed[$category][] = $targetPath . ($wasExisting ? ' (overwritten)' : '');
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
// Best-effort cleanup; ignore failures
|
||||
@unlink($zipPath);
|
||||
@rmdir($workDir);
|
||||
|
||||
// Reflect current Pro status in response if bootstrap was loaded
|
||||
$proActive = defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE;
|
||||
$proPayload = defined('FR_PRO_INFO') && is_array(FR_PRO_INFO)
|
||||
? (FR_PRO_INFO['payload'] ?? null)
|
||||
: null;
|
||||
$proVersion = defined('FR_PRO_BUNDLE_VERSION') ? FR_PRO_BUNDLE_VERSION : null;
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Pro bundle installed.',
|
||||
'installed' => $installed,
|
||||
'proActive' => (bool)$proActive,
|
||||
'proVersion' => $proVersion,
|
||||
'proPayload' => $proPayload,
|
||||
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
} catch (\Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode([
|
||||
'success' => false,
|
||||
'error' => 'Exception during bundle install: ' . $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function updateConfig(): void
|
||||
{
|
||||
@@ -149,6 +588,11 @@ class AdminController
|
||||
'clientSecret'=> '',
|
||||
'redirectUri' => ''
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
],
|
||||
];
|
||||
|
||||
// header_title (cap length and strip control chars)
|
||||
@@ -250,6 +694,7 @@ class AdminController
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// —– persist merged config —–
|
||||
// ---- ONLYOFFICE: merge from payload (unless locked by PHP defines) ----
|
||||
@@ -286,6 +731,22 @@ class AdminController
|
||||
|
||||
$merged['onlyoffice'] = $oo;
|
||||
}
|
||||
// Branding: pass through raw strings; AdminModel enforces Pro + sanitization.
|
||||
if (isset($data['branding']) && is_array($data['branding'])) {
|
||||
if (!isset($merged['branding']) || !is_array($merged['branding'])) {
|
||||
$merged['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
];
|
||||
}
|
||||
foreach (['customLogoUrl', 'headerBgLight', 'headerBgDark'] as $key) {
|
||||
if (array_key_exists($key, $data['branding'])) {
|
||||
$merged['branding'][$key] = (string)$data['branding'][$key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$result = AdminModel::updateConfig($merged);
|
||||
if (isset($result['error'])) {
|
||||
http_response_code(500);
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -590,25 +643,137 @@ public function deleteFiles()
|
||||
} finally { $this->_jsonEnd(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a file with proper HTTP Range support so HTML5 video/audio can seek.
|
||||
*
|
||||
* @param string $fullPath Absolute filesystem path
|
||||
* @param string $downloadName Name shown in Content-Disposition
|
||||
* @param string $mimeType MIME type (from FileModel::getDownloadInfo)
|
||||
* @param bool $inline true => inline, false => attachment
|
||||
*/
|
||||
private function streamFileWithRange(string $fullPath, string $downloadName, string $mimeType, bool $inline): void
|
||||
{
|
||||
if (!is_file($fullPath) || !is_readable($fullPath)) {
|
||||
http_response_code(404);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'File not found']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$size = (int)@filesize($fullPath);
|
||||
$start = 0;
|
||||
$end = $size > 0 ? $size - 1 : 0;
|
||||
|
||||
if ($size < 0) {
|
||||
$size = 0;
|
||||
$end = 0;
|
||||
}
|
||||
|
||||
// Close session + disable output buffering for streaming
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
@session_write_close();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
$disposition = $inline ? 'inline' : 'attachment';
|
||||
$mime = $mimeType ?: 'application/octet-stream';
|
||||
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Accept-Ranges: bytes');
|
||||
header("Content-Type: {$mime}");
|
||||
header("Content-Disposition: {$disposition}; filename=\"" . basename($downloadName) . "\"");
|
||||
|
||||
// Handle HTTP Range header (single range)
|
||||
$length = $size;
|
||||
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/bytes=\s*(\d*)-(\d*)/i', $_SERVER['HTTP_RANGE'], $m)) {
|
||||
if ($m[1] !== '') {
|
||||
$start = (int)$m[1];
|
||||
}
|
||||
if ($m[2] !== '') {
|
||||
$end = (int)$m[2];
|
||||
}
|
||||
|
||||
// clamp to file size
|
||||
if ($start < 0) $start = 0;
|
||||
if ($end < $start) $end = $start;
|
||||
if ($end >= $size) $end = $size - 1;
|
||||
|
||||
$length = $end - $start + 1;
|
||||
|
||||
http_response_code(206);
|
||||
header("Content-Range: bytes {$start}-{$end}/{$size}");
|
||||
header("Content-Length: {$length}");
|
||||
} else {
|
||||
// no range => full file
|
||||
http_response_code(200);
|
||||
if ($size > 0) {
|
||||
header("Content-Length: {$size}");
|
||||
}
|
||||
}
|
||||
|
||||
$fp = @fopen($fullPath, 'rb');
|
||||
if ($fp === false) {
|
||||
http_response_code(500);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(['error' => 'Unable to open file.']);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($start > 0) {
|
||||
@fseek($fp, $start);
|
||||
}
|
||||
|
||||
$bytesToSend = $length;
|
||||
$chunkSize = 8192;
|
||||
|
||||
while ($bytesToSend > 0 && !feof($fp)) {
|
||||
$readSize = ($bytesToSend > $chunkSize) ? $chunkSize : $bytesToSend;
|
||||
$buffer = fread($fp, $readSize);
|
||||
if ($buffer === false) {
|
||||
break;
|
||||
}
|
||||
echo $buffer;
|
||||
flush();
|
||||
$bytesToSend -= strlen($buffer);
|
||||
|
||||
if (connection_aborted()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
fclose($fp);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function downloadFile()
|
||||
{
|
||||
if (!isset($_SESSION['authenticated']) || $_SESSION['authenticated'] !== true) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Unauthorized"]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$file = isset($_GET['file']) ? basename($_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim($_GET['folder']) : 'root';
|
||||
$file = isset($_GET['file']) ? basename((string)$_GET['file']) : '';
|
||||
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
|
||||
$inlineParam = isset($_GET['inline']) && (string)$_GET['inline'] === '1';
|
||||
|
||||
if (!preg_match(REGEX_FILE_NAME, $file)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Invalid file name."]);
|
||||
exit;
|
||||
}
|
||||
if ($folder !== 'root' && !preg_match(REGEX_FOLDER_NAME, $folder)) {
|
||||
http_response_code(400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Invalid folder name."]);
|
||||
exit;
|
||||
}
|
||||
@@ -628,6 +793,7 @@ public function deleteFiles()
|
||||
|
||||
if (!$fullView && !$ownGrant) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Forbidden: no view access to this folder."]);
|
||||
exit;
|
||||
}
|
||||
@@ -637,6 +803,7 @@ public function deleteFiles()
|
||||
$meta = $this->loadFolderMetadata($folder);
|
||||
if (!isset($meta[$file]['uploader']) || strcasecmp((string)$meta[$file]['uploader'], $username) !== 0) {
|
||||
http_response_code(403);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => "Forbidden: you are not the owner of this file."]);
|
||||
exit;
|
||||
}
|
||||
@@ -644,99 +811,235 @@ public function deleteFiles()
|
||||
|
||||
$downloadInfo = FileModel::getDownloadInfo($folder, $file);
|
||||
if (isset($downloadInfo['error'])) {
|
||||
http_response_code((in_array($downloadInfo['error'], ["File not found.", "Access forbidden."])) ? 404 : 400);
|
||||
http_response_code(in_array($downloadInfo['error'], ["File not found.", "Access forbidden."]) ? 404 : 400);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
echo json_encode(["error" => $downloadInfo['error']]);
|
||||
exit;
|
||||
}
|
||||
|
||||
$realFilePath = $downloadInfo['filePath'];
|
||||
$mimeType = $downloadInfo['mimeType'];
|
||||
header("Content-Type: " . $mimeType);
|
||||
|
||||
// Decide inline vs attachment:
|
||||
// - if ?inline=1 => always inline (used by filePreview.js)
|
||||
// - else keep your old behavior: images inline, everything else attachment
|
||||
$ext = strtolower(pathinfo($realFilePath, PATHINFO_EXTENSION));
|
||||
$inlineImageTypes = ['jpg','jpeg','png','gif','bmp','webp','svg','ico'];
|
||||
if (in_array($ext, $inlineImageTypes, true)) {
|
||||
header('Content-Disposition: inline; filename="' . basename($realFilePath) . '"');
|
||||
} else {
|
||||
header('Content-Disposition: attachment; filename="' . basename($realFilePath) . '"');
|
||||
}
|
||||
header('Content-Length: ' . filesize($realFilePath));
|
||||
readfile($realFilePath);
|
||||
exit;
|
||||
|
||||
$inline = $inlineParam || in_array($ext, $inlineImageTypes, true);
|
||||
|
||||
// Stream with proper Range support for video/audio seeking
|
||||
$this->streamFileWithRange($realFilePath, basename($realFilePath), $mimeType, $inline);
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 (we’ll 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 doesn’t 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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -649,8 +649,16 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
// Assuming /uploads maps to UPLOAD_DIR publicly
|
||||
$url = '/uploads/profile_pics/' . $filename;
|
||||
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
|
||||
|
||||
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
|
||||
$root = rtrim(PROJECT_ROOT, '/\\');
|
||||
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
|
||||
|
||||
// Ensure it starts with /
|
||||
if ($url === '' || $url[0] !== '/') {
|
||||
$url = '/' . $url;
|
||||
}
|
||||
|
||||
$result = UserModel::setProfilePicture($_SESSION['username'], $url);
|
||||
if (!($result['success'] ?? false)) {
|
||||
@@ -667,6 +675,76 @@ class UserController
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload branding logo (Pro-only; admin, CSRF).
|
||||
* Reuses the profile_pics directory but does NOT change the user's avatar.
|
||||
*/
|
||||
public function uploadBrandLogo()
|
||||
{
|
||||
self::jsonHeaders();
|
||||
|
||||
// Auth, admin & CSRF
|
||||
self::requireAuth();
|
||||
self::requireAdmin();
|
||||
self::requireCsrf();
|
||||
|
||||
if (empty($_FILES['brand_logo']) || $_FILES['brand_logo']['error'] !== UPLOAD_ERR_OK) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'No file uploaded or error']);
|
||||
exit;
|
||||
}
|
||||
$file = $_FILES['brand_logo'];
|
||||
|
||||
// Validate MIME & size (same rules as uploadPicture)
|
||||
$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif'];
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
$mime = finfo_file($finfo, $file['tmp_name']);
|
||||
finfo_close($finfo);
|
||||
if (!isset($allowed[$mime])) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'Invalid file type']);
|
||||
exit;
|
||||
}
|
||||
if ($file['size'] > 2 * 1024 * 1024) {
|
||||
http_response_code(400);
|
||||
echo json_encode(['success' => false, 'error' => 'File too large']);
|
||||
exit;
|
||||
}
|
||||
|
||||
// Destination: reuse profile_pics directory
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics';
|
||||
if (!is_dir($uploadDir) && !mkdir($uploadDir, 0755, true)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Cannot create upload folder']);
|
||||
exit;
|
||||
}
|
||||
|
||||
$ext = $allowed[$mime];
|
||||
$user = preg_replace('/[^a-zA-Z0-9_\-]/', '', $_SESSION['username'] ?? 'logo');
|
||||
$filename = 'branding_' . $user . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
|
||||
$dest = $uploadDir . '/' . $filename;
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['success' => false, 'error' => 'Failed to save file']);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
$fsPath = rtrim(UPLOAD_DIR, '/\\') . '/profile_pics/' . $filename;
|
||||
|
||||
// Remove the filesystem root (PROJECT_ROOT) so we get a web-relative path
|
||||
$root = rtrim(PROJECT_ROOT, '/\\');
|
||||
$url = preg_replace('#^' . preg_quote($root, '#') . '#', '', $fsPath);
|
||||
|
||||
// Ensure it starts with /
|
||||
if ($url === '' || $url[0] !== '/') {
|
||||
$url = '/' . $url;
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'url' => $url]);
|
||||
exit;
|
||||
}
|
||||
|
||||
public function siteConfig(): void
|
||||
{
|
||||
header('Content-Type: application/json');
|
||||
|
||||
390
src/lib/ACL.php
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,27 @@ class AdminModel
|
||||
return ($scheme === 'http' || $scheme === 'https') ? $url : '';
|
||||
}
|
||||
|
||||
/** Allow logo URLs that are either site-relative (/uploads/…) or http(s). */
|
||||
private static function sanitizeLogoUrl($url): string
|
||||
{
|
||||
$url = trim((string)$url);
|
||||
if ($url === '') return '';
|
||||
|
||||
// 1) Site-relative like "/uploads/profile_pics/branding_foo.png"
|
||||
if ($url[0] === '/') {
|
||||
// Strip CRLF just in case
|
||||
$url = preg_replace('~[\r\n]+~', '', $url);
|
||||
// Don’t allow sneaky schemes embedded in a relative path
|
||||
if (strpos($url, '://') !== false) {
|
||||
return '';
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
|
||||
// 2) Fallback to plain http(s) validation
|
||||
return self::sanitizeHttpUrl($url);
|
||||
}
|
||||
|
||||
public static function buildPublicSubset(array $config): array
|
||||
{
|
||||
$public = [
|
||||
@@ -89,6 +110,17 @@ class AdminModel
|
||||
'providerUrl' => (string)($config['oidc']['providerUrl'] ?? ''),
|
||||
'redirectUri' => (string)($config['oidc']['redirectUri'] ?? ''),
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => self::sanitizeLogoUrl(
|
||||
$config['branding']['customLogoUrl'] ?? ''
|
||||
),
|
||||
'headerBgLight' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgLight'] ?? ''
|
||||
),
|
||||
'headerBgDark' => self::sanitizeColorHex(
|
||||
$config['branding']['headerBgDark'] ?? ''
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
// NEW: include ONLYOFFICE minimal public flag
|
||||
@@ -226,6 +258,30 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
|
||||
$configUpdate['onlyoffice'] = $norm;
|
||||
}
|
||||
|
||||
// Branding (Pro-only). Normalize and only persist when Pro is active.
|
||||
if (!isset($configUpdate['branding']) || !is_array($configUpdate['branding'])) {
|
||||
$configUpdate['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
];
|
||||
} else {
|
||||
$logo = self::sanitizeLogoUrl($configUpdate['branding']['customLogoUrl'] ?? '');
|
||||
$light = self::sanitizeColorHex($configUpdate['branding']['headerBgLight'] ?? '');
|
||||
$dark = self::sanitizeColorHex($configUpdate['branding']['headerBgDark'] ?? '');
|
||||
|
||||
if (defined('FR_PRO_ACTIVE') && FR_PRO_ACTIVE) {
|
||||
$configUpdate['branding']['customLogoUrl'] = $logo;
|
||||
$configUpdate['branding']['headerBgLight'] = $light;
|
||||
$configUpdate['branding']['headerBgDark'] = $dark;
|
||||
} else {
|
||||
// Free mode: always clear branding customizations
|
||||
$configUpdate['branding']['customLogoUrl'] = '';
|
||||
$configUpdate['branding']['headerBgLight'] = '';
|
||||
$configUpdate['branding']['headerBgDark'] = '';
|
||||
}
|
||||
}
|
||||
|
||||
// Convert configuration to JSON.
|
||||
$plainTextConfig = json_encode($configUpdate, JSON_PRETTY_PRINT);
|
||||
@@ -267,6 +323,18 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
return ["success" => "Configuration updated successfully."];
|
||||
}
|
||||
|
||||
private static function sanitizeColorHex($value): string
|
||||
{
|
||||
$value = trim((string)$value);
|
||||
if ($value === '') return '';
|
||||
|
||||
// allow #RGB or #RRGGBB
|
||||
if (preg_match('/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $value)) {
|
||||
return strtoupper($value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current configuration.
|
||||
*
|
||||
@@ -368,6 +436,25 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
$config['onlyoffice']['publicOrigin'] = self::sanitizeHttpUrl($config['onlyoffice']['publicOrigin'] ?? '');
|
||||
}
|
||||
|
||||
// Branding
|
||||
if (!isset($config['branding']) || !is_array($config['branding'])) {
|
||||
$config['branding'] = [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
];
|
||||
} else {
|
||||
$config['branding']['customLogoUrl'] = self::sanitizeLogoUrl(
|
||||
$config['branding']['customLogoUrl'] ?? ''
|
||||
);
|
||||
$config['branding']['headerBgLight'] = self::sanitizeColorHex(
|
||||
$config['branding']['headerBgLight'] ?? ''
|
||||
);
|
||||
$config['branding']['headerBgDark'] = self::sanitizeColorHex(
|
||||
$config['branding']['headerBgDark'] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
@@ -393,6 +480,11 @@ $public['onlyoffice'] = ['enabled' => $ooEnabled];
|
||||
'docsOrigin' => '',
|
||||
'publicOrigin' => '',
|
||||
],
|
||||
'branding' => [
|
||||
'customLogoUrl' => '',
|
||||
'headerBgLight' => '',
|
||||
'headerBgDark' => '',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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 FileModel’s 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'];
|
||||
}
|
||||
}
|
||||
17
start.sh
@@ -72,6 +72,23 @@ for d in uploads users metadata; do
|
||||
chmod 775 "${tgt}"
|
||||
done
|
||||
|
||||
# 2.4) Sync FileRise Pro public endpoints from persistent bundle
|
||||
BUNDLE_PRO_PUBLIC="/var/www/users/pro/public/api/pro"
|
||||
LIVE_PRO_PUBLIC="/var/www/public/api/pro"
|
||||
|
||||
if [ -d "${BUNDLE_PRO_PUBLIC}" ]; then
|
||||
echo "[startup] Syncing FileRise Pro public endpoints..."
|
||||
mkdir -p "${LIVE_PRO_PUBLIC}"
|
||||
|
||||
# Copy files from bundle to live api/pro (overwrite for upgrades)
|
||||
cp -R "${BUNDLE_PRO_PUBLIC}/." "${LIVE_PRO_PUBLIC}/" || echo "[startup] Pro sync copy failed (continuing)"
|
||||
|
||||
# Normalize ownership/permissions
|
||||
chown -R www-data:www-data "${LIVE_PRO_PUBLIC}" || echo "[startup] chown api/pro failed (continuing)"
|
||||
find "${LIVE_PRO_PUBLIC}" -type d -exec chmod 755 {} \; 2>/dev/null || true
|
||||
find "${LIVE_PRO_PUBLIC}" -type f -exec chmod 644 {} \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 3) Ensure PHP conf dir & set upload limits
|
||||
mkdir -p /etc/php/8.3/apache2/conf.d
|
||||
if [ -n "${TOTAL_UPLOAD_SIZE:-}" ]; then
|
||||
|
||||